Noveo

Наш блог API platform — не быстрый старт. Часть 1

API platform — не быстрый старт. Часть 1

Noveo API platform

Введение

Многие разработчики боятся использовать API platform в своих проектах. Свой страх они обосновывают тем, что возможностей API platform может не хватить для задач конкретного проекта. Цель этой серии статей — показать, что возможностей API platform хватит для всех проектов.

 

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

Установка

Установка API platform ничем не отличается от установки других бандлов. Все ставится через composer поверх проекта на symfony:

$ composer require api

Устанавливать API platform можно как в новых, так и в существующих проектах. После установки нужно сконфигурировать подключение к базе данных. О том, как это сделать, можно прочитать тут. Также я рекомендую поставить MakerBundle для удобного создания сущностей.

$ composer require symfony/maker-bundle --dev

Простой CRUD

Создаем сущность через Maker Bundle; для этого просто пишем в терминал:

$ bin/console make:entity

После того, как мы выберем имя сущности, бандл спросит нас, хотим ли мы сделать ее ресурсом для нашего API. Согласимся и добавим поля с именем и стоимостью. В моем случае процедура создания выглядит так:

 

Class name of the entity to create or update (e.g. FierceChef):
 > blogPost

 Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
 > yes

 created: src/Entity/BlogPost.php
 created: src/Repository/BlogPostRepository.php
 
 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > createdAt

 Field type (enter ? to see all types) [datetime_immutable]:
 > datetime

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > title

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > content

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > onReview

 Field type (enter ? to see all types) [string]:
 > boolean

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > 


          
 Success! 
          

 Next: When you're ready, create a migration with php bin/console make:migration

Заходим на сервер по пути /api и видим, что API platform сделал всю работу за нас, а мы не написали пока ни одной строчки кода.

Noveo working with API platform
Кстати, если кликнуть по пауку, он поднимется по своей паутине наверх :).

Каждый вызов можно рассмотреть поподробнее, а для коллекций API platform уже реализовал пагинацию. Попробовать отправлять и принимать вызовы можно прямо в браузере.

 

Доступные вызовы можно ограничить в аннотации. Например, уберем PUT: для этого откроем сущность и перечислим все операции, за исключением put в аннотации, следующим образом:

#[ApiResource(
    collectionOperations: ['get', 'post'],
    itemOperations: ['get', 'patch', 'delete'],
)]

В терминологии API platform все вызовы делятся на Collection operations и Item operations. Не трудно догадаться из названия, что Collection operations нужны нам для взаимодействия с коллекцией — добавления туда новых объектов или получения всей коллекции. Операции с отдельными объектами соответственно — Item operations.

 

Обновляем страницу в браузере и видим, что PUT пропал:

Noveo API platform PUT

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

Используем DTO и DataTransformer

В большинстве случаев нам необходимо как-либо изменять изначальную сущность перед отправкой клиенту — убрать лишние поля или добавить новые, исходя из потребностей клиента. Эту задачу нам помогут решить такие классы, как DTO и Data Transformer.

 

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

 

Теперь к примерам:

DTO в request

Допустим, мы создаем статьи в блоге. У статей есть счетчик просмотров, который нельзя задать при создании, флаг, который говорит нам, была ли статья проверена, и дата создания.

 

Модель, с которой взаимодействует клиент, будет выглядеть так:

{
    "title": “string”,
    "content": “string”,
}

А модель, в которой данные хранятся на сервере, — следующим образом:

{
    "id": int,
    "createdAt": DateTime,
    "title": string,
    "content": string,
    "onReview": bool,
}

В таком случае нам нужно описать в src/Dto класс ArticleInputPost следующим образом:

<?php

namespace App/Dto;

class ArticleInputPost
{
    public string $title;

    public string $content;
}

А также создать в src/DataTransformer класс ArticleInputPostDataTransformer, который будет создавать сущность и заполнять ее.

 

Все дататрансформеры наследуются от общего интерфейса DataTransformerInterface. Этот интерфейс содержит метод transform, который переводит данные из одного класса в другой, и метод supportTransformation, который позволяет определить, подходит ли дататранcформер для перевода. В нашем случае дататранcформер будет выглядеть так:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostInputPost;
use App\Entity\BlogPost;
use DateTime;

class BlogPostInputPostDataTransformer implements DataTransformerInterface
{

    public function transform($object, string $to, array $context = []): BlogPost
    {
        $blogPost = new BlogPost();

        $blogPost
            ->setCreatedAt(new DateTime())
            ->setTitle($object->title)
            ->setContent($object->content)
            ->setOnReview(true);
    
        return $blogPost;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
               return (
                  ($context['operation_type'] === 'collection') &&
                  ($context['collection_operation_name'] === 'post') &&
                  ($context['input']['class'] === BlogPostInputPost::class) &&
                  ($to === BlogPost::class)
              );
    }
}

Никаких дополнительных действий для работы дататрансформера не потребуется.

 

А для того, чтобы на входе принимался наш класс, нужно добавить в аннотацию нужный тип выходных данных:

#[ApiResource(
    collectionOperations: [
            'get',
            'post' => [
                  ‘input’ => ArticleInputPost::class
            ]
      ],
    itemOperations: ['get', 'patch', 'delete'],
)]

Таким же образом изменяем «patch». Сделаем так, чтобы при обновлении можно было менять только содержимое статьи.

 

Отличие в DTO для «patch» будет в том, что мы инициализируем значение сразу в сущности. Ведь в PATCH может прийти изменение только для одного поля:

 

<?php

namespace App\Dto;

class BlogPostInputPatch
{
    public ?string $content = null;
}

Дататрансформер же будет отличаться тем, что объект для изменения мы берем из контекста.

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostInputPatch;
use App\Entity\BlogPost;

class BlogPostInputPatchDataTransformer implements DataTransformerInterface
{
    public function transform($object, string $to, array $context = []): BlogPost
    {
        $blogPost = $context['object_to_populate'];

        if (!$object->content) {
            $blogPost->setContent($object->content);
        }

        return $blogPost;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
               return (
                 ($context['operation_type'] === 'item') &&
                 ($context['item_operation_name'] === 'patch') &&
                 ($context['input']['class'] === BlogPostInputPatch::class) &&
            ($to === BlogPost::class)
              );

    }
}

А в аннотации все так же, как и с POST.

#[ApiResource(
    collectionOperations: [
        'get',
        'post' => [
            'input' => BlogPostInputPost::class,
        ]
    ],
    itemOperations: [
        'get',
        'patch' => [
            'input' => BlogPostInputPatch::class,
        ],
        'delete'
    ],
)]

DTO в response

Для начала отключим всю выходную информацию для запросов post patch и delete. Клиенту она вряд ли пригодится:

#[ApiResource(
    collectionOperations: [
        'get',
        'post' => [
            'input' => BlogPostInputPost::class,
            'output' => false,
        ]
    ],
    itemOperations: [
        'get',
        'patch' => [
            'input' => BlogPostInputPatch::class,
            'output' => false,
        ],
        'delete' => [
            'output' => false,
        ]
    ],
)]

Ответом на эти запросы будет код 204.

 

Теперь представим следующую ситуацию: при получении списка статей клиенту не нужно видеть все содержимое статьи, только первые 10 слов. Всю остальную статью он получит только по id.

 

Создаем класс DTO.

<?php

namespace App\Dto;

use DateTime;

class BlogPostOutputCollection
{
    public string $title;

    public string $content;

    public DateTime $createdAt;
}

Дататрансформер напишем следующим образом:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostOutputCollection;
use App\Entity\BlogPost;

class BlogPostOutputCollectionDataTransformer implements DataTransformerInterface
{
    private const WORDS_COUNT = 10;

    public function transform($object, string $to, array $context = []): BlogPostOutputCollection
    {
        $blogPostOutput = new BlogPostOutputCollection();

        $blogPostContentWords = explode(' ', $object->getContent());

        $blogPostContentFirstWords = (count($blogPostContentWords) > self::WORDS_COUNT) ?
            array_slice($blogPostContentWords, 0, self::WORDS_COUNT):
            $blogPostContentWords;

        $blogPostOutput->title = $object->getTitle();
        $blogPostOutput->content = implode(
            ' ',
            $blogPostContentFirstWords
        );
        $blogPostOutput->createdAt = $object->getCreatedAt();

        return $blogPostOutput;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return ($data instanceof BlogPost) && ($to === BlogPostOutputCollection::class);
    }
}

Добавим класс DTO в нашу аннотацию:

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
            'output' => false,
        ]
    ],
    itemOperations: [
        'get',
        'patch' => [
            'input' => BlogPostInputPatch::class,
            'output' => false,
        ],
        'delete' => [
            'output' => false,
        ]
    ],
)]

Теперь сделаем дататрансформер для одиночного поста в блоге, но при этом добавим комментарии. Комментарий будет содержать в себе только текст и связь с постом.

Сделаем два дататранформера и DTO для добавления и получения коммента. Их логику описывать не буду. Если будет интересно, в конце статьи будет репозиторий с проектом. Внимания тут заслуживает аннотация коммента:

#[ApiResource(
    collectionOperations:[
              'post' => [
                 'input' => BlogPostCommentInputPost::class,
              ]
    ],
    itemOperations: [],
    output: BlogPostCommentOutput::class
)]

В ней мы декларируем выходной output глобально, и теперь можем использовать эту сущность как внутреннюю для других вызовов API.

 

Сделаем вот такой DTO-класс:

<?php

namespace App\Dto;

class BlogPostOutputItem
{
    public string $title;

    public string $content;

    public array $comments;
}

И в дататрансформере просто положим в комментарии нетрансформированные сущности.

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostOutputItem;
use App\Entity\BlogPost;

class BlogPostOutputItemDataTransformer implements DataTransformerInterface
{
    public function transform($object, string $to, array $context = []): BlogPostOutputItem
    {
        $blogPostOutputCollection = new BlogPostOutputItem();

        $blogPostOutputCollection->title = $object->getTitle();
        $blogPostOutputCollection->content = $object->getContent();
        $blogPostOutputCollection->comments = $object->getComments()->toArray();
        
        return $blogPostOutputCollection;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return ($data instanceof BlogPost) && ($to === BlogPostOutputItem::class);
    }
}

Отредактируем аннотацию в сущности:

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
            'output' => false,
        ]
    ],
    itemOperations: [
        'get' => [
            'output' => BlogPostOutputItem::class,
        ],
        'patch' => [
            'input' => BlogPostInputPatch::class,
            'output' => false,
        ],
        'delete' => [
            'output' => false,
        ]
    ],
)]

Теперь мы будем получать комментарии вместе с постом.

Резюмируя

Дататранформеры и DTO — очень мощный и гибкий инструмент в API Platform. Он с избытком покрывает задачу перевода внутренних моделей данных во внешние. А классы очень легко поддаются тестированию.

Кастомные контроллеры

Часто возникают ситуации, в которых при выполнении запросов нужно сделать какие-то дополнительные действия. Например, завести счетчик просмотров для статей, который будет прибавляться на единичку каждый раз, когда мы открываем статью. Давайте сделаем это.

 

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

 

Теперь добавим в репозиторий метод, который будет увеличивать количество просмотров на 1.

public function incrementViewsCount(BlogPost $blogPost): BlogPost
    {
        $entityManager = $this->getEntityManager();

        $blogPost->setViewsCount($blogPost->getViewsCount() + 1);

        $entityManager->persist($blogPost);
        $entityManager->flush();

        return $blogPost;
    }

После сделаем контроллер. Контроллеры в API platform немного отличаются от стандартных контроллеров в Symfony. Наш будет выглядеть так:

<?php

namespace App\Controller;

use App\Entity\BlogPost;
use App\Repository\BlogPostRepository;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
class GetBlogPostController
{
    private BlogPostRepository $repository;

    public function __construct(BlogPostRepository $repository)
    {
        $this->repository = $repository;
    }

    public function __invoke(BlogPost $data): BlogPost
    {
        return $this->repository->incrementViewsCount($data);
    }
}

Тут очень важно, чтобы аргумент имел имя $data.

 

Теперь добавим контроллер в аннотацию:

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
            'output' => false,
        ]
    ],
    itemOperations: [
        'get' => [
            'output' => BlogPostOutputItem::class,
            'controller' => GetBlogPostController::class,
        ],
        'patch' => [
            'input' => BlogPostInputPatch::class,
            'output' => false,
        ],
        'delete' => [
            'output' => false,
        ]
    ],
)]

Расширения для doctrine

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

 

Поставим задачу следующим образом: нам нужно сделать так, чтоб статьи можно было достать двумя методами. Одним мы достаем статьи, которые прошли проверку, вторым — те, что не прошли.

 

Ну а еще нужно дать возможность непосредственно проверять статьи. Это мы сделаем с помощью контроллера, который рассматривали в прошлой части. Не буду вдаваться в детали его реализации. Покажу лишь, как добавить к сущности второй метод patch:

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
            'output' => false,
        ]
    ],
    itemOperations: [
        'get' => [
            'output' => BlogPostOutputItem::class,
            'controller' => GetBlogPostController::class,
        ],
        'patch' => [
            'input' => BlogPostInputPatch::class,
            'output' => false,
        ],
        'review' => [
            'method' => Request::METHOD_PATCH,
            'path' => 'blog_posts/{id}/review',
            'controller' => RewiewBlogPostController::class,
            'input' => false,
            'output' => false,
        ],
        'delete' => [
            'output' => false,
        ]
    ],
)]

Для этого нужно добавить к кастомной операции метод и путь.

 

Теперь о расширениях. Расширения доктрины позволяют видоизменять запросы к базе. Обычно они используются, чтобы добавить выборку или сортировку к выдаче.

 

Сделаем два расширения для разных выборок соответственно; одно из них ниже, второе по аналогии:

<?php

namespace App\Doctrine;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use Doctrine\ORM\QueryBuilder;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\BlogPost;

class ShowNotReviewedBlogPostsExtension implements QueryCollectionExtensionInterface
{
    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        ?string $operationName = null
    ): void {
        if($this->isNeddedToExtend($resourceClass, $operationName))
        {
            $this->extend($queryBuilder);
        }
    }

    private function isNeddedToExtend(string $resourceClass, ?string $operationName = null): bool {
        return (
            ($operationName === 'get_rewiewed') &&
            ($resourceClass === BlogPost::class)
        );
    }

    private function extend(QueryBuilder $queryBuilder): void
    {
        $rootAlias = $queryBuilder->getRootAliases()[0];

        $queryBuilder->andWhere($rootAlias . '.onReview = false');
    }
}

Добавим их в services.yaml:

App\Doctrine\ShowNotReviewedBlogPostsExtension:
    tags:
        - { name: api_platform.doctrine.orm.query_extension.collection }
App\Doctrine\ShowReviewedBlogPostsExtension:
    tags:
        - { name: api_platform.doctrine.orm.query_extension.collection }

и вместо get в collection напишем следующее:

'get_not_reviewed' => [
        'method' => Request::METHOD_GET,
        'path' => 'blog_posts/not_reviewed',
        'output' => BlogPostOutputCollection::class
    ],
    'get_reviewed' => [
        'method' => Request::METHOD_GET,
        'path' => 'blog_posts',
        'output' => BlogPostOutputCollection::class
    ],

Теперь у нас есть get-методы для статей, которые прошли ревью и не прошли.

Заключение

В этой статье мы рассмотрели базовые возможности API platform на примере простого блога. В следующей части рассмотрим фильтры. Код проекта выложен на гитхаб — его можно развернуть локально и посмотреть и собственноручно опробовать.

 

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.

НазадПредыдущий пост ВпередСледующий пост

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: