(Build a social network) API with boring code and Symfony

Asmir Mustafic

San Diego PHP Meetup - February 2017

WHO AM I?

Asmir Mustafic

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,774