В прошлой статье я уже рассказывал, как однажды устроился на работу и получил пачку «интересных» задач: вручную синхронизировать OpenAPI, оформленный в комментариях к коду, с самим кодом в десятке сервисов.
Тогда это звучало как начало анекдота, но мне было не до смеха.
С тех пор я сменил работу. И, как будто вселенная решила проверить моё чувство юмора, я снова вижу API, где контракт живёт рядом с кодом в ручных #[OA\...] атрибутах.
И это важный момент. Это не история про одну конкретную компанию, один неудачный проект или один старый сервис, который все боятся трогать. Я вижу этот подход в разных местах.
Symfony уже знает маршруты. Контроллер уже знает входные параметры. DTO уже описывает request. View object уже описывает response. Но поверх этого всё равно часто пишется ещё один слой:
#[OA\Post( path: '/v1/completions', requestBody: new OA\RequestBody(...), responses: [ new OA\Response( response: 200, description: 'Completion result', content: new OA\JsonContent(...), ), ],)]#[Route('/v1/completions', methods: ['POST'])]public function __invoke(CreateCompletionRequest $request): JsonResponse{ // ...}
Я не считаю OpenAPI проблемой. OpenAPI полезен. Swagger UI полезен. Контракт API полезен.
Проблема в другом: мы часто вручную дублируем то, что уже есть в коде.
Поменял DTO — не забудь поменять OpenAPI. Поменял response — не забудь поменять OpenAPI. Поменял status code — не забудь поменять OpenAPI. Не поменял — в лучшем случае – баг.
Я уже несколько лет занимаюсь этим вопросом и считаю, что в большинстве случаев API-документация должна вытекать из кода, а не жить рядом с ним как отдельный ручной артефакт.
Именно поэтому я сделал sunrise-studio/symfony-openapi.
Что я хотел получить
Я хотел, чтобы обычный Symfony контроллер выглядел примерно так:
declare(strict_types=1);namespace App\Http\Controller;use App\Http\Request\CreateCompletionRequest;use App\Http\View\CompletionView;use App\Service\CompletionService;use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions', methods: ['POST'])]final readonly class CreateCompletionController{ public function __construct( private CompletionService $completionService, ) { } public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request, ): CompletionView { return new CompletionView( text: $this->completionService->complete($request->prompt), ); }}
Request DTO:
declare(strict_types=1);namespace App\Http\Request;final readonly class CreateCompletionRequest{ public function __construct( public string $prompt, ) { }}
View object:
declare(strict_types=1);namespace App\Http\View;final readonly class CompletionView{ public function __construct( public string $text, ) { }}
Маршрут описывает path и method.#[MapRequestPayload] описывает, откуда взять входные данные.
Request DTO описывает request body.
Return type описывает response.
View object описывает форму ответа.
По моей задумке, этого уже должно быть достаточно, чтобы получить OpenAPI-документ.
Не всегда. Есть corner cases. Но для большинства API methods, по моему опыту, этого действительно достаточно.
А что если…
Во времена ИИ-ажиотажа можно, конечно, решить проблему примерно так:
declare(strict_types=1);namespace App\Command;use App\Ai\ArtificialIntelligenceInterface;use Symfony\Component\Console\Attribute\AsCommand;use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;#[AsCommand('app:generate-openapi')]final readonly class GenerateOpenApiCommand extends Command{ public function __construct( private ArtificialIntelligenceInterface $ai, ) { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { $document = $this->ai->complete( <<<'PROMPT' Analyze all Symfony controllers in src/Http/Controller. Generate a valid OpenAPI 3.1 JSON document based on routes, request DTOs and response types. Return only raw JSON without Markdown, comments or explanations. PROMPT, ); file_put_contents(__DIR__ . '/../../var/openapi.json', $document); return Command::SUCCESS; }}
Я не серьёзно.
Очень надеюсь, что так никто не делает.
Я не мог не поделиться этой шуткой, меня она позабавила, надеюсь и вас!
И дело даже не в том, что ИИ может ошибиться. Он обязательно ошибётся. Вопрос только в том, насколько уверенно и насколько поздно вы это заметите.
Что делает пакет
sunrise-studio/symfony-openapi генерирует OpenAPI-документ из того, что уже есть в Symfony-приложении:
-
Symfony routes;
-
сигнатур контроллеров;
-
Symfony HttpKernel attributes;
-
типизированных DTO/View classes;
-
route options;
-
небольших OpenAPI attributes только для тех случаев, где PHP-типов и маршрутов уже недостаточно.
Главная цель простая: обычные endpoints не должны требовать больших блоков #[OA\...].
Если endpoint простой, он должен документироваться почти сам. Если endpoint особенный, можно вмешаться вручную, но точечно.
Подробности я постарался описать в README. Там есть установка, конфигурация, route options, request mapping, responses, errors, ручные OpenAPI-фрагменты, schema resolvers и extension points.
Как минимум, если не для вас лично, то для ИИ, которому вы потом скормите README и попросите подключить пакет в проекте.
Установка и первый запуск
Установка:
composer require sunrise-studio/symfony-openapi
Подключите bundle:
// config/bundles.phpreturn [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Sunrise\Symfony\OpenApi\OpenApiBundle::class => ['all' => true],];
Импортируйте маршруты пакета:
# config/routes.yamlopenapi: resource: '@OpenApiBundle/config/routes.php'
После этого будут доступны два маршрута:
GET /docsGET /docs/openapi.json
/docs открывает Swagger UI./docs/openapi.json возвращает OpenAPI JSON document.
Базовая конфигурация может выглядеть так:
# config/packages/openapi.yamlparameters: openapi.initial_document: openapi: 3.1.1 info: title: API version: 1.0.0
Сгенерировать документ в файл можно командой:
php bin/console openapi:build-document
По умолчанию команда записывает документ в файл, указанный в openapi.document_filename.
Если нужен другой путь для Swagger UI, можно определить маршрут самому:
# config/routes.yamlswagger_ui: path: /swagger.html controller: Sunrise\Symfony\OpenApi\Controller\SwaggerController methods: [GET] options: api: false
Если меняется путь самого OpenAPI-документа, нужно обновить и маршрут, и openapi.document_uri, чтобы Swagger UI загружал правильный документ:
# config/routes.yamlopenapi_document: path: /openapi.json controller: Sunrise\Symfony\OpenApi\Controller\DocumentController methods: [GET] options: api: false
# config/packages/openapi.yamlparameters: openapi.document_uri: /openapi.json
Минимальный endpoint
Вернёмся к примеру с completions.
declare(strict_types=1);namespace App\Http\Controller;use App\Http\Request\CreateCompletionRequest;use App\Http\View\CompletionView;use App\Service\CompletionService;use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions', methods: ['POST'])]final readonly class CreateCompletionController{ public function __construct( private CompletionService $completionService, ) { } public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request, ): CompletionView { return new CompletionView( text: $this->completionService->complete($request->prompt), ); }}
Здесь нет:
#[OA\Post(...)]#[OA\RequestBody(...)]#[OA\Response(...)]#[OA\JsonContent(...)]
Идея в том, что мне не нужно второй раз описывать то, что уже есть в коде.
Route уже знает path и method.CreateCompletionRequest уже знает request body.CompletionView уже знает response body.
Return type контроллера уже говорит, что endpoint возвращает CompletionView.
Если всё-таки хочется добавить metadata
Иногда нужно добавить tags, summary, description или status code.
Я не хотел превращать это обратно в большой OpenAPI-блок, поэтому metadata можно держать ближе к маршруту:
declare(strict_types=1);namespace App\Http\Controller;use App\Http\Request\CreateCompletionRequest;use App\Http\View\CompletionView;use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\Routing\Attribute\Route;#[Route( '/v1/completions', methods: ['POST'], options: [ 'tags' => ['Completions'], 'summary' => 'Creates completion', 'description' => 'Creates text completion for the given prompt.', 'response_code' => 201, ],)]final readonly class CreateCompletionController{ public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request, ): CompletionView { // ... }}
Это всё ещё выглядит как описание маршрута и поведения endpoint-а, а не как отдельный OpenAPI-документ внутри PHP-атрибута.
Поддерживаются, например:
-
tag,tags; -
summary; -
description; -
deprecated; -
api; -
response_code; -
response_format,response_formats.
Если вашему проекту не нравится хранить это в route options, можно заменить RouteMetadataResolverInterface.
Request body, query, path variables
Пакет понимает Symfony attributes, которые описывают request data.
Request body через #[MapRequestPayload]:
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions', methods: ['POST'])]public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request,): CompletionView { // ...}
Path variables читаются из маршрута и сигнатуры метода:
use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions/{id}', methods: ['GET'])]public function __invoke(string $id): CompletionView{ // ...}
Query object можно описать через #[MapQueryString]:
use Symfony\Component\HttpKernel\Attribute\MapQueryString;use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions', methods: ['GET'])]public function __invoke( #[MapQueryString] CompletionListQuery $query,): CompletionListView { // ...}
Обычные scalar query parameters можно описывать через #[MapQueryParameter].
Почему я предпочитаю View objects вместо JsonResponse
Я не могу и не хочу навязывать всем архитектуру. У проектов бывают разные требования, legacy, соглашения, странные интеграции и ещё множество вещей, которые обычно появляются в системе после слов «это временно».
Но лично я уже не в одном проекте отказался от ручного возврата JsonResponse/Response из большинства API-контроллеров.
На мой взгляд, для API это чище:
public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request,): CompletionView { return new CompletionView( text: $this->completionService->complete($request->prompt), );}
Контроллер возвращает результат операции.
HTTP-слой занимается сериализацией.
OpenAPI-генератор видит return type и может построить schema.
Я не называю это DDD в строгом смысле. Скорее это нормальная граница между application code и HTTP-представлением. DTO описывает вход, View object описывает выход, а контроллер перестаёт быть местом, где руками собираются JSON-массивы, status codes и документация.
У меня есть проект со 100+ API методами, и буквально в нескольких местах пришлось вернуть Response напрямую. Такие corner cases бывают. Это нормально.
Для них есть ручное описание операции через #[Operation].
Ручное описание через #[Operation] и Type
Если endpoint возвращает Response, пакет не может автоматически понять response body. И это правильно: если вы вернули низкоуровневый Response, значит вы сами взяли контроль над ответом.
Но даже в таком случае не обязательно возвращаться к огромным #[OA\...].
Можно использовать #[Operation] и Type:
declare(strict_types=1);namespace App\Http\Controller;use App\Http\Request\CreateCompletionRequest;use App\Http\View\CompletionView;use Sunrise\Symfony\OpenApi\Annotation\Operation;use Sunrise\Symfony\OpenApi\Type;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions/stream', methods: ['POST'])]final readonly class StreamCompletionController{ #[Operation([ 'responses' => [ 200 => [ 'description' => 'Completion stream.', 'content' => [ 'application/json' => [ 'schema' => new Type(CompletionView::class), ], ], ], ], ])] public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request, ): Response { // corner case }}
Type удобен тем, что можно сослаться на PHP-класс, а пакет сам превратит его в OpenAPI schema.
Ручной режим есть, но он, как видно на примере выше, точечный.
Symfony 8.1 и сериализация результата контроллера
В Symfony 8.1 появился нативный #[Serialize], который позволяет вернуть из контроллера объект или массив, а Symfony сам сериализует результат в Response.
Это очень приятный бонус.
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\HttpKernel\Attribute\Serialize;use Symfony\Component\Routing\Attribute\Route;#[Route('/v1/completions', methods: ['POST'])]#[Serialize(code: 201)]public function __invoke( #[MapRequestPayload] CreateCompletionRequest $request,): CompletionView { // ...}
Если #[Serialize] есть, пакет может учитывать его code, а schema всё равно берётся из PHP return type.
Но я бы не стал обновлять Symfony только ради документации. Если проект ниже 8.1, runtime-сериализация результата контроллера делается небольшим listener-ом.
Например, JSON-only вариант может выглядеть так:
declare(strict_types=1);namespace App\Http\EventListener;use Symfony\Component\EventDispatcher\Attribute\AsEventListener;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Event\ViewEvent;use Symfony\Component\HttpKernel\KernelEvents;use Symfony\Component\Routing\Route;use Symfony\Component\Routing\RouterInterface;use Symfony\Component\Serializer\SerializerInterface;#[AsEventListener(event: KernelEvents::VIEW)]final readonly class JsonControllerResultListener{ public function __construct( private SerializerInterface $serializer, private RouterInterface $router, ) { } public function __invoke(ViewEvent $event): void { $result = $event->getControllerResult(); if ($result === null) { $event->setResponse(new Response(status: Response::HTTP_NO_CONTENT)); return; } $event->setResponse(new JsonResponse( data: $this->serializer->serialize($result, 'json'), status: $this->resolveResponseCode($event), json: true, )); } private function resolveResponseCode(ViewEvent $event): int { $routeName = $event->getRequest()->attributes->get('_route'); if (!is_string($routeName)) { return Response::HTTP_OK; } $route = $this->router->getRouteCollection()->get($routeName); if (!$route instanceof Route) { return Response::HTTP_OK; } return $route->getOption('response_code') ?? Response::HTTP_OK; }}
Такой listener не должен поставляться с пакетом.
Это выходит за рамки его ответственности. У каждого проекта могут быть свои правила: поддержка заголовка Accept, разные форматы ответа, serializer groups, логирование, нестандартные headers, динамическая сериализация и так далее.
Пакет отвечает за генерацию OpenAPI-документа. Runtime-поведение приложения должно оставаться под контролем самого приложения.
Что, возможно, придётся изменить в проекте
Пакет можно поставить и попробовать достаточно быстро.
Но если хочется получить от него максимум пользы, возможно, придётся привести в порядок сам API-слой.
1. Меньше HttpFoundation Request/Response в контроллерах
Если контроллеры везде принимают Request и возвращают JsonResponse, генератору сложнее понять публичный контракт.
Я бы рекомендовал для обычных API methods использовать:
-
request DTO для входа;
-
View objects для выхода;
-
typed properties;
-
явные return types;
-
минимальное количество ручных OpenAPI-фрагментов.
Не потому что «так надо по книжке», а потому что так проще читать код, проще рефакторить и проще генерировать документацию.
2. Единый формат ошибок
Отдельно стоит пересмотреть обработку ошибок.
Очень неудобно, когда один endpoint возвращает:
{"message": "Validation failed"}
другой:
{"error": "Bad request"}
а третий:
{"success": false, "data": null, "exception": "..."}
Тут никакой генератор не спасёт. Он может описать контракт, но если контракт размазан по проекту в виде разных случайных форматов, документировать особо нечего.
В документации есть отдельный раздел про документирование ошибок. Я бы рекомендовал привести ошибки к единому виду и уже его документировать.
Например, view для ошибки может выглядеть так:
declare(strict_types=1);namespace App\Http\View;use Symfony\Component\Validator\ConstraintViolationListInterface;final readonly class ErrorResponseView{ public function __construct( public string $message, /** @var array<array-key, ErrorView> */ public array $errors = [], ) { } public static function fromViolationList( ConstraintViolationListInterface $violations, ?string $message = null, ): self { return new self( message: $message ?: 'Validation failed', errors: array_map(ErrorView::fromViolation(...), [...$violations]), ); }}
declare(strict_types=1);namespace App\Http\View;use Symfony\Component\Validator\ConstraintViolationInterface;final readonly class ErrorView{ public function __construct( public string $key, public string $message, ) { } public static function fromViolation(ConstraintViolationInterface $violation): self { return new self( key: $violation->getPropertyPath(), message: (string) $violation->getMessage(), ); }}
А ExceptionSubscriber может приводить исключения к этой форме:
declare(strict_types=1);namespace App\Http\Subscriber;use App\Http\View\ErrorResponseView;use Psr\Log\LoggerInterface;use Symfony\Component\EventDispatcher\EventSubscriberInterface;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Event\ExceptionEvent;use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;use Symfony\Component\HttpKernel\KernelEvents;use Symfony\Component\HttpKernel\KernelInterface;use Symfony\Component\Validator\ConstraintViolationListInterface;use Symfony\Component\Validator\Exception\ValidationFailedException;final readonly class ExceptionSubscriber implements EventSubscriberInterface{ public function __construct( private LoggerInterface $logger, private KernelInterface $kernel, ) { } /** * @return array<string, string> */ public static function getSubscribedEvents(): array { return [KernelEvents::EXCEPTION => 'onKernelException']; } public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); if ($exception instanceof HttpExceptionInterface) { $event->setResponse($this->buildHttpErrorResponse($exception)); return; } $this->logger->error($exception->getMessage(), [ 'exception' => $exception, ]); $event->setResponse($this->buildFatalErrorResponse($exception)); } private function buildErrorResponse(string $message, int $status): JsonResponse { $message = $message ?: Response::$statusTexts[$status] ?? (string) $status; return new JsonResponse(new ErrorResponseView($message), $status); } private function buildHttpErrorResponse(HttpExceptionInterface $exception): Response { $previous = $exception->getPrevious(); $response = null; if ($previous instanceof ValidationFailedException) { $response = $this->buildValidationErrorResponse( $previous->getViolations(), $exception->getMessage(), $exception->getStatusCode(), ); } $response ??= $this->buildErrorResponse( $exception->getMessage(), $exception->getStatusCode(), ); $response->headers->add($exception->getHeaders()); return $response; } private function buildValidationErrorResponse( ConstraintViolationListInterface $violations, ?string $message = null, ?int $status = null, ): JsonResponse { return new JsonResponse( ErrorResponseView::fromViolationList($violations, $message), $status ?? Response::HTTP_BAD_REQUEST, ); } private function buildFatalErrorResponse(\Throwable $exception): Response { $message = $this->kernel->isDebug() ? (string) $exception : 'Something went wrong'; return $this->buildErrorResponse( $message, Response::HTTP_INTERNAL_SERVER_ERROR, ); }}
После этого общий error response можно описать через openapi.initial_operation:
# config/packages/openapi.yamlparameters: openapi.initial_operation: responses: default: description: The operation was unsuccessful. content: application/json: schema: 'App\Http\View\ErrorResponseView'
Или точечно через #[Operation].
3. Единый формат дат
Я бы отдельно навёл порядок с датами.
Например:
# config/services.yamlparameters: app_output_timestamp_format: 'Y-m-d\TH:i:s.up'
# config/packages/serializer.yamlframework: serializer: enabled: true default_context: datetime_format: '%app_output_timestamp_format%'
# config/packages/openapi.yamlparameters: openapi.default_timestamp_format: '%app_output_timestamp_format%'
Так runtime-сериализация и OpenAPI examples хотя бы смотрят в одну сторону.
Массивы и item types
Отдельная боль PHP — массивы.
Сам тип array почти ничего не говорит о публичном контракте. Это может быть список строк, список объектов, ассоциативный массив, карта ошибок, что угодно.
Для таких случаев пакет умеет читать item type из PHPDoc:
declare(strict_types=1);namespace App\Http\View;final readonly class CompletionListView{ public function __construct( /** @var CompletionView[] */ public array $items, ) { }}
Поддерживаются разные формы описания item type, а если нужно явное переопределение, есть #[ItemType].
Я не хочу превращать статью в пересказ README, поэтому подробности лучше смотреть в документации.
Ограничения
Важно честно сказать: пакет не пытается угадать вообще всё.
DTO и View объекаты описываются по типизированным свойствам. Пакет не обязан читать всю runtime-магию Symfony Serializer: groups, getters, setters, SerializedName, name converters или camelCase/snake_case conversion rules.
Я считаю это нормальным компромиссом.
Для публичного API явные DTO и View объекты часто проще, надёжнее и лучше переживают рефакторинг. Если нужна другая внешняя форма, можно сделать отдельный View объект и замапить в него сущность.
Если вашей команде нужна first-class поддержка Symfony Serializer metadata, это уже отдельная задача и отдельная стратегия, а не то, что стоит неявно смешивать с базовой генерацией схем.
Точки расширения
Пакет собран из заменяемых сервисов для проектов со своими соглашениями.
Можно заменить или расширить:
-
RouteMetadataResolverInterface; -
ResponseMetadataResolverInterface; -
OpenApiOperationEnricherInterface; -
OpenApiPhpTypeSchemaResolverInterface; -
OpenApiPathBuilderInterface.
Например, если у проекта свои правила для status codes или response formats, можно заменить ResponseMetadataResolverInterface.
Если нужен особый PHP type, можно реализовать OpenApiPhpTypeSchemaResolverInterface и зарегистрировать resolver.
Идея в том, чтобы не форкать пакет ради каждого проектного правила.
Что получилось
Я хотел получить инструмент, который в обычном случае работает по принципу:
установил, подключил, описал нормальные DTO/View objects, получил OpenAPI.
Почти «установил и забыл».
Разумеется, в реальном проекте всё равно есть нюансы: ошибки, даты, runtime-сериализация, нестандартные ответы, внутренние соглашения. Но это уже нормальная инженерная настройка, а не ручное переписывание одного и того же контракта в двух местах.
И, возможно, главный эффект даже не в Swagger UI.
Пакет может подтолкнуть чей-то, а может быть и ваш, проект к более чистому API:
-
меньше ручной сборки
JsonResponse; -
меньше дублирования;
-
больше typed DTO;
-
больше явных View objects;
-
понятнее ошибки;
-
прозрачнее даты;
-
проще рефакторинг.
Я не говорю, что у всех в проектах бардак. Я понятия не имею, что у вас в проектах. Но на моём опыте последних лет вокруг API часто не очень аккуратно, и я рад, что могу хотя бы немного повлиять на это.
Документация
Документация пакета лежит здесь:README-ru.md
Я постарался описать не только «happy path», но и реальные пользовательские сценарии:
-
установка;
-
маршруты документации;
-
генерация документа;
-
route options;
-
Symfony attributes;
-
responses;
-
ручные OpenAPI-фрагменты;
-
ошибки;
-
type schema resolvers;
-
object schemas;
-
extension points.
Возможно, вы прочитаете её сами.
Возможно, скормите её ИИ и попросите помочь подключить пакет.
В любом случае идея та же: меньше ручного описания OpenAPI, больше нормального кода, из которого контракт можно вывести автоматически.
Главное, чтобы ИИ не начал сам придумывать OpenAPI вместо того, чтобы использовать уже существующую архитектуру.
А если хочется пойти дальше
Если вам близка сама идея API, где документация является естественным продолжением кода, а не отдельным этапом работы, можно посмотреть и на sunrise/http-router.
Про него я уже писал в прошлой статье.
Symfony bundle появился не вместо этого маршрутизатора, а как продолжение той же идеи для Symfony-проектов. Если у вас Symfony — можно попробовать sunrise-studio/symfony-openapi. Если хочется попробовать другой маршрутизатор и чуть другую архитектуру API — можно посмотреть в сторону Sunrise Router.
Финал
OpenAPI полезен. Swagger UI полезен. Контракт API полезен.
Но если контракт приходится постоянно синхронизировать руками с кодом, рано или поздно он начнёт расходиться с реальностью. Это неизбежно.
Я считаю, что в Symfony уже достаточно информации, чтобы большую часть OpenAPI-документации генерировать автоматически.
Маршруты, сигнатуры, DTO, View objects и небольшое количество metadata — этого обычно хватает.
Именно так я и хотел сделать.
ссылка на оригинал статьи https://habr.com/ru/articles/1047686/