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

Building REST API with Symfony: experience, errors and recipes

101b5adab18a468a3dfe63f77980ccb9?s=47 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.

101b5adab18a468a3dfe63f77980ccb9?s=128

Roma

November 14, 2015
Tweet

Transcript

  1. Building REST API with Symfony: experience, errors and recipes Roman

    Lapin, Evercode Lab roma@evercodelab.com, @memphys
  2. whoami Co-founder and CEO at Evercode Lab. Developer transformed into

    manager, occasional public speaker and consultant and table- foosball fan.
  3. Intro

  4. What this talk is NOT about Ideology Terminology Safe/Unsafe Idempotency

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

    RFC2616 PUT or PATCH How to use PATCH HTTP Cache Unicorns Religion
  6. What this talk IS about… Practical aspects of building RESTful

    APIs using Symfony with some examples.
  7. …and why?

  8. …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.
  9. …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.
  10. …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.
  11. Richardson model http://martinfowler.com/articles/richardsonMaturityModel.html

  12. –said someone “REST has a lot of rules to break”

  13. 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.
  14. Why Symfony for RESTful API

  15. Because it knows about HTTP verbs Request/Response structures Validation Security

    and all kinds of other stuff
  16. Because bundles FOSRestBundle JMSSerializerBundle NelmioApiDocBundle …

  17. Project structure

  18. ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ

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

    ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web
  20. ʦʒʒ 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.
  21. ʦʒʒ 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.
  22. ʮʒʒ 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
  23. ʮʒʒ 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.
  24. Handling requests

  25. Separate endpoint In the browser: /categories /orders In the API:

    /api/v1/categories /api/v1/orders
  26. 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?
  27. 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
  28. 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
  29. Forms $form = $this->createForm(new OrderType()); $form->handleRequest($request); if (!$form->isValid()) { return

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

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

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

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

    $resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } }
  34. ParamFetcher fos_rest: param_fetcher_listener: true

  35. ParamFetcher $title = $paramFetcher->get(‘title’); $venue = $this->get(‘app.venue.manager') ->create($paramFetcher->all(), $user); //

    Force validation and in the service without forms
  36. Validation and errors

  37. 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;
  38. Gathering errors protected function gatherFormErrors(Form $form) { $errors = [];

    foreach ($form->getErrors(true) as $err) { $errors[$err->getOrigin()->getName()][] = $err->getMessage(); } return $errors; }
  39. Sending responses

  40. Building blocks JMSSerializerBundle Model is not always directly a representation

    Use DTO to control response
  41. 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; …
  42. public function getUserAddresses(User $user) { $addressesAsDto = []; foreach ($user->getAddresses()

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

    properties Isolate entities from response (safe changes) Not so bloated entities Ease up documentation
  44. Errors structure

  45. Pretty facts about errors

  46. Pretty facts about errors How you handle and output errors

    is one of the most important parts of your API.
  47. 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.
  48. 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!
  49. 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, ]); }
  50. 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 ); } } }
  51. Then what? $apiProblem = new ApiProblem( 400, ApiProblem::TYPE_INVALID_REQUEST_BODY_FORMAT ); throw

    new ApiProblemException($apiProblem);
  52. 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
  53. 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); }
  54. onKernelException Limit to api routes only Add debug option to

    show more information in dev Catch and process other HttpExceptions you need
  55. Documentation NelmioApiDocBundle

  56. 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)
  57. 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)
  58. 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)
  59. 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" * )
  60. None
  61. None
  62. None
  63. Versioning

  64. Choose wisely URI: /api/v1/cart Content-type: application/vnd.app.cart-v1+xml Accept: application/vnd.app.cart+xml;v=1

  65. Choose wisely URI: /api/v1/cart Content-type: application/vnd.app.cart-v1+xml Accept: application/vnd.app.cart+xml;v=1

  66. URI versioning cart: type: rest resource: ApiBundle\Controller\CartController name_prefix: api_v1_ prefix:

    /api/v1
  67. Final thoughts

  68. 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
  69. Just be consistent in Request/Response format Errors structure Naming stuff

    (like resources and fields) Status codes
  70. naming resources /api/v1/admins /api/v1/admins/{admin} /api/v1/avatars /api/v1/categories /api/v1/employees /api/v1/employees/{id} /api/v1/rate

  71. You should read it http://williamdurand.fr/2012/08/02/rest-apis-with- symfony2-the-right-way/ http://welcometothebundle.com/symfony2-rest-api- the-best-2013-way/ https://www.ietf.org/rfc/rfc2616.txt

  72. You should watch this http://williamdurand.fr/2015/06/02/video-nobody- understands-rest/ https://knpuniversity.com/tracks/rest RESTful APIs in

    the Real World Symfony RESTful API
  73. You should thank them https://twitter.com/couac https://twitter.com/lsmith https://twitter.com/weaverryan All the creators

    of and contributors to Symfony, all the libraries and bundles around it
  74. Thanks! For all the feedback either good or bad use:

    @memphys roma@evercodelab.com
  75. Thanks! For all the feedback either good or bad use:

    @memphys roma@evercodelab.com