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


  1. strokoff
    20.11.2023 15:37
    +2

    Хороший пример, однозначно в копилку. Хотелось выразить отдельный + за само стремление делать код, а не спецификацию, на многих проектах в современном мире сваггер пишется в отрыве от кода и реализации и представляет собой больше набор обещаний, чем спецификацию реализованного сервиса.


    1. vodevel Автор
      20.11.2023 15:37
      +1

      Большое спасибо! Наши фронты тоже поддержали эту мысль, что "сила в правде", и работать с актуальным апи без купюр лучше, чем с намарафеченной хнёй. Кажется, искусственное описание вместо отображения реальности - поворот не туда.


    1. NightShad0w
      20.11.2023 15:37

      А разве спецификация интерфейса не есть набор обещаний? А реализация - частный случай, выполняющий эти обещания?

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


      1. vodevel Автор
        20.11.2023 15:37

        Не уверен, что понял вопрос. Поэтому если отвечу не то, не обессудь)

        Контроллер с его сигнатурой входа/выхода - это и есть интерфейс (контракт). А его реализация - сервис, где можно задействовать вот это вот всё. Так что здесь отображение кода не мешает ООП, а лишь гарантирует достоверность спецификации (хоть и делает ее более куцей). За кадром остались тесты, которые будут следить, чтобы контракт конкретной версии апи не поменялся. Но если документация использует не код, а дополнительные нотации - то тесты ее не защитят.


  1. koreychenko
    20.11.2023 15:37

    Вам осталось сделать ещё один шаг и вы придёте к API platform :-)


    1. vodevel Автор
      20.11.2023 15:37

      Мое знакомство с этой штукой было примерно таким:

      - Здравствуйте, желаете поговорить об апиплатформ? У нас контроллеры светятся от счастья, но на алтарь пойдут ваши модели, мы нашинкуем их дополнительными атрибутами, эти атрибуты с помощью других атрибутов перемешаем по группам (поверьте, это так удобно!), подсадим на круд-иглу, и политику доступов тоже заберем себе. Интересует?
      - Спасибо, но мне бы просто генератор апи интерфейса (которым даже пользоваться будут в основном для экспорта в постмен/инсомнию).

      Но теперь на их главной странице красуется ApiResource, без каких-либо обязательств. И я в растерянности) Возможно сам себе навертел это все на мозги.


  1. IgorPI
    20.11.2023 15:37

    Есть же Symfony 6.3: Mapping Request Data to Typed Objects зачем prugala/symfony-request-dto?


    1. vodevel Автор
      20.11.2023 15:37

      Спасибо, попробую! Вообще, я больше хотел популяризовать ValueResolver, в котором можно самостоятельно вклинится в алгоритм переливания запроса в дто. Если Mapping Request Data обладает такой же гибкостью, то супер!


  1. dmitriylanets
    20.11.2023 15:37

    Всегда удивляли такие подходы реализацию контакта держать вместе с контрактом