Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров12K

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

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

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

Теги:
Хабы:
Всего голосов 21: ↑21 и ↓0+21
Комментарии9

Публикации

Ближайшие события