Краткий обзор API platform для разработки Symfony приложений

Существует много вариантов реализации REST API в symfony приложении. Один из них — API Platform. Из «коробки» мы получаем поддержку REST API протокола, документацию, Swagger UI с возможностью протестировать работу наших эндпоинтов.

Быстрый старт

Добавляем API platform в приложение:

    composer req api

По умолчанию swagger UI активен и доступен по ссылке http://localhost/api:

Пока ни один эндпоинт (операция в терминологии API Platform) не определен, сделать это несложно — достаточно добавить аннотацию @ApiResource к сущности.

    <?php
     
    declare(strict_types=1);
     
    namespace App\Entity;
     
    ...
     
    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource()
     */
    class Customer
    {
        /**
    ...

Появился полный набор CRUD операций. Все операции делятся на 2 группы:

    • collection operations — операции с коллекцией элементов;
    • item operations — операции с элементом данных.

Collection operations:

    • GET - получить список элементов;
    • POST - добавить элемент в коллекцию.

Item operations:

    • GET - получить элемент по ID;
    • PUT - изменить элемент;
    • DELETE - удалить элемент из коллекции;
    • PATCH - частичное изменение элемента.

Конфигурацию элемента можно описывать в аннотации, xml или yaml файлах. Расположение yaml файлов определяется параметром path конфигурационного файла config/packages/api_platform.yaml

    api_platform:
        mapping:
            paths:
                - '%kernel.project_dir%/src/Entity'
                - '%kernel.project_dir%/config/api_platform'

При необходимости можем ограничить набор операций нужным списком:

    <?php
     
    declare(strict_types=1);
     
    namespace App\Entity;
     
    ...
     
    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource(collectionOperations={"get","post"},itemOperations={"get","delete"})
     */
    class Customer
    {
        /**
    ...

или в yaml файле:

    # config/api_platform/customer.yaml
    App\Entity\Customer:
      itemOperations:
        get: ~
        delete: ~
      collectionOperations:
        get:
        post: ~

Постраничный вывод коллекции

Коллекции элементов поддерживают постраничную разбивку.

Валидация

При необходимости выполнить проверку вводимых данных достаточно добавить валидатор на свойство сущности — API platform генерирует сообщение об ошибке.

    ...
        /**
         * @var string
         *
         * @ORM\Column(type="string", length=50, nullable=false)
         *
         * @Assert\NotBlank()
         * @Assert\NotNull()
         * @Assert\Length(max="50")
         */
        private $name;
    ...

при строке более 50 символов получим ошибку:

    {
      "@context": "/api/contexts/ConstraintViolationList",
      "@type": "ConstraintViolationList",
      "hydra:title": "An error occurred",
      "hydra:description": "name: This value is too long. It should have 50 characters or less.",
      "violations": [
        {
          "propertyPath": "name",
          "message": "This value is too long. It should have 50 characters or less."
        }
      ]
    }

Связанные объекты

Для обеспечения уникальности идентификаторов объектов API platform использует IRI (Internationalized Resource Identifier). IRI каждого объекта совпадает с GET запросом этого объекта /api/customer/123. Этот же тип идентификатора используется и при запросах добавления и редактирования объектов.

Добавим новую сущность, связанную с сущностью Customer:

    ...
    /**
     * Message.
     *
     * @ORM\Entity()
     *
     * @ApiResource()
     */
    class Message
    {
        ...
     
        /**
         * @var Customer
         *
         * @ORM\ManyToOne(targetEntity="App\Entity\Customer", inversedBy="messages")
         * @ORM\JoinColumn(nullable=false)
         *
         * @Assert\NotBlank()
         * @Assert\Type(type="App\Entity\Customer")
         */
        private $createdBy;
        ...
    }

Тело запроса на добавление нового элемента будет выглядеть так:

    {
       ...
       “createBy”:/api/customer/123,
       ...
    }

Список элементов коллекции будет выглядеть следующим образом:

    {
      "@context": "/api/contexts/Customer",
      "@id": "/api/customers",
      "@type": "hydra:Collection",
      "hydra:member": [
        {
          "@id": "/api/customers/1",
          "@type": "Customer",
          "id": 1,
          "name": "name 1",
          "email": "test@test.com",
          "messages": [
            "/api/messages/3",
            "/api/messages/2",
            "/api/messages/1"
          ]
        },
      ],
      "hydra:totalItems": 1
    }

Как видно список сообщений представлен в виде массива строк IRI:

       "messages": [
            "/api/messages/3",
            "/api/messages/2",
            "/api/messages/1"
          ]

А запрос http://localhost/api/messages/1 даст такой результат:

    {
      "@context": "/api/contexts/Message",
      "@id": "/api/messages/1",
      "@type": "Message",
      "id": 1,
      "createdBy": "/api/customers/1",
      "title": "title 1",
      "text": "message 1"
    }

Но, при необходимости, используя группы сериализации мы можем вывести не только IRI объекта, но и данные вложенного объекта. Для этого для item operation GET укажем группу сериализации.

    # config/api_platform/message.yaml
    App\Entity\Message:
      itemOperations:
        get:
          normalization_context:
            groups:
              - message
        put: ~
        delete: ~
      collectionOperations:
        get: ~
        post: ~

Добавим группу к свойствам объекта Message и Customer. Теперь результат того же запроса будет другим:

    {
      "@context": "/api/contexts/Message",
      "@id": "/api/messages/1",
      "@type": "Message",
      "id": 1,
      "createdBy": {
        "@id": "/api/customers/1",
        "@type": "Customer",
        "id": 1,
        "name": "customer 1"
      },
      "title": "title 1",
      "text": "message 1"
    }

Фильтры

API platform имеет встроенный механизм фильтров:

Doctrine ORM и Mongo ODM

    • Search filter (строковые поля);
    • Date filter;
    • Boolean filter;
    • Range filter (числовые поля);
    • Exists filter (nullable значения);
    • Order filter (изменение порядка сортировки коллекции).

Elasticsearch

    • Ordering filter;
    • Matching filter;
    • Term filter.

Фильтры сериализации

    • Group filter (определяет перечень групп сериализации для результата запроса);
    • Property filter (определяет перечень выводимых свойств).

Также есть возможность определять пользовательские типы фильтров:

Для примера добавим фильтр по строковым полям:

    ...
    /**
     * Message.
     *
     * @ORM\Entity()
     *
     * @ApiResource()
     * @ApiFilter(SearchFilter::class, properties={"title": "iend", "text": "partial"})
     */
    class Message
    {
    ...

Мы добавили 2 фильтра:

    • по полю title — будет выполняться поиск всех элементов, у которых поле title заканчивается на образец поиска. Поиск регистронезависимый (символ i в начале названия типа фильтра).
    • по полю text — будет выполняться поиск всех элементов, у которых поле text содержит образец поиска. Поиск регистрозависимый.

Использование Data Transfer Objects (DTO)

При необходимости мы можем использовать не сущности напрямую, а DTO. Для этого нужно выполнить 3 шага:

  1. Описать входной и выходной классы DTO;
  2. Указать классы как input и output в параметрах нашей сущности;
  3. Описать классы Data Transformer для преобразования сущности в DTO и обратно.

Входной DTO:

    ...
    /**
     * MessageInputDto.
     */
    class MessageInputDto
    {
    ...
    }
    ...

Выходной DTO:

    ...
    /**
     * MessageOutputDto.
     */
    class MessageOutputDto
    {
    ...
    }
    ...

Добавляем классы DTO в описание сущности:

    /**
     * Message.
     *
     * @ORM\Entity()
     *
     * @ApiResource(
     *     input=MessageInputDto::class,
     *     output=MessageOutputDto::class
     * )
     */
    class Message
    {
    ...

Data Transformer для входного DTO:

    /**
     * MessageInputTransformer.
     */
    class MessageInputTransformer implements DataTransformerInterface
    {
        /**
         * {@inheritDoc}
         */
        public function transform($object, string $to, array $context = []): Message
        {
            $message = new Message();
            ... 
     
            return $message;
        }
     
        /**
         * {@inheritDoc}
         */
        public function supportsTransformation($data, string $to, array $context = []): bool
        {
            return Message::class === $to && $data instanceof MessageInputDto;
        }
    }

Data Transformer для выходного DTO:

    /**
     * MessageOutputTransformer.
     */
    class MessageOutputTransformer implements DataTransformerInterface
    {
        /**
         * {@inheritDoc}
         */
        public function transform($object, string $to, array $context = []): MessageOutputDto
        {
            $dto = new MessageOutputDto();
            ... 
     
            return $dto;
        }
     
        /**
         * {@inheritDoc}
         */
        public function supportsTransformation($data, string $to, array $context = []): bool
        {
            return MessageOutputDto::class === $to && $data instanceof Message;
        }
    }
     

Данная реализация имеет один небольшой недостаток — не будет работать валидация DTO. Для исправления данного дефекта нужно изменить класс трансформера input DTO:

    /**
     * MessageInputTransformer.
     */
    class MessageInputTransformer implements DataTransformerInterface
    {
        private $validator;
     
        /**
         * @param ValidatorInterface $validator
         */
        public function __construct(ValidatorInterface $validator)
        {
            $this->validator = $validator;
        }
     
        /**
         * {@inheritDoc}
         */
        public function transform($object, string $to, array $context = []): Message
        {
            $this->validator->validate($object);
     
            $message = new Message();
            ... 
     
            return $message;
        }
     
        /**
         * {@inheritDoc}
         */
        public function supportsTransformation($data, string $to, array $context = []): bool
        {
            return Message::class === $to && $data instanceof MessageInputDto;
        }
    }

Пользовательские контроллеры

В том случае, если ни один из вышеперечисленных способов не позволяет получить необходимый нам результат, мы можем использовать собственный контроллер.

Вариант 1

    /**
     * CustomerAction.
     */
    class CustomerAction
    {
        private $customerService;
     
        /**
         * @param CustomerService $customerService
         */
        public function __construct(CustomerService $customerService)
        {
            $this->customerService = $customerService;
        }
     
        /**
         * @param Customer $data
         *
         * @return Customer
         */
        public function __invoke(Customer $data): Customer
        {
            $this->customerService->handle($data);
     
            return $data;
        }
    }
     

Имя параметра метода __invoke обязательно должен быть $data, в противном случае он не будет заполнен. Добавим контроллер в конфигурацию сущности.

    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource(itemOperations={
     *     "get",
     *     "delete",
     *     "customer_action"={
     *          "method"="POST",
     *          "path"="/api/customer/{id}/action",
     *          "controller"=CustomerAction::class,
     *     }
     * })
     */
    class Customer
    {

Вариант 2

    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource(itemOperations={
     *     "get",
     *     "delete",
     *     "customer_action"={"route_name"="customer_action_route"}
     * })
     */
    class Customer
    {

И добавляем класс контроллера:

    /**
     * CustomerController.
     */
    class CustomerController
    {
        /**
         * @Route("/api/customer/{id}/action", name="customer_action_route", methods={"POST"})
         *
         * @param Customer $customer
         *
         * @return Response
         */
        public function executeAction(Customer $customer): Response
        {
            ...
     
     
            return new Response(...);
        }
    }

Пользовательские источники данных

Доступ к данным с использованием Doctrine ORM и Elasticsearch-PHP включены в API Platform как стандартные. Doctrine ORM активно сразу после установки, Elasticsearch-PHP можно включить в конфигурационном файле.

Для других вариантов источников данных мы можем определить свой собственный источник данных добавив новый класс, реализовав интерфейсы CollectionDataProviderInterface, RestrictedDataProviderInterface для провайдера коллекции. или ItemDataProviderInterface, RestrictedDataProviderInterface для провайдера элемента коллекции. Изменением данных занимается другой провайдер, который реализует интерфейс ContextAwareDataPersisterInterface.

Впечатления, выводы...

API Platform намного обширнее, чем было описано в этом обзоре. За его рамками остались автоматизированная генерация документации и json схем, управление сериализацией, безопасность и разграничение доступа, собственные расширения платформы, интеграция с Symfony Messenger и другой функционал. Полное описание можно найти в официальной документации https://api-platform.com/docs/. Целью же данной статьи было первое знакомство с инструментом.

API Platform включает в себя большой арсенал инструментов для написания REST API, позволяющие минимальным количеством кода реализовать большинство задач, возникающих при написании REST API Backend. В то же время, любые действия, которые не укладываются в CRUD, придется самостоятельно реализовывать в виде стандартных контроллеров.