Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building REST API with Symfony: experience, errors and recipes

Roma
November 14, 2015

Building REST API with Symfony: experience, errors and recipes

Ever built a REST API? And did you try to do it with Symfony? I will tell the story about a small outsource team, which did all that within several projects. From handling request to generating proper responses. Through versioning, error handling, access and rights control, testing, documenting and more. With examples of our bad decisions and recipes to good ones. This talk will give you the perspective on how to use powerful tools in both good and bad ways with examples from real projects.

Roma

November 14, 2015
Tweet

More Decks by Roma

Other Decks in Programming

Transcript

  1. whoami Co-founder and CEO at Evercode Lab. Developer transformed into

    manager, occasional public speaker and consultant and table- foosball fan.
  2. What this talk is NOT about Ideology Terminology Safe/Unsafe Idempotency

    RFC2616 PUT or PATCH How to use PATCH HTTP Cache Unicorns Religion
  3. What this talk is NOT about Ideology Terminology Safe/Unsafe Idempotency

    RFC2616 PUT or PATCH How to use PATCH HTTP Cache Unicorns Religion
  4. …and why? Because despite of great components, bundles and documentation

    in Symfony world there are too many overcomplications and not enough recipes for RESTful APIs.
  5. …and why? Because despite of great components, bundles and documentation

    in Symfony world there are too many overcomplications and not enough recipes for RESTful APIs.
  6. …and why? Because despite of great components, bundles and documentation

    in Symfony world there are too many overcomplications and not enough recipes for RESTful APIs. And REST itself is not that easy.
  7. Who are the users/clients of our APIs? Mobile applications (iOS,

    Android etc.) Frontend Applications (Angular, Backbone etc.) Most of our APIs aren’t open to everyone.
  8. ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ

    ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web
  9. ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ

    ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web
  10. ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ

    ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web One bundle with division for specific classes like controllers, listeners, tests, etc.
  11. ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ

    ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web One bundle with division for specific classes like controllers, listeners, tests, etc.
  12. ʮʒʒ Api ʔ ʮʒʒ Cart ʔ ʔ ʮʒʒ Adapters ʔ

    ʔ ʮʒʒ ChoiceLists ʔ ʔ ʦʒʒ Dto ʔ ʮʒʒ Exception ʔ ʮʒʒ Notifications ʔ ʦʒʒ User ʔ ʮʒʒ Adapters ʔ ʮʒʒ Dto ʔ ʮʒʒ Event ʔ ʦʒʒ Exception ʮʒʒ ApiBundle ʔ ʮʒʒ Controller ʔ ʮʒʒ Form ʔ ʔ ʦʒʒ Subscribers ʔ ʮʒʒ Handler ʔ ʮʒʒ Listener ʔ ʮʒʒ Tests ʔ ʦʒʒ Validator ʔ ʦʒʒ Constraints ʮʒʒ App ʔ ʦʒʒ Entity ʔ ʮʒʒ Exception ʔ ʦʒʒ Repository ʦʒʒ AppBundle
  13. ʮʒʒ Api ʔ ʮʒʒ Cart ʔ ʔ ʮʒʒ Adapters ʔ

    ʔ ʮʒʒ ChoiceLists ʔ ʔ ʦʒʒ Dto ʔ ʮʒʒ Exception ʔ ʮʒʒ Notifications ʔ ʦʒʒ User ʔ ʮʒʒ Adapters ʔ ʮʒʒ Dto ʔ ʮʒʒ Event ʔ ʦʒʒ Exception ʮʒʒ ApiBundle ʔ ʮʒʒ Controller ʔ ʮʒʒ Form ʔ ʔ ʦʒʒ Subscribers ʔ ʮʒʒ Handler ʔ ʮʒʒ Listener ʔ ʮʒʒ Tests ʔ ʦʒʒ Validator ʔ ʦʒʒ Constraints ʮʒʒ App ʔ ʦʒʒ Entity ʔ ʮʒʒ Exception ʔ ʦʒʒ Repository ʦʒʒ AppBundle Better isolation, but harder to find things and more complex structure to support.
  14. json or what? Support of multiple formats on input and

    output is cool Symfony and FOSRestBundle allow to do it But it always adds complexity Do you really need it?
  15. json or what? fos_rest: format_listener: false view: view_response_listener: force formats:

    xml: false json: true templating_formats: html: false body_listener: decoders: json: fos_rest.decoder.jsontoform
  16. FOSRestBundle Allows to send payload in json and process it

    like forms (Request params, actually) Output data in any set of formats you really need Integrates with JMSSerializerBundle to do it easily
  17. Forms $form = $this->createForm(new OrderType()); $form->handleRequest($request); if (!$form->isValid()) { return

    $this->returnBadRequest(new InputFormatError($this->gatherFormErrors($form))); }
  18. Forms $form = $this->createForm(new OrderType()); $form->handleRequest($request); if (!$form->isValid()) { return

    $this->returnBadRequest(new InputFormatError($this->gatherFormErrors($form))); }
  19. Forms abstract class AbstractRestApiType extends AbstractType { public function configureOptions(OptionsResolver

    $resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } }
  20. Forms abstract class AbstractRestApiType extends AbstractType { public function configureOptions(OptionsResolver

    $resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } }
  21. Forms abstract class AbstractRestApiType extends AbstractType { public function configureOptions(OptionsResolver

    $resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } }
  22. Validator component namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints

    as Assert; class Promocode extends Entity { /** * @ORM\Column(type="string", length=60, nullable=false) * @Assert\NotBlank() * @var string */ private $code;
  23. Gathering errors protected function gatherFormErrors(Form $form) { $errors = [];

    foreach ($form->getErrors(true) as $err) { $errors[$err->getOrigin()->getName()][] = $err->getMessage(); } return $errors; }
  24. namespace Api\Address\Dto; use JMS\Serializer\Annotation as JMS; use Api\Search\Dto\Location; class Address

    { /** * Id of address * @JMS\Type("integer") * @var int */ public $id; /** * City * @JMS\Type("string") * @var string */ public $city; /** * Location * @JMS\Type("Api\Search\Dto\Location") * @var Location */ public $location; …
  25. public function getUserAddresses(User $user) { $addressesAsDto = []; foreach ($user->getAddresses()

    as $address) { $addressesAsDto[] = $this->adapterFactory ->userAddressAdapter() ->transform($address); } return $addressesAsDto; }
  26. Why? Less magic, more control Custom serialization rules and virtual

    properties Isolate entities from response (safe changes) Not so bloated entities Ease up documentation
  27. Pretty facts about errors How you handle and output errors

    is one of the most important parts of your API.
  28. Pretty facts about errors How you handle and output errors

    is one of the most important parts of your API. And let’s be honest in most cases it is the last thing you pay attention to.
  29. Silver bullet? HTTP/1.1 403 Forbidden Content-Type: application/problem+json Content-Language: en {

    "type": "http://example.com/probs/out-of-credit", "title": "You do not have enough credit.", "detail": "Your current balance is 30, but that costs 50.", "instance": "http://example.net/account/12345/msgs/abc", "balance": 30, "accounts": ["http://example.net/account/12345", "http://example.net/account/67890"] } Thanks, KnpUniversity!
  30. class ApiProblem { const TYPE_VALIDATION_ERROR = ‘validation_error'; const TYPE_INVALID_REQUEST_BODY_FORMAT =

    'invalid_body_format'; … private static $titles = array( … ); private $statusCode; private $type; private $extraData = array(); public function toArray() { return array_merge( $this->extraData, [ 'status' => $this->statusCode, 'type' => $this->type, 'title' => $this->title, ]); }
  31. class ApiProblemException extends HttpException { private $apiProblem; public function __construct(ApiProblem

    $apiProblem, \Exception $previous = null, array $headers = array(), { $this->apiProblem = $apiProblem; $statusCode = $apiProblem->getStatusCode(); $message = $apiProblem->getTitle(); parent::__construct( $statusCode, $message, $previous, $headers, $code ); } } }
  32. Then what? The ApiProblem object knows everything about how the

    response should look You extend and throw ApiProblemException for all the problems you get You need a common place to catch them and transform into a proper response
  33. onKernelException public function onKernelException(GetResponseForExceptionEvent $event) { $exception = $event->getException(); if

    ($exception instanceof BadRequestHttpException) { } if ($exception instanceof ApiProblemException) { $response = $exception->getApiProblem()->toArray(); } if ($exception instanceof AccessDeniedHttpException) { } $event->setResponse($response); }
  34. onKernelException Limit to api routes only Add debug option to

    show more information in dev Catch and process other HttpExceptions you need
  35. NelmioApiDocBundle /** * @ApiDoc( * section="Catalog", * resource=true, * description="Returns

    list of places depending on location and preferred delivery time", * output="Api\Search\Dto\SearchResult", * statusCodes={ * 200="Returned when one or more places found or nothing found", * } * ) */ public function searchCatalogAction(Request $request)
  36. DTO for output /** * @ApiDoc( * section="Catalog", * resource=true,

    * description="Returns list of places depending on location and preferred delivery time", * output="Api\Search\Dto\SearchResult", * statusCodes={ * 200="Returned when one or more places found or nothing found", * } * ) */ public function searchCatalogAction(Request $request)
  37. Forms for input /** * @ApiDoc( * section="Orders", * resource=true,

    * input="ApiBundle\Form\CreateFirstOrderType", * output="Api\Order\CreatedOrder", * description="Creates address and order and returns order number", * statusCodes={ * 200="Returned when order created successfully", * 400="Returned when input parameters are invalid" * }, * ) * @Rest\Post( * "/orders/first" * ) */ public function createFirstOrderAction(Request $request)
  38. QueryParam for filterinng * @Rest\Get( * "/catalog/search" * ) *

    @Rest\QueryParam( * name="latitude", * requirements="-?\d+(\.\d+)?", * strict=true, * description="Destination latitude" * ) * @Rest\QueryParam( * name="longitude", * requirements="-?\d+(\.\d+)?", * strict=true, * description="Destination longitude" * )
  39. Security Use any mechanism you like to ensure user access

    (token, oAuth, etc.) Check the access based on blacklist/whitelist of routes in the listener Use Voters to check for other complicated access rules