Кто из нас не был одурманен сказками про свагер? Мол, добавь эту волшебную штуку — да заживешь! Но плата за магию — зеленое болото нотаций. А нельзя ли обойтись только типизацией самого 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); } } }
Зачем нужны дто, почему не взять существующие модели?
Думали тут будет очередной холиварный текст? Не благодарите ;)
