Кто из нас не был одурманен сказками про свагер? Мол, добавь эту волшебную штуку — да заживешь! Но плата за магию — зеленое болото нотаций. А нельзя ли обойтись только типизацией самого php? (Спойлер: онжом)
Цель — превратить этот симпатичный Symfony контроллер
<?php
declare(strict_types=1);
// ...
class LoginController extends AbstractController
{
#[Route('/api/login', methods: ['POST'])]
#[Tag(name: 'login')]
public function __invoke(
LoginRequest $request,
LoginService $service,
UserResponse $response
): UserResponse
{
$user = $service->login($request);
return $response->with($user);
}
}
В такую до боли знакомую штуку
Осторожно! Swagger показывает OpenApi без прикрас
То есть сначала типизируем входные/выходные данные, а затем превратим типы в нотации. В путь!
Шаг 1. Типизация запроса
Чтобы в контроллер вместо базового Request
объекта передавался дто, возьмем готовую библиотеку prugala/symfony-request-dto. Там используется механизм ValueResolver, который потом можно, не стесняясь, переделать под себя.
<?php
declare(strict_types=1);
class LoginRequest implements RequestDtoInterface
{
#[NotBlank]
#[Email]
public string $login;
#[NotBlank]
public string $password;
}
Дополнительные условия над полями — приятный бонус, но не стоит этим сильно увлекаться. Здесь уместна валидация только типов, но не данных.
Отлично, работаем дальше ©
Шаг 2. Типизация ответа
Symfony бухтит, когда контроллер возвращает не Response объект. Конечная? К счастью, разрулить ситуацию можно, если заабузить событие kernel.view
:
Реализация
services:
App\Listener\ResponseListener:
tags:
- { name: kernel.event_listener, event: kernel.view}
<?php
declare(strict_types=1);
namespace App\Listener;
use App\Dto\ResponseJsonInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\Serializer\SerializerInterface;
class ResponseListener
{
public function __construct(
private readonly SerializerInterface $serializer,
) {
}
public function __invoke(ViewEvent $event): void
{
$response = $event->getControllerResult();
if (!($response instanceof ResponseJsonInterface)) {
return;
}
$jsonResponse = $this->json($response);
$event->setResponse($jsonResponse);
}
protected function json(mixed $data, int $status = 200, array $headers = [], array $context = []): JsonResponse
{
$json = $this->serializer->serialize($data, 'json', array_merge([
'json_encode_options' => JsonResponse::DEFAULT_ENCODING_OPTIONS
| JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
| JSON_PRETTY_PRINT,
], $context));
return new JsonResponse($json, $status, $headers, true);
}
}
Благодаря этому можно возвращать обычный дто, а вот, кстати, и он:
<?php
declare(strict_types=1);
class UserResponse implements ResponseJsonInterface
{
public int $id;
public string $email;
public int $special;
public function __construct(private readonly SpecialService $specialService)
{
}
public function with(User $user): self
{
$this->id = $user->id();
$this->email = $user->email();
$this->special = $this->specialService->special($user);
return $this;
}
}
Теперь контроллер не на словах, а на деле соблюдает заявленные контракты! (звучит, да?)
Шаг 3. Заводим свагер
Для этого воспользуемся библиотекой nelmio/NelmioApiDocBundle. В ней уже из коробки есть возможность использовать классы для нотаций:
<?php
#[Route('/api/login', methods: ['POST'])]
#[RequestBody(content: new Model(type: LoginRequest::class))]
#[Response(response: 200, content: new Model(type: UserResponse::class))]
#[Tag(name: 'login')]
public function __invoke(
LoginRequest $request,
LoginService $service,
UserResponse $response
): UserResponse
{
//...
}
Вроде бы почти то, что надо! Но дублирование все равно остается :(
Если изменить сигнатуру метода и не обновить нотации, то.. никакой ошибки не возникнет, и сгенерируется неактуальная дока. Ненавязчивая попытка подтолкнуть автора библиотеки к добавлению обработчика типов осталась безответной.
Шаг 4. Кастом
Расширим NelmioApiDocBundle с помощью vodevel/api-doc-bundle-type-describer. Этот бандл хоть и примитивен, но делает что до́лжно:
<?php
#[Route('/api/login', methods: ['POST'])]
#[Tag(name: 'login')]
public function __invoke(
LoginRequest $request,
LoginService $service,
UserResponse $response
): UserResponse
{
// ...
}
use OpenApi\Attributes\RequestBody;
#[RequestBody]
class LoginRequest implements RequestDtoInterface {
// ...
}
use OpenApi\Attributes\Response;
#[Response]
class UserResponse implements ResponseJsonInterface {
// ...
}
У контроллера все еще осталась нотация #[Tag]
, без которой он просто не будет замечен и обработан. А у дто появились атрибуты #[RequestBody]
, #[Response]
. Но дублирующих нотаций нет. Точно нет? Да говорю же!
Шаг 5. Страшно, вырубай
Хотите попробовать, но боитесь превратить документацию в тыкву? Я тоже. Поэтому библиотека работает крайне деликатно. Методы контроллеров, у которых уже есть нотации, не затронет.
Кстати, если в дто имя поля недостаточно говорящее, то можно добавить Property:
<?php
#[RequestBody]
class LoginRequest implements RequestDtoInterface
{
#[Property(description: "It’s an email, dude!", example: 'test@test.test')]
#[NotBlank]
#[Email]
public string $login;
Professional!
[Из невошедшего]
Как задокументировать список возможных ответов, а не только один успешный?
Подловили! Такой возможности нет (ну, кроме как вручную расписать). Для исключений можно было бы еще что-то придумать (аннотации с проверкой через снифер, или анализ AST). Но не настолько я люблю список ответов. А вы?
Почему для описания дто используется и интерфейсы, и атрибуты?
Чтобы подчеркнуть их независимость, ведь это две разные ответственности так то. Можно добиться их однообразия, используя только механизм атрибутов, но их не так удобно проверять, как интерфейсы. А использовать только интерфейсы не получится, так как для документации порой нужна начинка.
Перечислять Response объект в параметрах контроллера обязательно?
Главным образом это сделано для поддержки DI. Если хотите иммутабельный объект, то можно и так:
<?php
public function __invoke(
LoginRequest $request,
LoginService $service,
SpecialService $specialService
): UserResponse
{
$user = $service->login($request);
return new UserResponse($specialService, $user);
}
А если надо возвращать не объект, а список объектов, например, список юзеров?
Жизнь показала, что голые списки - такое себе. Рано или поздно, но появятся метаданные (пагинация, агрегация, для отладки доп.информация). Поэтому для списка тоже лучше использовать объект с ключом "data" ("items", "collection", "list").
Но если вопрос стоит ребром (апи уже существует), то вот:
<?php
class UserListController extends BaseController
{
#[Route('user/list', methods: ['GET'])]
#[Tag(name: 'user')]
public function __invoke(
UserListRequest $request,
UserListService $userListService,
SpecialService $specialService
): UserListResponse
{
$users = $userListService->userList($request);
return UserListResponse::new($users, $specialService);
}
}
#[Response]
#[Schema(type: 'array', items: new Items(ref: new Model(type: UserResponse::class)))]
class UserListResponse extends ArrayIterator implements ResponseJsonInterface
{
public static function new(array $users, SpecialService $specialService)
{
$data = [];
foreach ($users as $user) {
$userResponse = new UserResponse($specialService);
$data[] = $userResponse->with($user);
}
return new UserListResponse($data);
}
}
Если контроллеры такие тонкие, может вложиться в создание конфигурации, вместо создания классов?
Можно описать контроллер декларативно, например, через yaml:
api/login:
method: POST
request: LoginRequest
service: LoginService
response: UserResponse
Ну, или еще чуть более гибкую структуру, а потом скармливать ее шаблонному методу. Но зачем? (ага, сам придумал упоротый вопрос и не знает зачем). Чтобы гарантировать соблюдения архитектуры тонких до прозрачности контроллеров? Оптимизация? Да не, все это шляпа. Код куда лучше, чем конфиг.
Можно ли в контроллере одновременно использовать параметры из роута и дто, вложенные дто, запросы с разными Content-type?
Да:
<?php
#[Route('/api/post/{id}/like', methods: ['POST'])]
#[Tag(name: 'post')]
public function __invoke(int id, LikeRequest $likeRequest, ...
Можно даже подкрутить ValueResolver, чтобы он проталкивал параметры из роута внутрь дто. А еще подобрать нужный набор сериализаторов, нормалайзеров, экстракторов и других часто упоминаемых с этим механизмом слов. Технических стопоров нет. Пример кастомного расчехления json запроса (для любопытных):
<?php
$extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new CustomReflectionExtractor()]);
$normalizer = [
new CustomDateTimeNormalizer(),
new DateTimeNormalizer(),
new ArrayDenormalizer(),
new ObjectNormalizer(null, null, null, $extractor),
];
$this->serializer = new Serializer($normalizer, [new JsonEncoder(), new XmlEncoder()]);
try {
$request = $serializer->denormalize($payload, $argument->getType(), null, [
AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true,
]);
} catch (Exception $exception) {
$this->assertSpecificProblem($exception);
$violations = ConstraintViolationList::createFromMessage($exception->getMessage());
throw new RequestValidationException($violations);
}
$this->fullValidation($request);
public function fullValidation($object): void
{
$violations = $this->validator->validate($object);
if ($violations->count()) {
throw new RequestValidationException($violations);
}
foreach ($object as $value) {
if (
($value instanceof RequestJsonDtoInterface)
|| ($value instanceof RequestFileInterface)
|| (is_iterable($value))
) {
$this->fullValidation($value);
}
}
}
Зачем нужны дто, почему не взять существующие модели?
Думали тут будет очередной холиварный текст? Не благодарите ;)
Комментарии (9)
koreychenko
20.11.2023 15:37Вам осталось сделать ещё один шаг и вы придёте к API platform :-)
vodevel Автор
20.11.2023 15:37Мое знакомство с этой штукой было примерно таким:
- Здравствуйте, желаете поговорить об апиплатформ? У нас контроллеры светятся от счастья, но на алтарь пойдут ваши модели, мы нашинкуем их дополнительными атрибутами, эти атрибуты с помощью других атрибутов перемешаем по группам (поверьте, это так удобно!), подсадим на круд-иглу, и политику доступов тоже заберем себе. Интересует?
- Спасибо, но мне бы просто генератор апи интерфейса (которым даже пользоваться будут в основном для экспорта в постмен/инсомнию).Но теперь на их главной странице красуется ApiResource, без каких-либо обязательств. И я в растерянности) Возможно сам себе навертел это все на мозги.
IgorPI
20.11.2023 15:37Есть же Symfony 6.3: Mapping Request Data to Typed Objects зачем prugala/symfony-request-dto?
vodevel Автор
20.11.2023 15:37Спасибо, попробую! Вообще, я больше хотел популяризовать ValueResolver, в котором можно самостоятельно вклинится в алгоритм переливания запроса в дто. Если Mapping Request Data обладает такой же гибкостью, то супер!
dmitriylanets
20.11.2023 15:37Всегда удивляли такие подходы реализацию контакта держать вместе с контрактом
strokoff
Хороший пример, однозначно в копилку. Хотелось выразить отдельный + за само стремление делать код, а не спецификацию, на многих проектах в современном мире сваггер пишется в отрыве от кода и реализации и представляет собой больше набор обещаний, чем спецификацию реализованного сервиса.
vodevel Автор
Большое спасибо! Наши фронты тоже поддержали эту мысль, что "сила в правде", и работать с актуальным апи без купюр лучше, чем с намарафеченной хнёй. Кажется, искусственное описание вместо отображения реальности - поворот не туда.
NightShad0w
А разве спецификация интерфейса не есть набор обещаний? А реализация - частный случай, выполняющий эти обещания?
Как по мне, так сам подход от кода в спецификации чреват проблемами с неотторгаемым интерфейсом от реализации, а потому стабы, моки, и бойлерплейт проходят мимо.
vodevel Автор
Не уверен, что понял вопрос. Поэтому если отвечу не то, не обессудь)
Контроллер с его сигнатурой входа/выхода - это и есть интерфейс (контракт). А его реализация - сервис, где можно задействовать вот это вот всё. Так что здесь отображение кода не мешает ООП, а лишь гарантирует достоверность спецификации (хоть и делает ее более куцей). За кадром остались тесты, которые будут следить, чтобы контракт конкретной версии апи не поменялся. Но если документация использует не код, а дополнительные нотации - то тесты ее не защитят.