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

Кто из нас не был одурманен сказками про свагер? Мол, добавь эту волшебную штуку — да заживешь! Но плата за магию — зеленое болото нотаций. А нельзя ли обойтись только типизацией самого php? (Спойлер: онжом)

Цель — превратить этот симпатичный Symfony контроллер

login($request);

        return $response->with($user);
    }
}

В такую до боли знакомую штуку

Осторожно! Swagger показывает OpenApi без прикрас

Не самая говорливая, зато правдива и незатратна

Не самая говорливая, зато правдива и незатратна

То есть сначала типизируем входные/выходные данные, а затем превратим типы в нотации. В путь!

Шаг 1. Типизация запроса

Чтобы в контроллер вместо базового Request объекта передавался дто, возьмем готовую библиотеку prugala/symfony-request-dto. Там используется механизм ValueResolver, который потом можно, не стесняясь, переделать под себя.

Дополнительные условия над полями — приятный бонус, но не стоит этим сильно увлекаться. Здесь уместна валидация только типов, но не данных.

Отлично, работаем дальше ©

Шаг 2. Типизация ответа

Symfony бухтит, когда контроллер возвращает не Response объект. Конечная? К счастью, разрулить ситуацию можно, если заабузить событие kernel.view:

Реализация

services:
    App\Listener\ResponseListener:
        tags:
            - { name: kernel.event_listener, event: kernel.view}
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);
    }
}

Благодаря этому можно возвращать обычный дто, а вот, кстати, и он:

id = $user->id();
        $this->email = $user->email();
        $this->special = $this->specialService->special($user);

        return $this;
    }
}

Теперь контроллер не на словах, а на деле соблюдает заявленные контракты! (звучит, да?)

Шаг 3. Заводим свагер

Для этого воспользуемся библиотекой nelmio/NelmioApiDocBundle. В ней уже из коробки есть возможность использовать классы для нотаций:

Вроде бы почти то, что надо! Но дублирование все равно остается :(

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

Шаг 4. Кастом

Расширим NelmioApiDocBundle с помощью vodevel/api-doc-bundle-type-describer. Этот бандл хоть и примитивен, но делает что до́лжно:

У контроллера все еще осталась нотация #[Tag], без которой он просто не будет замечен и обработан. А у дто появились атрибуты #[RequestBody], #[Response]. Но дублирующих нотаций нет. Точно нет? Да говорю же!

Шаг 5. Страшно, вырубай

Хотите попробовать, но боитесь превратить документацию в тыкву? Я тоже. Поэтому библиотека работает крайне деликатно. Методы контроллеров, у которых уже есть нотации, не затронет.

Кстати, если в дто имя поля недостаточно говорящее, то можно добавить Property:

Professional!

[Из невошедшего]

Как задокументировать список возможных ответов, а не только один успешный?

Подловили! Такой возможности нет (ну, кроме как вручную расписать). Для исключений можно было бы еще что-то придумать (аннотации с проверкой через снифер, или анализ AST). Но не настолько я люблю список ответов. А вы?

Почему для описания дто используется и интерфейсы, и атрибуты?

Чтобы подчеркнуть их независимость, ведь это две разные ответственности так то. Можно добиться их однообразия, используя только механизм атрибутов, но их не так удобно проверять, как интерфейсы. А использовать только интерфейсы не получится, так как для документации порой нужна начинка.

Перечислять Response объект в параметрах контроллера обязательно?

Главным образом это сделано для поддержки DI. Если хотите иммутабельный объект, то можно и так:

login($request);

   return new UserResponse($specialService, $user);
}

А если надо возвращать не объект, а список объектов, например, список юзеров?

Жизнь показала, что голые списки — такое себе. Рано или поздно, но появятся метаданные (пагинация, агрегация, для отладки доп.информация). Поэтому для списка тоже лучше использовать объект с ключом «data» («items», «collection», «list»).

Но если вопрос стоит ребром (апи уже существует), то вот:

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?

Да:

Можно даже подкрутить ValueResolver, чтобы он проталкивал параметры из роута внутрь дто. А еще подобрать нужный набор сериализаторов, нормалайзеров, экстракторов и других часто упоминаемых с этим механизмом слов. Технических стопоров нет. Пример кастомного расчехления json запроса (для любопытных):

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);
        }
    }
}

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

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

© Habrahabr.ru