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