В прошлой статье я уже рассказывал, как однажды устроился на работу и получил пачку «интересных» задач: вручную синхронизировать 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.php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Sunrise\Symfony\OpenApi\OpenApiBundle::class => ['all' => true],
];
Импортируйте маршруты пакета:
# config/routes.yaml
openapi:
resource: '@OpenApiBundle/config/routes.php'
После этого будут доступны два маршрута:
GET /docs
GET /docs/openapi.json
/docs открывает Swagger UI./docs/openapi.json возвращает OpenAPI JSON document.
Базовая конфигурация может выглядеть так:
# config/packages/openapi.yaml
parameters:
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.yaml
swagger_ui:
path: /swagger.html
controller: Sunrise\Symfony\OpenApi\Controller\SwaggerController
methods: [GET]
options:
api: false
Если меняется путь самого OpenAPI-документа, нужно обновить и маршрут, и openapi.document_uri, чтобы Swagger UI загружал правильный документ:
# config/routes.yaml
openapi_document:
path: /openapi.json
controller: Sunrise\Symfony\OpenApi\Controller\DocumentController
methods: [GET]
options:
api: false
# config/packages/openapi.yaml
parameters:
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.yaml
parameters:
openapi.initial_operation:
responses:
default:
description: The operation was unsuccessful.
content:
application/json:
schema: 'App\Http\View\ErrorResponseView'
Или точечно через #[Operation].
3. Единый формат дат
Я бы отдельно навёл порядок с датами.
Например:
# config/services.yaml
parameters:
app_output_timestamp_format: 'Y-m-d\TH:i:s.up'
# config/packages/serializer.yaml
framework:
serializer:
enabled: true
default_context:
datetime_format: '%app_output_timestamp_format%'
# config/packages/openapi.yaml
parameters:
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 — этого обычно хватает.
Именно так я и хотел сделать.























