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

Building a first class REST API with Symfony

Michael C.
September 28, 2018

Building a first class REST API with Symfony

Michael C.

September 28, 2018
Tweet

More Decks by Michael C.

Other Decks in Programming

Transcript

  1. BUILDING FIRST-CLASS REST APIS WITH
    SYMFONY
    @MICHAELCULLUMUK
    SYMFONY LIVE LONDON 2018

    View full-size slide

  2. BUILDING FIRST-CLASS REST APIS WITH
    SYMFONY
    @MICHAELCULLUMUK
    SYMFONY LIVE LONDON 2018

    View full-size slide

  3. @MICHAELCULLUMUK
    ME?

    View full-size slide

  4. MICHAEL CULLUM
    @MICHAELCULLUMUK

    View full-size slide

  5. @MICHAELCULLUMUK
    APIS

    View full-size slide

  6. @MICHAELCULLUMUK
    WHAT IS REST?

    View full-size slide

  7. @MICHAELCULLUMUK
    REPRESENTATIONAL
    STATE TRANSFER

    View full-size slide

  8. @MICHAELCULLUMUK
    REPRESENTATIONAL
    STATE TRANSFER

    View full-size slide

  9. @MICHAELCULLUMUK
    ▸ Uniform Interface
    REPRESENTATIONAL STATE TRANSFER (REST)

    View full-size slide

  10. @MICHAELCULLUMUK
    ▸ Uniform Interface
    ▸ Stateless
    REPRESENTATIONAL STATE TRANSFER (REST)

    View full-size slide

  11. @MICHAELCULLUMUK
    ▸ Uniform Interface
    ▸ Stateless
    ▸ Cacheable
    REPRESENTATIONAL STATE TRANSFER (REST)

    View full-size slide

  12. @MICHAELCULLUMUK
    ▸ Uniform Interface
    ▸ Stateless
    ▸ Cacheable
    ▸ Client-Server
    REPRESENTATIONAL STATE TRANSFER (REST)

    View full-size slide

  13. @MICHAELCULLUMUK
    ▸ Uniform Interface
    ▸ Stateless
    ▸ Cacheable
    ▸ Client-Server
    ▸ Layered System
    REPRESENTATIONAL STATE TRANSFER (REST)

    View full-size slide

  14. @MICHAELCULLUMUK
    ▸ Uniform Interface
    ▸ Stateless
    ▸ Cacheable
    ▸ Client-Server
    ▸ Layered System
    ▸ Code on demand (Optional)
    REPRESENTATIONAL STATE TRANSFER (REST)

    View full-size slide

  15. @MICHAELCULLUMUK
    SO WHAT?

    View full-size slide

  16. @MICHAELCULLUMUK
    HTTP RESPONSE CODES

    View full-size slide

  17. @MICHAELCULLUMUK
    USE HTTP WELL

    View full-size slide

  18. @MICHAELCULLUMUK
    HTTP VERBS

    View full-size slide

  19. @MICHAELCULLUMUK
    THE RIGHT HTTP VERBS

    View full-size slide

  20. @MICHAELCULLUMUK
    HTTP RESPONSE CODES

    View full-size slide

  21. @MICHAELCULLUMUK
    ▸ 200 - All is right with the world
    ▸ 301 / 302 - Turn around, walk down the road and where you’re looking for is on the right
    ▸ 400 - You made a mistake
    ▸ 401 or 403 - You can’t see this, it’s a secret
    ▸ 404 - I can’t find what you’re asking for
    ▸ 405 - Fix your HTTP verbs
    ▸ 500 - Something is wrong with me but I don’t know what. Sorry.
    BASIC HTTP CODES

    View full-size slide

  22. @MICHAELCULLUMUK
    ▸ 201 - Created something OK
    ▸ 202 - Accepted but will be properly processed in the background
    ▸ 422 - Validation Error
    ▸ 401 - Login!
    ▸ 403 - You’re not supposed to be here
    ▸ 410 - This resource did exist, but it’s now been deleted or disabled
    ▸ 429 - You’ve hit a rate limit, steady on there
    ▸ 503 - API is not here right now, please try again later
    BASIC HTTP CODES

    View full-size slide

  23. @MICHAELCULLUMUK
    RESPONSE CODES
    $response->setStatusCode(422);

    View full-size slide

  24. @MICHAELCULLUMUK
    RESPONSE CODES
    $response->setStatusCode(Response::UNPROCESSABLE_ENTITY);

    View full-size slide

  25. @MICHAELCULLUMUK
    RESPONSE CODES
    namespace Symfony\Component\HttpFoundation;
    class Response
    {
    const HTTP_OK = 200;
    const HTTP_CREATED = 201;
    const HTTP_ACCEPTED = 202;
    const HTTP_TEMPORARY_REDIRECT = 307;
    const HTTP_PERMANENTLY_REDIRECT = 308;
    const HTTP_BAD_REQUEST = 400;
    const HTTP_UNAUTHORIZED = 401;
    const HTTP_PAYMENT_REQUIRED = 402;
    const HTTP_FORBIDDEN = 403;
    const HTTP_NOT_FOUND = 404;
    const HTTP_METHOD_NOT_ALLOWED = 405;
    const HTTP_NOT_ACCEPTABLE = 406;
    const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;
    const HTTP_REQUEST_TIMEOUT = 408;
    const HTTP_CONFLICT = 409;
    const HTTP_GONE = 410;
    const HTTP_LENGTH_REQUIRED = 411;
    const HTTP_PRECONDITION_FAILED = 412;
    const HTTP_REQUEST_ENTITY_TOO_LARGE = 413;
    const HTTP_REQUEST_URI_TOO_LONG = 414;
    const HTTP_UNSUPPORTED_MEDIA_TYPE = 415;
    const HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416;
    const HTTP_EXPECTATION_FAILED = 417;
    const HTTP_I_AM_A_TEAPOT = 418;
    const HTTP_UNPROCESSABLE_ENTITY = 422;
    const HTTP_TOO_MANY_REQUESTS = 429;
    const HTTP_INTERNAL_SERVER_ERROR = 500;
    const HTTP_NOT_IMPLEMENTED = 501;
    const HTTP_BAD_GATEWAY = 502;
    const HTTP_SERVICE_UNAVAILABLE = 503;
    const HTTP_GATEWAY_TIMEOUT = 504;
    }

    View full-size slide

  26. @MICHAELCULLUMUK
    ERRORS

    View full-size slide

  27. @MICHAELCULLUMUK
    AN EVENT
    class ExceptionListener
    {
    /**
    * @param GetResponseForExceptionEvent $event
    */
    public function onKernelException(GetResponseForExceptionEvent $event): void
    {
    $e = $event->getException();
    if ($e instanceof ValidationException) {
    $response = new ValidationErrorResponse($e->getErrors());
    } else {
    $response = JsonResponse::create(
    [
    'code' => 'UNKNOWN_ERROR',
    'message' => $e->getMessage(),
    ],
    Response::HTTP_INTERNAL_SERVER_ERROR
    );
    }
    $event->setResponse($response);
    }
    }

    View full-size slide

  28. @MICHAELCULLUMUK
    EXCEPTION CONTROLLER OVERRIDE
    declare(strict_types=1);
    namespace SamKnows\Common\Controller;
    class ExceptionController extends DefaultExceptionController
    {
    public function showAction(Request $request, FlattenException $exception, DebugLoggerInterface $logger = null): Response
    }

    View full-size slide

  29. @MICHAELCULLUMUK
    EXCEPTION CONTROLLER OVERRIDE
    public function showAction(Request $request, FlattenException $exception, DebugLoggerInterface $logger = null)
    {
    $code = $exception->getStatusCode();
    $body = $this->getError($exception, $this->debug);
    $response = new JsonResponse(
    $body,
    $code
    );
    $response->headers->set('Content-Type', 'application/json; charset=UTF-8');
    return $response;
    }

    View full-size slide

  30. @MICHAELCULLUMUK
    EXCEPTION CONTROLLER OVERRIDE
    public function getError(FlattenException $exception)
    {
    $error = [];
    $mapping = $this->errorMap[$exception->getClass()];
    $error['code'] = $mapping[‘code'] ?? 'UNKNOWN_ERROR',
    $error['message'] = $mapping[‘message'] ?? 'Unknown error';
    if ($exception->getClass() === ValidationException::class) {
    $error['errors'] = $exception->getErrors();
    }
    if ($this->twig->isDebug()) {
    $error['debug'] = sprintf(
    '%s in file %s amd line %d',
    $exception->getMessage(),
    $exception->getFile(),
    $exception->getLine()
    );
    }
    return $error;
    }
    private $errorMap = [
    AccessDeniedException::class => [
    'code' => 'ACCESS_DENIED',
    'message' => 'Access Denied',
    ],
    ValidationException::class => [
    'code' => 'VALIDATION_FAIL',
    'message' => 'Validation failed',
    ],
    ];

    View full-size slide

  31. @MICHAELCULLUMUK
    DTO ALL THE THINGS

    View full-size slide

  32. @MICHAELCULLUMUK
    PARAM CONVERTER IS
    YOUR FRIEND

    View full-size slide

  33. @MICHAELCULLUMUK
    PARAM CONVERTOR + DTO
    public function postAddressesAction(AddressCreateDTO $addressCreateDTO): Response
    {
    // ...
    $address = $this->entityPersister->mapAndPersist($addressCreateDTO, new Address());
    // ...
    }

    View full-size slide

  34. @MICHAELCULLUMUK
    DTO PARAMETER CONVERTOR
    class DataTransferObjectConverter implements ParamConverterInterface
    {
    // Constructor etc. ...
    public function apply(Request $request, ParamConverter $configuration)
    {
    $data = $request->get('content');
    $class = $configuration->getClass();
    $dto = $this->serializer->denormalize($data, $class);
    if ($request->files->count() > 0) {
    $dto = $this->serializer->denormalize($request->files->all(), $class, [
    'object_to_populate' => $dto,
    ]);
    }
    $request->attributes->set($configuration->getName(), $dto);
    return true;
    }
    // supports() etc. ...
    }

    View full-size slide

  35. @MICHAELCULLUMUK
    VALIDATION

    View full-size slide

  36. @MICHAELCULLUMUK
    EXCEPTIONS!

    View full-size slide

  37. @MICHAELCULLUMUK
    REMOVE CONTROL
    FROM CONTROLLERS

    View full-size slide

  38. @MICHAELCULLUMUK
    CONTROLLER
    public function postAddressesAction(AddressCreateDTO $addressCreateDTO): Response
    {
    try {
    $address = $this->entityPersister->mapAndPersist($addressCreateDTO);
    } catch (ValidationException $exception) {
    return new ValidationErrorResponse($exception);
    }
    return $this->created(
    'addresses_item',
    [
    'id' => $address->getId(),
    ]
    );
    }

    View full-size slide

  39. @MICHAELCULLUMUK
    FORMATTING YOUR
    OUTPUT

    View full-size slide

  40. @MICHAELCULLUMUK
    SYMFONY SERIALIZER
    COMPONENT

    View full-size slide

  41. @MICHAELCULLUMUK
    TRANFORMERS

    View full-size slide

  42. @MICHAELCULLUMUK
    FRACTAL

    View full-size slide

  43. @MICHAELCULLUMUK
    FRACTAL TRANSFORMER
    class AddressTransformer extends TransformerAbstract
    {
    protected $availableIncludes = [
    'account',
    ];
    public function transform(Address $address)
    {
    return [
    'id' => $address->getId(),
    'address_line_1' => $address->getAddressLine1(),
    'address_line_2' => $address->getAddressLine2(),
    'address_line_3' => $address->getAddressLine3(),
    'county' => $address->getCounty(),
    'city' => $address->getCity(),
    'postcode' => $address->getPostcode(),
    'country' => $address->getCountry(),
    'updated_at' => $address->getUpdatedAt() ? $address->getUpdatedAt()->getTimestamp() : null,
    ];
    }
    }

    View full-size slide

  44. @MICHAELCULLUMUK
    FRACTAL TRANSFORMER
    // Create your resource, or collection of resources
    $resource = new Fractal\Resource\Item($book, new BookTransformer);
    $resource = new Fractal\Resource\Collection($books, new BookTransformer);
    // Turn that into a structured array (handy for XML views or auto-YAML converting)
    $array = $fractal->createData($resource)->toArray();
    // Turn all of that into a JSON string
    $fractal->createData($resource)->toJson();

    View full-size slide

  45. @MICHAELCULLUMUK
    PAGINATION AND LINKS

    View full-size slide

  46. @MICHAELCULLUMUK
    NOBODY LIKES
    PAGINATION

    View full-size slide

  47. @MICHAELCULLUMUK
    ▸ Do it
    ▸ Link to the next and previous pages
    ▸ Detail the current page number
    ▸ Detail the total number of pages
    REST PAGINATION TODO LIST

    View full-size slide

  48. @MICHAELCULLUMUK
    DISABLING A TOTAL CAN
    MAKE THINGS QUICKER

    View full-size slide

  49. @MICHAELCULLUMUK
    DOCTRINE PAGINATOR

    View full-size slide

  50. @MICHAELCULLUMUK
    PAGERFANTA

    View full-size slide

  51. @MICHAELCULLUMUK
    FRACTAL INTEGRATION

    View full-size slide

  52. @MICHAELCULLUMUK
    PAGINATED OR NOT
    public function getUsersAction(Request $request, UserFilters $filters): Response
    {
    // ...
    $paginator = $this->grantRepository->findAllPaginated($filters);
    return $this->success($paginator, UserTransformer::class, $request->query->get('include'));
    }
    public function getUsersAction(Request $request, GrantFilters $filters): Response
    {
    // ...
    $users = $this->grantRepository->findAll($filters);
    return $this->success($users, UserTransformer::class, $request->query->get('include'));
    }

    View full-size slide

  53. @MICHAELCULLUMUK
    GETTING IT TO YOUR
    REPOSITORY

    View full-size slide

  54. @MICHAELCULLUMUK
    PUT IT IN AN OBJECT
    public function getUsersAction(Request $request, UserFilters $filters): Response
    {
    // ...
    $paginator = $this->grantRepository->findAllPaginated($filters);
    return $this->success($paginator, UserTransformer::class, $request->query->get('include'));
    }

    View full-size slide

  55. @MICHAELCULLUMUK
    SORTS AND FILTERS

    View full-size slide

  56. @MICHAELCULLUMUK
    THERE’S AN OBJECT
    FOR THAT

    View full-size slide

  57. @MICHAELCULLUMUK
    QUERY STRING
    [GET] /users?page=1&per_page=20&sorts=-id&include=groups%2Caddress&email=michael
    Pagination Sorting Includes Filters

    View full-size slide

  58. @MICHAELCULLUMUK
    CONTROLLER
    public function getAddressesAction(Request $request, AddressFilters $filters): Response

    View full-size slide

  59. @MICHAELCULLUMUK
    FILTER CLASS
    class AddressFilters
    {
    use PaginationFilterTrait;
    use SortingFiltersTrait;
    public function getUser()
    {
    return $this->user;
    }
    /**
    * @param int $user
    */
    public function setUser($user)
    {
    $this->user = $user;
    }
    }
    trait PaginationFilterTrait
    {
    private $page = 1;
    private $perPage = 10;
    // ... Getters and setters...
    }
    trait SortingFiltersTrait
    {
    private $sorts = [];
    // ... Getters and setters ...
    }

    View full-size slide

  60. @MICHAELCULLUMUK
    REPOSITORY
    public function findAllPaginated(AddressFilters $addressFilters)
    {
    $queryBuilder = $this->createQueryBuilder('address');
    $this->addFilter($queryBuilder, 'user', $addressFilters->getUser());
    $this->addSorts($queryBuilder, $filter->getSorts());
    return $this->createPaginator($queryBuilder, $addressFilters->getPage(), $addressFilters->getPerPage());
    }

    View full-size slide

  61. @MICHAELCULLUMUK
    ADD SORTS AND ADD FILTERS METHODS
    protected function addSorts(QueryBuilder $queryBuilder, array $sorts): void
    {
    if (count($sorts) > 0) {
    foreach ($sorts as $sort) {
    $field = $this->getFieldFromMap($sort[0]);
    if ($field) {
    $queryBuilder->addOrderBy($field, $sort[1]);
    }
    }
    }
    }
    protected function addFilter(QueryBuilder $queryBuilder, $name, $value, string $parameter = null): void
    {
    $field = $this->getFieldFromMap($name);
    if ($value === null || $value == '') {
    return;
    }
    if ($parameter === null) {
    $parameter = $name;
    }
    if ($field) {
    $queryBuilder->andWhere(sprintf('%s = :%s', $field, $parameter));
    $queryBuilder->setParameter($parameter, $value);
    }
    }

    View full-size slide

  62. @MICHAELCULLUMUK
    TOOLS

    View full-size slide

  63. @MICHAELCULLUMUK
    API PLATFORM

    View full-size slide

  64. @MICHAELCULLUMUK
    API PLATFORM
    composer require api

    View full-size slide

  65. @MICHAELCULLUMUK
    DEMO

    View full-size slide

  66. @MICHAELCULLUMUK
    FOS REST BUNDLE

    View full-size slide

  67. @MICHAELCULLUMUK
    EXCEPTION CONTROLLER
    fos_rest:
    exception:
    codes:
    'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
    'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
    messages:
    'Acme\HelloBundle\Exception\MyExceptionWithASafeMessage': true

    View full-size slide

  68. @MICHAELCULLUMUK
    VIEW LAYER
    public function getUsersAction()
    {
    $data = ...; // get data, in this case list of users.
    $view = $this->view($data, 200)
    ->setTemplate("MyBundle:Users:getUsers.html.twig")
    ->setTemplateVar('users')
    ;
    return $this->handleView($view);
    }

    View full-size slide

  69. @MICHAELCULLUMUK
    AUTOMATIC ROUTING
    # routes.yml
    users:
    type: rest
    host: m.example.com
    resource: Acme\Controller\UsersController
    namespace AppBundle\Controller;
    class UsersController
    {
    public function getUsersAction()
    {} // "get_users" [GET] /users
    public function postUsersAction()
    {} // "post_users" [POST] /users
    public function putUserAction($slug)
    {} // "put_user" [PUT] /users/{slug}
    public function patchUserAction($slug)
    {} // "patch_user" [PATCH] /users/{slug}
    public function deleteUserAction($slug)
    {} // "delete_user" [DELETE] /users/{slug}
    }

    View full-size slide

  70. @MICHAELCULLUMUK
    RECAP

    View full-size slide

  71. @MICHAELCULLUMUK
    ▸ Principles of REST
    RECAP

    View full-size slide

  72. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    RECAP

    View full-size slide

  73. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ Error handling
    RECAP

    View full-size slide

  74. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ DTOs and the Param Converter
    RECAP

    View full-size slide

  75. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ DTOs and the Param Converter
    ▸ Validation
    RECAP

    View full-size slide

  76. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ DTOs and the Param Converter
    ▸ Validation
    ▸ Formatting output
    RECAP

    View full-size slide

  77. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ DTOs and the Param Converter
    ▸ Validation
    ▸ Formatting output
    ▸ Pagination
    RECAP

    View full-size slide

  78. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ DTOs and the Param Converter
    ▸ Validation
    ▸ Formatting output
    ▸ Pagination
    RECAP
    ▸ Sorts & Filters

    View full-size slide

  79. @MICHAELCULLUMUK
    ▸ Principles of REST
    ▸ Using HTTP well
    ▸ DTOs and the Param Converter
    ▸ Validation
    ▸ Formatting output
    ▸ Pagination
    RECAP
    ▸ Sorts & Filters
    ▸ Tools
    ▸ API Platform
    ▸ FOS Rest Bundle

    View full-size slide

  80. THANKS
    @MICHAELCULLUMUK

    View full-size slide

  81. @MICHAELCULLUMUK
    ANY QUESTIONS?

    View full-size slide

  82. BUILDING FIRST-CLASS REST APIS WITH
    SYMFONY
    @MICHAELCULLUMUK
    SYMFONY LIVE LONDON 2018

    View full-size slide