OpenAPI без #[OA\…]: как я сделал генератор документации для Symfony

от автора

В прошлой статье я уже рассказывал, как однажды устроился на работу и получил пачку «интересных» задач: вручную синхронизировать 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/