Типичный Swagger без гмо

от автора

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

Зачем нужны дто, почему не взять существующие модели?

Думали тут будет очередной холиварный текст? Не благодарите 😉


ссылка на оригинал статьи https://habr.com/ru/articles/775056/