(Build a social network) API with boring code and Symfony
Asmir Mustafic
San Diego PHP Meetup - February 2017
WHO AM I?
Asmir Mustafic
- Twitter: @goetas_asmir
- Github: @goetas
- LinkedIn: @goetas
- WWW: goetas.com
Bosnia, Italy, Germany
Work
software engineer
Helping startups to deal with their tech stack
Open source
- doctrine2 (contributor)
- jms/serializer (maintainer)
- html5-php (maintainer)
- xsd2php (author)
- twital (author)
- and many other...
contributed/contributing to:
Feel free to interrupt me during the presentation
feedback is welcome
Personal experience
A lot of...
- Slides
- Symfony
- Annotations
- Code
collabout.com
collabout.com
Marketplace for
sponsors
and
event organizers
collabout.com
collabout.com
Is this a social network?
~
collabout.com
symfony 3.2 (api)
+
AngularJS 1.6 (frontend)
API
API
Requirements
(do the job)
Functional requirements
CRUD
- Users
- Events
- Companies
- Messages
- mark-as-read
- Friendship
- ask confirm ignore
More logic
-
Authentication
- password, token, oauth...
-
Authorization
- who can see my data
- who can contact me
- what am I allowed to do
-
Application behavior
- send notifications
- save interaction results
- ...
Non-functional
requirements
Rich
Fast
Up
Stable
Easy to change
Fast to develop
Documented
(and many more)
Easy:
to develop
to change
to use
Documented
documentation and code must grow together
otherwise they will go
out of sync
RESTFul
http://stackoverflow.com/questions/20335967/how-useful-important-is-rest-hateoas-maturity-level-3
HATEOAS
GET /account/12345
HTTP/1.1 200 OK
Content-Type: application/xml
Content-Length: ...
<?xml version="1.0"?>
<account>
<account_number>12345</account_number>
<balance currency="usd">100.00</balance>
<link rel="deposit" href="https://somebank.org/account/12345/deposit" />
<link rel="withdraw" href="https://somebank.org/account/12345/withdraw" />
<link rel="transfer" href="https://somebank.org/account/12345/transfer" />
<link rel="close" href="https://somebank.org/account/12345/close" />
</account>
It looks pretty hard,
right?
It is not!
How to reach the goal?
Let's write some code
Boring code
Boring controllers
- max 10 lines of code for each action
- no other methods (protected/private helpers)
- no conditionals/control structures (almost)
- single responsibility principle
- dependency injection as much as possible
Boring services
- same level of abstraction
- no framework dependencies (only controllers?)
- framework-deps != libraries-deps
- allow easy extraction to separate packages/services/microservices
Pragmatic approach
Implementation
Symfony
(used just as a glue for many other [cool] libraries)
Services
Entities
Controllers
+
authorization + serialization + input handling + validation
Services
will skip this topic
use dependency injection
Entities
(or models...)
doctrine/orm
/** @ORM\Entity */
class User [implements UserInterface, \Serializable, EquatableInterface]
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/** @ORM\Column(length=255, unique=true) */
private $email;
/** @ORM\OneToMany(targetEntity="AppBundle\Entity\Event", mappedBy="createdBy") */
private $events;
/** @ORM\OneToMany(targetEntity="AppBundle\Entity\Company", mappedBy="createdBy") */
private $companies;
// more more fields
public function __construct(UuidInterface $id = null)
{
$this->id = $id ?: Uuid::uuid4();
$this->createdAt = new \DateTime();
$this->connections = new ArrayCollection();
$this->events = new ArrayCollection();
$this->companies = new ArrayCollection();
}
// more methods
}
/** @ORM\Entity */
class Company
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/** @ORM\Column(type="string") */
private $name;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="companies")
* @ORM\JoinColumn(name="created_by_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $createdBy;
// more more fields
public function __construct(User $createdBy, UuidInterface $id = null)
{
$this->id = $id ?: Uuid::uuid4();
$this->createdBy = $createdBy;
$this->createdAt = new \DateTime();
}
// more methods
}
/** @ORM\Entity */
class Event
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/** @ORM\Column(type="string") */
private $name;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="events")
* @ORM\JoinColumn(name="created_by_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $createdBy;
// more more fields
public function __construct(User $createdBy, UuidInterface $id = null)
{
$this->id = $id ?: Uuid::uuid4();
$this->createdBy = $createdBy;
$this->createdAt = new \DateTime();
}
// more methods
}
/** @ORM\Entity */
class Connection
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="connections")
* @ORM\JoinColumn(name="from_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $from;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="connected")
* @ORM\JoinColumn(name="to_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $to;
/** @ORM\Column(type="string") */
private $status = 'requested';
public function __construct(
User $from,
User $to
)
// more methods
}
/** @ORM\Entity */
class Message
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
* @ORM\JoinColumn(name="from_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $from;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
* @ORM\JoinColumn(name="to_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $to;
/** @ORM\Column(type="text") */
private $text;
public function __construct(
User $from,
User $to,
string $text
)
// more more fields
// more methods
}
Doctrine tip
Use UUID
$user = new User();
echo $user->getId();
$em->persist($user);
echo $user->getId();
$em->flush();
echo $user->getId();
query count | echo |
---|---|
0 | null |
0 | null |
1 | 5 |
query count | echo |
---|---|
0 | null |
1 | 5 |
2 | 5 |
query count | echo |
---|---|
0 | 57d9dbcd... |
0 | 57d9dbcd... |
1 | 57d9dbcd... |
autoincrement
sequence
uuid
Controllers
Company Controller
[GET|DELETE|POST|PUT] /company/{id}
/** @Route(service="app.controller.company") */
class CompanyController
{
/** @var ObjectManager */
private $em;
/** @var CompanyRepository */
private $repo;
/** @var TokenStorageInterface */
private $tokenStorage;
/** @var FormFactoryInterface */
private $formFactory;
public function __construct(
ObjectManager $em,
CompanyRepository $companyRepository,
TokenStorageInterface $tokenStorage,
FormFactoryInterface $formFactory)
{
$this->em = $em;
$this->repo = $companyRepository;
$this->tokenStorage = $tokenStorage;
$this->formFactory = $formFactory;
}
// ...
}
/**
* @Route("/company/{id}", name="company_get")
* @Method({"GET"})
*
* @ParamConverter("company", options={
* "param" = "id",
* })
*
* @Security("is_granted('COMPANY_VIEW', company)")
*/
public function getAction(Company $company): View
{
return new View($company, Response::HTTP_OK);
}
GET
/**
* @Route("/company/", name="company_list")
* @Method({"GET"})
*
* @QueryParam(name="name", nullable=true)
* @QueryParam(name="place", map=true)
*/
public function listAction(ParamFetcher $paramFetcher, Request $request): View
{
$filters = $paramFetcher->all();
$count = $this->repo->countByFilters($filters);
// pagination with HTTP Range header
list ($start, $limit) = RangeUtils::extract($request, 20, 50);
$companies = $this->repo->findByFilters($filters, ['date' => 'DESC'], $start, $limit);
return new View($companies, Response::HTTP_PARTIAL_CONTENT, [
"Content-Range" => RangeUtils::create($start, count($companies), $count),
"Vary" => "Accept-Encoding,Accept-Language,Range",
]);
}
GET Collection
/**
* @Route("/company/{id}", name="company_delete")
* @Method({"DELETE"})
*
* @Security("is_granted('COMPANY_DELETE', company)")
*/
public function deleteAction(Company $company): View
{
$this->em->remove($company);
$this->em->flush();
return new View(null, Response::HTTP_NO_CONTENT);
}
DELETE
/**
* @Route("/{id}", name="company_post")
* @Method({"POST"})
*
* @Security("is_granted('COMPANY_EDIT', company)")
*/
public function postAction(Company $company, Request $request): View
{
$form = $this->formFactory->createNamedBuilder('', CompanyType::class, $company)->getForm();
$form->submit($request->request->all(), false);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($company);
$this->em->flush();
return new View($company, Response::HTTP_OK);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
POST
/**
* @Route("/company/{id}", name="company_put")
* @Method({"PUT"})
*
* @Security("is_granted('COMPANY_CREATE')")
*/
public function putAction(Request $request): View
{
$company = new Company($this->tokenStorage->getToken()->getUser());
$form = $this->formFactory->createNamedBuilder('', CompanyType::class, $company)->getForm();
$form->submit($request->request->all());
if ($form->isValid()) {
$this->em->persist($company);
$this->em->flush();
return new View($company, Response::HTTP_CREATED);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PUT
Event Controller
[GET|DELETE|POST|PUT] /event/{id}
Event Controller
Identical to Company Controller
Ctrl+R "company" -> "event" (preserve case)
User Controller
[GET|DELETE|POST|PUT] /user/{id}
User Controller
Identical to Company Controller
Ctrl+R "company" -> "user" (preserve case)
Friendship Controller
[GET|PUT|DELETE] /user/{id}/connections/{friend}
/**
* @Route("/user/{id}/connections", service="app.controller.user_connections")
*
* @ParamConverter("user", options={
* "param" = "id",
* })
*
* @Security("is_granted('USER_EDIT', user)")
*/
class FriendshipController
{
/** @var ObjectManager */
private $em;
/** @var ConnectionRepository */
private $repo;
/** @var FriendshipManager */
private $friendshipManager;
public function __construct(
ObjectManager $em,
FriendshipManager $friendshipManager,
FriendshipRepository $friendshipRepository
)
{
$this->friendshipManager = $friendshipManager;
$this->repo = $friendshipRepository;
$this->em = $em;
}
// actions
}
/**
* @Route("/{connection}", name="user_connections_get")
* @Method({"GET"})
*
* @ParamConverter("friend", options={
* "param" = "connection",
* })
*
* @Security("is_granted('USER_EDIT', user)")
*/
public function getAction(User $user, User $friend): View
{
$friendship = $this->repo->findOneBy(['from' => $user, 'to' => $friend]);
return new View($friendship, $friendship ? Response::HTTP_OK : Response::HTTP_NOT_FOUND);
}
GET
/**
* @Route("/", name="user_connections_list")
* @Method({"GET"})
*
* @Security("is_granted('USER_EDIT', user)")
*/
public function listAction(User $user, ParamFetcher $paramFetcher, Request $request): View
{
$filters = ['from' => $user];
$count = $this->repo->countByFilters($filters);
// pagination using HTTP Range
list ($start, $limit) = RangeUtils::extract($request, 20, 50);
$friendships = $this->repo->findByFilters($filters, [], $start, $limit);
return $this->view($friendships, Response::HTTP_PARTIAL_CONTENT, [
"Content-Range" => RangeUtils::create($start, count($friendships), $count),
"Vary" => "Accept-Encoding,Accept-Language,Range",
]);
}
GET Collection
/**
* @Route("/{friend}", name="user_connections_put")
* @Method({"PUT"})
*
* @ParamConverter("friend", options={
* "param" = "friend",
* })
*
* @Security("is_granted('USER_EDIT', user) and is_granted('USER_CONNECTION_ADD', friend)")
*/
public function connectAction(User $user, User $friend): View
{
$connection = $this->friendshipManager->connect($user, $friend);
$this->em->flush();
return new View($connection, Response::HTTP_CREATED);
}
PUT
/**
* @Route("/{friend}", name="user_connections_get")
* @Method({"DELETE"})
*
* @ParamConverter("friend", options={
* "param" = "friend",
* })
*
* @Security("is_granted('USER_EDIT', user)")
*/
public function deleteAction(User $user, User $friend): View
{
if ($friendship = $this->repo->findOneBy(['from' => $user, 'to' => $friend])) {
$this->em->remove($friendship);
$this->em->flush();
return $this->view(null, Response::HTTP_NO_CONTENT);
}
return $this->view(null, Response::HTTP_NOT_FOUND);
}
DELETE
Messages Controller
[GET|PUT|POST] /user/{id}/messages/{friend}
/**
* @Route("/messages/{id}", service="app.controller.user_messages")
*
* @ParamConverter("user", options={
* "param" = "id",
* })
*/
class MessagesController
{
/** @var ObjectManager */
private $em;
/** @var MessageRepository */
private $repo;
/** @var AppMailer */
private $mailer;
public function __construct(
ObjectManager $em,
MessageRepository $messageRepository,
AppMailer $mailer
)
{
$this->em = $em;
$this->mailer = $mailer;
$this->repo = $messageRepository;
}
// actions
}
/**
* @Route("/{to}/", name="message_get")
* @Method({"GET"})
*
* @ParamConverter("to", options={
* "param" = "to",
* })
*
* @Security("is_granted('USER_EDIT', user)")
*/
public function getAction(User $user, User $to, Request $request, ParamFetcher $paramFetcher): View
{
list ($start, $limit) = RangeUtils::extract($request, 20, 50);
$count = $this->repo->countMessages($user, $to);
$messages = $this->repo->findMessages($user, $to, $start, $limit);
return $this->view($messages, Response::HTTP_PARTIAL_CONTENT, [
"Content-Range" => RangeUtils::create($start, count($messages), $count),
"Vary" => "Accept-Encoding,Accept-Language,Range",
]);
}
GET Collection
/**
* @Route("/{to}/", name="message_put")
* @Method({"PUT"})
*
* @ParamConverter("to", options={
* "param" = "to",
* })
*
* @Security("is_granted('USER_SEND_MESSAGE', to)")
*/
public function createMessage(User $user, User $to, Request $request): View
{
$message = new Message($user, $to, $request->get("text"));
$this->em->persist($message);
$this->em->flush();
$this->mailer->sendNotification($message);
return $this->view($message, Response::HTTP_CREATED);
}
PUT
/**
* @Route("/{to}/read", name="message_read")
* @Method({"POST"})
*
* @ParamConverter("to", options={
* "param" = "to",
* })
*
* @Security("is_granted('USER_EDIT', user)")
*/
public function markAsReadAction(User $user, User $to): View;
/**
* @Route("/conversations", name="message_get_conversations")
* @Method({"GET"})
*
* @Security("is_granted('USER_EDIT', user)")
*/
public function conversationsAction(User $user): View;
other...
Serialization to JSON
jms/serializer
// in addition to existing annotation
class User implements UserInterface, \Serializable, EquatableInterface
{
/**
* @Serializer\Type("string")
*/
private $id;
/**
* @Serializer\Type("string")
* @Serializer\Expose(if="service('app.connection_permission').loggedAs(object)")
*/
private $email;
/**
* @Serializer\Type("DateTime<'Y-m-d H:i:s'>")
* @Serializer\Expose(if="service('app.connection_permission').canShareInfo(object)")
*/
private $lastActive;
/**
* @Serializer\Type("DateTime<'Y-m-d H:i:s'>")
*/
private $registered;
// more fields here
}
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"email":"me@example.com",
"last_active":"2017-01-23 16:30:04",
"registered":"2017-01-23 16:30:04"
}
/** @ORM\Entity */
class Company
{
/**
* @Serializer\Type("string")
*/
private $id;
/**
* @Serializer\Type("string")
*/
private $name;
/**
* @Serializer\Type("string")
* @Serializer\Expose(
* if="service('app.connection_permission').canShareInfo(object.getCreatedBy())"
* )
*/
private $link;
// more more fields
}
{
"id":"ab9745f0-2abc-40ce-81ab-93766dda8cf1",
"name":"Acme",
"link":"http://www.example.com"
}
And so on...
HATEOAS
willdurand/hateoas
// in addition to existing annotation
/**
* @Hateoas\Relation("self",
* href = @Hateoas\Route(
* "user_get",
* parameters = {"id" = "expr(object.getId())"}
* )
* )
* @Hateoas\Relation("conversations",
* href = @Hateoas\Route(
* "message_get_conversations",
* parameters = {"id" = "expr(object.getId())"}
* )
* )
* @Hateoas\Relation("add_as_friend",
* href = @Hateoas\Route(
* "user_connections_put",
* generator="templated_uri",
* parameters = {
* "friend" = "expr(object.getId())",
* "id" = "{user}"
* }
* ),
* attributes={"templated":true}
* )
*/
class User implements UserInterface, \Serializable, EquatableInterface
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"name":"Splash",
"email":"me@example.com",
"_links":{
"self":{
"href":"http:\/\/www.example.com\/api\/user\/f357c49c..."
},
"conversations":{
"href":"http:\/\/www.example.com\/api\/messages\/f357c49c...\/conversations"
},
"add_as_friend":{
"href":"http:\/\/www.example.com\/api\/user\/{user}\/connections\/f357c49c...",
"templated":true
}
}
}
/**
* @Hateoas\Relation("self",
* href = @Hateoas\Route("company_get", parameters = {"id" = "expr(object.getId())"})
* )
* @Hateoas\Relation("logo",
* href = @Hateoas\Route("company_logo_get", parameters = {"id" = "expr(object.getId())"})
* )
* @Hateoas\Relation(
* name = "created_by",
* embedded = @Hateoas\Embedded(
* "expr(object.getCreatedBy())"
* )
* )
*/
class Company
{
"id":"ab9745f0-2abc-40ce-81ab-93766dda8cf1",
"name":"Acme",
"link":"http://www.example.com",
"_links":{
"self":{
"href":"http:\/\/www.example.com\/api\/company\/ab9745f0..."
},
"logo":{
"href":"http:\/\/www.example.com\/api\/company\/ab9745f0...\/logo"
}
},
"_embedded":{
"created_by":{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"name":"Splash",
"email":"me@example.com",
"_links":{
"self":{
"href":"http:\/\/www.example.com\/api\/user\/f357c49c..."
},
"conversations":{
"href":"http:\/\/www.example.com\/api\/messages\/f357c49c...\/conversations"
},
"add_as_friend":{
"href":"http:\/\/www.example.com\/api\/user\/{user}\/connections\/f357c49c...",
"templated":true
}
}
}
}
}
And so on...
HTTP Input
symfony/form
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('name');
$builder->add('email');
$builder->add('bio');
$builder->add('place');
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', User::class);
$resolver->setDefault('allow_extra_fields', true);
}
}
User POST/PUT
POST /user/f357c49c-2abc-40ce-81ab-93766dda8cf1
Content-Type: application/json
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"email":"me@example.com",
"_links":{
"self":{
"href":"http:\/\/www.example.com\/api\/user\/f357c49c..."
},
"conversations":{
"href":"http:\/\/www.example.com\/api\/messages\/f357c49c...\/conversations"
},
"add_as_friend":{
"href":"http:\/\/www.example.com\/api\/user\/{user}\/connections\/f357c49c...",
"templated":true
}
}
}
User POST/PUT
class CompanyType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name')
->add('link')
->add('description')
->add('published', CheckboxType::class)
->add('location')
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', Company::class);
$resolver->setDefault('allow_extra_fields', true);
}
}
Company POST/PUT
POST /company/f357c49c-2abc-40ce-81ab-93766dda8cf1
Content-Type: application/json
{
"id":"ab9745f0-2abc-40ce-81ab-93766dda8cf1",
"name":"Acme",
"link":"http://www.example.com",
"_links":{
"self":{
"href":"http:\/\/www.example.com\/api\/company\/ab9745f0..."
},
"logo":{
"href":"http:\/\/www.example.com\/api\/company\/ab9745f0...\/logo"
}
},
"_embedded":{
"created_by":{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"name":"Splash",
"email":"me@example.com"
}
}
}
Company POST/PUT
And so on...
Validation
symfony/validator
// in addition to existing annotation
/**
* @UniqueEntity("email, message="This email is already registered")
*/
class User implements UserInterface, \Serializable, EquatableInterface
{
// more fields here
/**
* @Assert\Email(checkHost=true)
* @Assert\NotBlank
*/
private $email;
/**
* @Assert\NotBlank
* @Assert\Length(min="3")
*/
private $name;
// more fields here
}
User
{
"code":400,
"message":"Validation Failed",
"errors":{
"children":{
"name":{
"errors":[
"This value should not be blank."
]
},
"email":{
"errors":[
"This email is already registered."
]
},
"description":{
}
}
}
}
User - errors
// in addition to existing annotation
class Company
{
// more fields here
/**
* @Assert\NotBlank
* @Assert\Url(checkDNS=true)
*/
private $link;
/**
* @Assert\NotBlank
* @Assert\Length(min="3")
*/
private $name;
// more fields here
}
Company
{
"code":400,
"message":"Validation Failed",
"errors":{
"children":{
"link":{
"errors":[
"This value should not be blank."
]
},
"name":{
}
}
}
}
Company - errors
And so on...
Authorization
symfony/security
security voters
class CompanyVoter extends Voter
{
protected function supports($attribute, $subject)
{
...
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!($user instanceof User)) {
// the user must be logged in; if not, deny access
return false;
}
/** @var Company $subject */
switch ($attribute) {
case 'COMPANY_CREATE':
return $this->canCreate($user);
case 'COMPANY_EDIT':
return $this->canEdit($subject, $user);
case 'COMPANY_DELETE':
return $this->canDelete($subject, $user);
case 'COMPANY_VIEW':
return $this->canView($subject, $user);
}
throw new \LogicException('This code should not be reached!');
}
}
Documentation
nelmio/api-doc-bundle
/**
* Save a user.
*
* @ApiDoc(
* resource=true,
* section="User",
* statusCodes={
* 200="Returned when successful",
* 404="Returned when the user is not found",
* 400="Returned when validation fails"
* },
* input="AppBundle\Form\Type\UserType",
* output="AppBundle\Entity\User"
* )
*/
public function postAction(User $user, Request $request)
{
//...
}
POST /user/{id}
And so on...
What to take home?
symfony/framework
friendsofsymfony/rest-bundle
doctrine/orm
jms/serializer
willdurand/hateoas
symfony/form
symfony/validation
symfony/security
nelmio/api-doc-bundle
(and may other packages...)
Open source software is awesome!
use small libraries
that do (well) few things
and can be glued together
many small (good?) decisions
allowed to produce a (good?) result
without (big?) efforts
Thank you!
An API with boring code
By Asmir Mustafic
An API with boring code
- 2,946