惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Security Affairs
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
The Blog of Author Tim Ferriss
J
Java Code Geeks
月光博客
月光博客
Recorded Future
Recorded Future
WordPress大学
WordPress大学
MongoDB | Blog
MongoDB | Blog
小众软件
小众软件
人人都是产品经理
人人都是产品经理
B
Blog
U
Unit 42
宝玉的分享
宝玉的分享
IT之家
IT之家
Blog — PlanetScale
Blog — PlanetScale
GbyAI
GbyAI
The Cloudflare Blog
Recent Announcements
Recent Announcements
Microsoft Security Blog
Microsoft Security Blog
D
Docker
Hugging Face - Blog
Hugging Face - Blog
I
InfoQ
D
DataBreaches.Net
云风的 BLOG
云风的 BLOG
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园_首页
Martin Fowler
Martin Fowler
G
Google Developers Blog
雷峰网
雷峰网
A
About on SuperTechFans
量子位
L
LangChain Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
aimingoo的专栏
aimingoo的专栏
C
Check Point Blog
博客园 - 司徒正美
N
Netflix TechBlog - Medium
The Register - Security
The Register - Security
博客园 - 【当耐特】
Engineering at Meta
Engineering at Meta
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
F
Full Disclosure
Stack Overflow Blog
Stack Overflow Blog
S
SegmentFault 最新的问题
P
Proofpoint News Feed
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Vercel News
Vercel News
T
Threatpost
B
Blog RSS Feed
K
Kaspersky official blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
OpenAPI без #[OA\...]: как я сделал генератор документации для Symfony
Anatolii Nekhai · 2026-06-15 · via Все публикации подряд на Хабре

Средний

16 мин

48

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

Именно так я и хотел сделать.