В сентябре 2022 года была выпущена новая версия библиотеки API Platform. Составили обзор с изменениями в третьей версии API Platform.
1К открытий2К показов
В сентябре 2022 года была выпущена новая версия библиотеки API Platform. Она включала в себя несколько серьезных модификаций, меняющих основной подход разработки API с использованием этой библиотеки, которого предпочитали придерживаться многие разработчики.
Веб-разработчик Noveo Александр знакомит читателей с новыми стратегиями разработки, представленными в версии v3. Кроме того, он уделит некоторое внимание инструментам и подходам, которые с тех пор были удалены или объявлены устаревшими.
Некоторые из них были довольно полезными при решении многих задач, поэтому Александр предложит собственное решение по интеграции старых тактик в новую систему API Platform.
Ниже приведены основные изменения, которые будут приняты во внимание в этой статье:
При объявлении операции больше нет разграничений между коллекциями сущностей и единичными сущностями.
ApiPlatform\Core\DataTransformer\DataTransformerInterface получил пометку deprecated и будет полностью удалён в версии 3.0. В качестве альтернативы будет использоваться State Providers.
Операции с подресурсами теперь обозначаются в отдельном атрибуте #[ApiResource].
В статье разработчик Новео Александр расскажет вам об этих изменениях и рассмотрит каждое из них на примерах.
State Processors и State Providers
Как было отмечено ранее, Data Transformers были упразднены, а в качестве альтернативы представлены State Providers и State Processors, основные задачи которых — предоставлять данные клиенту и обрабатывать данные от клиента соответственно.
Если вкратце, основная цель State Providers — предоставить доступ к объекту, хранящемуся в базе данных. State Processors, наоборот, нужны для обработки поступающих данных и сохранения их в базе данных (если необходимо) при обработке http-запроса.
Для того, чтобы API Platform могла получать данные извне или извлекать их из базы данных, по умолчанию используются State Providers и State Processors, взаимодействующие с Doctrine ORM.
Вы можете настроить свои собственные State Providers, если хотите получать данные из другого источника (Elasticsearch, MongoDB и т. д.) или изменять данные перед отправкой ответа сервера.
State Provider
Получение объекта
Предположим, у нас есть объект User. У него есть поля, которые мы хотим отобразить в запросе GET. Например:
Обозначить группы нормализации для операции GET и назначить группы нормализации с помощью аннотации #[Groups()] в классе сущности для полей, которые необходимо отобразить.
Создать объект передачи данных (DTO), назначить #[Groups()] полям DTO и назначить State Provider для операции GET.
Первый подход детально описан в официальной документации API Platform, поэтому мы сосредоточимся на втором. Во-первых, давайте начнём с создания класса DTO для этого объекта. Код класса DTO показан ниже:
<?php
declare(strict_types=1);
namespace App\Dto\Api\User;
use DateTimeInterface;
use Symfony\Component\Serializer\Annotation\Groups;
class UserOutputDto
{
#[Groups(['User:read'])]
public int $id;
#[Groups(['User:read'])]
public string $firstname;
#[Groups(['User:read'])]
public ?string $lastname = null;
#[Groups(['User:read'])]
public ?string $email = null;
#[Groups(['User:read'])]
public string $phone;
#[Groups(['User:read'])]
public DateTimeInterface $createdAt;
public function __construct(
int $id,
string $firstname,
?string $lastname,
?string $email,
string $phone,
DateTimeInterface $createdAt
) {
$this->id = $id;
$this->firstname = $firstname;
$this->lastname = $lastname;
$this->email = $email;
$this->phone = $phone;
$this->createdAt = $createdAt;
}
}
В приведённом выше коде мы определили поля, которые мы хотим отображать для запроса GET, и группы сериализации для каждого свойства. Следующим шагом является создание класса State Provider, который позволит нам извлекать пользователя из базы данных. Все поставщики данных должны реализовать ApiPlatform\State\ProviderInterface, который применяется как к операциям с коллекциями, так и к операциям с единичными объектами. С этого момента я предлагаю создать CollectionProviderInterface и ItemProviderInterface, которые расширяют ApiPlatform\State\ProviderInterface.
Мы указали, что State Providers, ответственные за получение одного объекта, должны получать Doctrine Item Provider в качестве аргумента. В то же время State Providers, ответственные за получение коллекции объектов, должны получать Doctrine Collection Provider для извлечения необходимых объектов из базы данных.
Перед созданием нашего первого класса State Provider я советую создать класс DataTransformer, основная роль которого заключается в преобразовании сущности в DTO. Код класса UserOutputGetDataTransformer показан ниже:
<?php
declare(strict_types=1);
namespace App\DataTransformer\Api\User;
use App\Dto\Api\User\UserOutputDto;
use App\Entity\User;
class UserOutputGetDataTransformer
{
public function transform(User $user): UserOutputDto
{
return new UserOutputDto(
$user->getId(),
$user->getFirstname(),
$user->getLastname(),
$user->getEmail(),
$user->getPhone(),
$user->getCreatedAt()
);
}
}
И, наконец, давайте создадим наш первый класс, реализующий ItemProviderInterface. Этот класс будет использоваться для извлечения объекта из базы данных и преобразования его состояния в DTO, который будет отправлен в качестве ответа от сервера.
Код класса представлен ниже:
<?php
declare(strict_types=1);
namespace App\State\Provider\User;
use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserOutputDto;
use App\State\Provider\ItemProviderInterface;
class UserProvider implements ItemProviderInterface
{
public function __construct(
private ProviderInterface $itemProvider,
private UserOutputGetDataTransformer $dataTransformer
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): UserOutputDto
{
$user = $this->itemProvider->provide($operation, $uriVariables, $context) ??
throw new ItemNotFoundException('Not Found');
return $this->dataTransformer->transform($user);
}
}
Метод __construct класса UserProvider принимает два аргумента:
$itemProvider будет использоваться для получения сущности из базы данных.
$dataTransformer будет преобразовывать исходный объект в представление DTO.
Проще простого, не так ли?
Наконец, мы должны назначить UserProvider методу GET класса User. Здесь мы указываем вывод: UserOutputDto::class и провайдер: UserProvider::class:
<?php
declare(strict_types=1);
namespace App\State\Provider\User;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserOutputDto;
use App\Entity\User;
use App\State\Provider\CollectionProviderInterface;
class UsersProvider implements CollectionProviderInterface
{
public function __construct(
private ProviderInterface $collectionProvider,
private UserOutputGetDataTransformer $dataTransformer,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
return array_map(
fn (User $user): UserOutputDto => $this->dataTransformer->transform($user),
iterator_to_array(($this->collectionProvider->provide($operation, $uriVariables, $context))->getIterator())
);
}
}
Далее давайте рассмотрим тот же подход для операции сбора. Во-первых, мы создадим класс UsersProvider, реализующий наш CollectionProviderInterface. Задача State Provider – получить пользователей из базы данных, в то время как UserOutputGetDataTransformer должен преобразовать коллекцию пользователей в коллекцию из объектов DTO. Код класса UsersProvider показан ниже:
Пагинация к результату уже применена, поэтому вам не нужно об этом беспокоиться. Последнее, что нужно сделать, — это назначить этому провайдеру операцию GetCollection в классе User:
Предположим, что нам необходимо создать нового пользователя с помощью метода POST. Входные данные JSON для метода POST должны выглядеть следующим образом:
Давайте начнём с создания класса входных данных DTO UserInputPostDto.
<?php
declare(strict_types=1);
namespace App\Dto\Api\User;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
class UserInputPostDto
{
#[Groups(['User:write'])]
#[Assert\NotBlank()]
public string $firstname;
#[Groups(['User:write'])]
#[Assert\NotBlank(allowNull: true)]
public ?string $lastname = null;
#[Groups(['User:write'])]
#[Assert\NotBlank(allowNull: true)]
public ?string $email = null;
#[Groups(['User:write'])]
#[Assert\NotBlank()]
public string $phone;
#[Groups(['User:write'])]
#[Assert\NotBlank()]
public string $password;
}
Представленный выше код содержит поля, которые должны быть получены в качестве входного JSON, ограничения проверки и группы сериализации для записи.
Кроме того, давайте создадим класс UserInputPostDataTransformer, который будет преобразовывать входящие данные из тела запроса в новый объект класса:
<?php
declare(strict_types=1);
namespace App\DataTransformer\Api\User;
use ApiPlatform\Validator\ValidatorInterface;
use App\Dto\Api\User\UserInputPostDto;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserInputPostDataTransformer
{
public function __construct(
private ValidatorInterface $validator,
private UserPasswordHasherInterface $hasher
) {
}
public function transform(UserInputPostDto $data): User
{
$this->validator->validate($data);
return new User(
$data->email,
$data->phone,
$data->firstname,
$data->lastname,
$data->password,
$this->hasher
);
}
}
Что касается обработчиков состояний, то все обработчики данных должны реализовывать ApiPlatform\State\ProcessorInterface. Для дальнейшего удобства создадим PersistProcessorInterface со следующим кодом:
Наш подход заключается в том, чтобы все пользовательские процессоры реализовывали этот интерфейс. Давайте добавим следующую конфигурацию в config/services.yaml:
Иногда при обновлении объекта может потребоваться изменить только определенные поля объекта, не затрагивая другие поля. В предыдущих версиях API Platform можно было использовать интерфейс DataTransformerInitializerInterface, который позволял инициализировать DTO с необходимыми предварительно инициализированными полями. Однако DataTransformers вместе с DataTransformerInitializerInterface устарели и больше не присутствуют в API Platform v3. В настоящее время нам не удалось определить подходящую альтернативу для DataTransformerInitializerInterface, поэтому мы предлагаем вам наше личное решение этой проблемы. Начнем с создания PersistProcessorInitializerInterface, реализующего созданный ранее PersistProcessorInterface. Интерфейс содержит один метод инициализации и имеет следующую структуру:
После этого давайте создадим класс декоратора, который украшает нормализатор элементов API Platform. Наша цель — изменить процесс денормализации. Чтобы добиться этого, мы должны сделать собственную реализацию метода денормализации и при необходимости сохранить логику декорированного класса. Для реализации необходимо выполнить следующие действия:
Сохранить основную логику декорируемого класса.
Предоставить классам Provider общий интерфейс, содержащий в себе initialize метод.
Ниже вы можете увидеть реализацию вышеупомянутых действий:
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Decorator;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Serializer\AbstractItemNormalizer;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;
class InitializerDecorator implements DenormalizerInterface, SerializerAwareInterface
{
use SerializerAwareTrait;
public function __construct(
private AbstractItemNormalizer $decoratedNormalizer,
private iterable $stateProcessors
) {
}
public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
{
$this->decoratedNormalizer->setSerializer($this->serializer);
if (!($operation = $context['operation']) instanceof Patch || !$operation->getInput()) {
return $this->decoratedNormalizer->denormalize($data, $class, $format, $context);
}
foreach ($this->stateProcessors as $stateProcessor) {
if ($stateProcessor::class === $operation->getProcessor()) {
$initializedObject = $stateProcessor->initialize($data, $class, $format, $context);
foreach ($data as $inputField => $inputValue) {
if (property_exists($initializedObject, $inputField)) {
try {
$initializedObject->$inputField = $inputValue;
} catch (\TypeError $error) {
throw new UnprocessableEntityHttpException('The field "' . $inputField . '" was not expected');
}
}
}
return $initializedObject;
}
}
return $this->decoratedNormalizer->denormalize($data, $class, $format, $context);
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $this->decoratedNormalizer->supportsDenormalization($data, $type, $format);
}
}
Наконец, нам нужно добавить конфигурацию в файл config/services.yaml, чтобы указать, что наш класс украшает нормализатор элементов API Platform. Для этого добавьте следующий код в файл config/services.yaml:
С этого момента каждый раз, когда вы хотите инициализировать DTO перед обработкой, вы должны сделать свой класс процессора состояния реализующим PersistProcessorInitializerInterface, который обеспечивает вашу собственную логику инициализации через метод инициализации.
Обновление сущности
Механизм обновления объекта соответствует тому же шаблону, что и решения выше.
Во-первых, давайте создадим класс UserInputPatchDto, содержащий все поля, которые можно изменить в нашей сущности:
<?php
declare(strict_types=1);
namespace App\Dto\Api\User;
use Symfony\Component\Serializer\Annotation\Groups;
class UserInputPatchDto
{
#[Groups(['User:write'])]
public ?string $firstname = null;
#[Groups(['User:write'])]
public ?string $lastname = null;
#[Groups(['User:write'])]
public ?string $password = null;
}
Затем давайте создадим класс UserInputPatchDataTransformer, который обрабатывает входящие данные и изменяет целевой объект:
<?php
declare(strict_types=1);
namespace App\DataTransformer\Api\User;
use ApiPlatform\Validator\ValidatorInterface;
use App\Dto\Api\User\UserInputPatchDto;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserInputPatchDataTransformer
{
public function __construct(
private ValidatorInterface $validator,
private UserPasswordHasherInterface $hasher,
) {
}
public function transform(UserInputPatchDto $data, User $user): User
{
$this->validator->validate($data);
$user->setFirstname($data->firstname);
$user->setLastname($data->lastname);
return $user;
}
}
Напоследок создадим класс PatchUserProcessor, который реализует наш PersistProcessorInitializerInterface. Код класса показан ниже:
<?php
declare(strict_types=1);
namespace App\State\Processor\User;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\DataTransformer\Api\User\UserInputPatchDataTransformer;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserInputPatchDto;
use App\Repository\UserRepository;
use App\State\Processor\PersistProcessorInitializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class PatchUserProcessor implements PersistProcessorInitializerInterface
{
public function __construct(
private ProcessorInterface $persistProcessor,
private UserRepository $userRepository,
private UserInputPatchDataTransformer $patchDataTransformer,
private UserOutputGetDataTransformer $getDataTransformer,
) {
}
/**
* @param UserInputPatchDto $data
*/
public function process($data, Operation $operation, array $uriVariables = [], array $context = []): array|object|null
{
$user = $this->userRepository->find($uriVariables['id']);
$user = $this->patchDataTransformer->transform($data, $user);
$this->persistProcessor->process($user, $operation, $uriVariables, $context);
return $this->getDataTransformer->transform($user);
}
public function initialize(mixed $data, string $class, ?string $format = null, array $context = []): object
{
$user = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
$dto = new UserInputPatchDto()
$dto->firstname = $user->getFirstname();
$dto->lastname = $user->getLastname();
return $dto;
}
}
Мы создали функцию инициализации, которая позволяет предварительно заполнить DTO существующими данными. После инициализации DTO передается функции процесса, где целевой объект должен быть обновлен.
Добавим необходимую конфигурацию для метода PATCH в атрибут #[ApiResource()] класса User:
#[ApiResource(
operations: [
new Get(
uriTemplate: '/users/{id<\d+>}',
output: UserOutputDto::class,
provider: UserProvider::class,
),
new GetCollection(
output: UserOutputDto::class,
provider: UsersProvider::class
),
new Post(
uriTemplate: '/register',
input: UserInputPostDto::class,
processor: PostUserProcessor::class,
),
new Patch(
uriTemplate: '/users/{id<\d+>}',
input: UserInputPatchDto::class,
processor: PatchUserProcessor::class,
output: UserOutputDto::class,
security: 'object === user or is_granted("ROLE_ADMIN")',
),
],
normalizationContext: ['groups' => ['User:read']],
denormalizationContext: ['groups' => ['User:write']]
)]
Мы указали uriTemplate для метода PATCH, а также параметры: input, processor, output и security. Параметр security в данном случае используется для ограничения доступа, чтобы доступ к объекту был либо у администратора, либо у текущего авторизованного пользователя, если он редактирует свои собственные данные.
Subresources
Подресурс — это еще один способ объявления ресурса, который обычно включает более сложный URI. Например, у нас есть такие сущности, как пользователь и встреча. У каждого пользователя может быть несколько встреч (OneToMany).
В операциях указываем тип запроса GetCollection. Вы также можете настроить файл config/security.yaml и разрешить или запретить доступ по URI, указанному в аннотации класса. Более того, внутри операции можно указать:
operations: [ new Get(security: "is_granted('ROLE_ADMIN')")]
Примечание. Если вы ограничиваете доступ, например, к пользователю объекта, вы все равно можете получать встречи этого пользователя через /users/{userId}/appointments/{appointmentId}.
API Platform не может создавать URI длиннее двух сущностей. Например, API Platform не может создать путь, состоящий из 3 и более объектов:
Мы рассмотрели основные изменения в новой версии API Platform и открыли для себя новые инструменты и то, как они работают. Общая стратегия развития во многом остается такой же, как и в предыдущей версии платформы API.
Соцсетями пользуются более 5 млрд человек по всему миру. Рассказываем, на каких языках программирования их пишут и как они выдерживают такой поток юзеров.