Короткий огляд API platform для розробки додатків Symfony

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

Існує багато варіантів реалізації REST API в додатку. Один з них — API Platform. Із «коробки» ми отримуємо підтримку REST API протоколу, документацію, Swagger UI з можливістю протестувати роботу наших ендпоінтів.

Швидкий старт

Додаємо API platform у додаток:

    composer req api

За замовчуванням swagger UI активний і доступний за посиланням https://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. Цей самий тип ідентифікатора використовується і під час запитів додавання та редагування об'єктів.

Давайте додамо нову сутність, пов’язану з сутністю клієнта:

    ...
    /**
     * 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 об'єкта, але й дані вкладеного об'єкта у разі необхідності. Для цього в операції 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 (нульове значення);
    • 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;
        }
    }

Користувальницькі контролери

У тому випадку, якщо жоден з перерахованих вище способів не дозволяє отримати необхідний нам результат, ми можемо використовувати власний контролер.

    /**
     * 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, доведеться самостійно реалізовувати у вигляді стандартних контролерів.