Фронтенд получил сгенерированный клиент, и в нём метод postApiV1OrdersOrderIdConfirm. В Swagger UI сто эндпоинтов идут плоским списком, и партнёр ищет нужный по минуте. Схема заказа скопирована в двенадцать ответов, в трёх из них отстала на поле, и клиенты этих трёх эндпоинтов поля не видят. У всех трёх бед одна причина: файл OpenAPI писали как документацию для людей, а читают его машины, и каждую пустую строку они заполняют по-своему.
Как контракт появляется раньше кода и как из него собирается проект, разобрано в статье про API-first. Здесь про сам формат: что должно стоять в файле, чтобы документация, SDK и мок получались правильными без ручной доводки.
Спецификация — не документация, а входные данные для машин: из одного файла растут Swagger UI, клиентский SDK и коллекции запросов. Поэтому пустые operationId, tags и summary — не мелочь оформления: генератор подставит вместо них свои имена, и они разойдутся по всему коду клиентов.
Один файл, четыре потребителя
OpenAPI это описание REST API в YAML или JSON, у которого есть стандарт и есть читатели. Swagger UI и Redoc рисуют из него документацию с кнопкой «выполнить». Генератор кода, обычно openapi-generator, делает из него интерфейсы контроллеров и классы DTO для сервера и готовый клиент для фронтенда или соседнего сервиса. Мок-сервер, например Prism, поднимает по нему фальшивый бэкенд, который отвечает примерами из файла, и потребители начинают интеграцию до того, как сервис написан. Линтер и проверка совместимости читают его в CI.
Один файл читают четыре потребителя, и ни один из них не человек. Поэтому пустое поле в спеке это не пробел в документации, а имя, которое за вас придумает генератор, или ответ, которого мок не сможет отдать.
Скелет файла умещается в десять строк, и дальше статья идёт по его разделам:
openapi: 3.1.0
info:
title: Orders API
version: 1.4.0
servers:
- url: https://api.shop.example/api/v1
paths:
/orders/{orderId}/confirm:
post: { ... }
components:
schemas: { ... }
responses: { ... }
securitySchemes: { ... }
security:
- bearerAuth: []
paths описывает операции, components хранит всё, на что операции ссылаются, servers и security говорят, куда ходить и с чем.
operationId: имя операции
У каждой операции есть поле operationId, и это не подпись для документации, а имя метода в чужом коде: генератор клиента делает из него функцию. Без него генератор строит имя из метода и пути, получается postApiV1OrdersOrderIdConfirm, и стоит переименовать путь, как у всех клиентов меняется имя метода и ломается сборка. Правило: имя уникально во всём файле, в camelCase, в форме «действие плюс ресурс»:
/orders:
get: { operationId: listOrders }
post: { operationId: createOrder }
/orders/{orderId}:
get: { operationId: getOrder }
patch: { operationId: patchOrder }
delete: { operationId: deleteOrder }
/orders/{orderId}/confirm:
post: { operationId: confirmOrder }
Клиентский код тогда читается как orderService.confirmOrder(orderId). Для списков берут list, для одного объекта get, для создания create, для действий над ресурсом глагол действия: confirmOrder, cancelOrder.
Если спека рождается из кода, а не наоборот, за это поле отвечает аннотация. В Spring с springdoc-openapi без неё именем операции становится имя Java-метода, а при совпадении имён генератор дописывает суффиксы, и три метода get в одном контроллере превращаются в get, get_1 и get_2:
@Operation(operationId = "confirmOrder", summary = "Подтвердить заказ")
@PostMapping("/orders/{orderId}/confirm")
public OrderResponse confirm(@PathVariable UUID orderId) { ... }
tags, summary и description
Без тегов Swagger UI показывает все операции одним списком. Поле tags собирает их в разделы, и правило простое: один тег на ресурс, во множественном числе, с заглавной буквы. Действие над заказом относится к тегу Orders, а не к придуманному OrderActions: человек ищет подтверждение заказа там же, где сам заказ. В springdoc тег вешают на класс контроллера, @Tag(name = "Orders"), и все его методы попадают в раздел.
summary это строка рядом с адресом в документации, до восьмидесяти знаков, фраза, а не предложение: «Подтвердить заказ». description пишут в Markdown и только когда в операции есть что объяснять: переходы состояний, условия, ограничения. description: Подтвердить заказ под summary: Подтвердить заказ это шум, а вот «переводит заказ из CREATED в CONFIRMED, заказ должен содержать хотя бы одну позицию, после подтверждения состав менять нельзя» это то, чего в коде клиента не увидеть.
Параметры пути: у каждого своё имя
Когда идентификатор в пути один, его пишут {id}, и это нормально. Когда их два, одинаковое имя использовать нельзя, и это требование самого стандарта: параметры операции различаются парой «имя и место», поэтому два id с местом path неразличимы. Swagger UI по такой спеке не даёт выполнить запрос, а генератор делает метод с одним параметром:
/orders/{orderId}/items/{itemId}:
get:
operationId: getOrderItem
parameters:
- { name: orderId, in: path, required: true, schema: { type: string, format: uuid } }
- { name: itemId, in: path, required: true, schema: { type: string, format: uuid } }
Те же имена обязаны стоять и в коде. Spring путь с двумя одинаковыми именами не примет вовсе: при старте разбор шаблона падает с сообщением Not allowed to capture 'id' twice in the same pattern. Сколько идентификаторов в пути, столько разных имён, и в контракте, и в контроллере.
Схемы: components и $ref
Схема заказа нужна в ответе списка, в ответе одного заказа, в ответе создания и в ответе правки. Скопировать её четыре раза легко, и ровно так получается спека, в которой три копии отстали от четвёртой. Вместо этого схему описывают один раз в components/schemas, а из ответов ссылаются на неё через $ref:
components:
schemas:
Order:
type: object
required: [id, status, total]
properties:
id: { type: string, format: uuid, readOnly: true }
status: { $ref: '#/components/schemas/OrderStatus' }
total: { type: string, description: 'Сумма в минорных единицах, строкой' }
comment: { type: [string, 'null'], maxLength: 500 }
OrderStatus:
type: string
enum: [CREATED, CONFIRMED, CANCELLED]
Схема заказа описана один раз, а четыре ответа ссылаются на неё через $ref. Новое поле появляется во всех четырёх одновременно, и генератор делает из схемы один класс Order, а не четыре одинаковых.
Три детали в этой схеме решают, каким будет сгенерированный код. required перечисляет поля, которые есть всегда: без списка каждое поле считается необязательным, и генератор сделает все поля клиента допускающими отсутствие, а фронтенд обложит каждое обращение проверкой. readOnly у id говорит, что поле приходит в ответах, но не принимается в запросах: в теле POST /orders его не будет, и генератор не заставит клиента передавать идентификатор, которого ещё нет. И перечисление вынесено в отдельную именованную схему OrderStatus: если написать enum прямо в поле, генератор создаст по одному безымянному типу на каждое место, где статус встречается, и в клиенте будет пять одинаковых Status, между которыми нельзя присвоить значение.
Ответы, ошибки и заголовки
У операции описывают каждый код ответа, который сервер действительно отдаёт, и не только успешный. Тело ошибки одно на весь сервис, формат Problem Details, поэтому ответы об ошибках тоже выносят в components и ссылаются:
/orders/{orderId}:
get:
operationId: getOrder
responses:
'200':
description: Заказ
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'404': { $ref: '#/components/responses/NotFound' }
'429': { $ref: '#/components/responses/TooManyRequests' }
components:
responses:
NotFound:
description: Ресурс не найден
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
TooManyRequests:
description: Превышен лимит запросов
headers:
Retry-After: { schema: { type: integer }, description: 'Через сколько секунд повторить' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
Заголовки ответа описывают в headers рядом с кодом, и это не формальность: клиент, сгенерированный по спеке без Retry-After, про этот заголовок не узнает и будет повторять запрос сразу. Так же описывают Location у ответа 201 и заголовки лимитов. Что кладут в само тело ошибки, разобрано в статье про ошибки.
Примеры: из них растёт мок
Мок-сервер не выдумывает ответы, он отдаёт примеры из спеки. Пока примеров нет, Prism соберёт ответ из схемы, и это будут случайные строки и числа, на которых фронтенд не сможет проверить ни одного экрана. Примеры пишут именованными, прямо у типа содержимого:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
examples:
confirmed:
summary: Подтверждённый заказ
value: { id: 6f1c…, status: CONFIRMED, total: '129900' }
cancelled:
summary: Отменённый заказ
value: { id: 9a2e…, status: CANCELLED, total: '0' }
Prism по умолчанию отдаёт первый пример, а заголовок запроса Prefer: example=cancelled переключает на нужный, и фронтенд проверяет экран отменённого заказа без единой строки бэкенда. Те же примеры показывает Swagger UI, и по ним линтер проверяет, что пример вообще соответствует схеме: пример с status: DONE при перечислении из трёх значений это ошибка сборки, а не сюрприз потребителя.
servers и securitySchemes
Раздел servers перечисляет адреса окружений, и Swagger UI даёт их выбирать; генератор клиента подставляет первый как адрес по умолчанию. Авторизацию описывают в components/securitySchemes и включают на весь файл ключом security верхнего уровня:
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
security:
- bearerAuth: []
paths:
/health:
get:
security: []
Без этого раздела кнопка «Authorize» в Swagger UI не появится, а сгенерированный клиент не будет знать, что ему нужен токен. Пустой список security: [] у операции снимает требование для публичных путей. В springdoc за это отвечают @SecurityScheme на классе конфигурации и @SecurityRequirement там, где требование отличается от общего.
3.0 или 3.1
В шапке файла стоит версия стандарта, и от неё зависит, как читать схемы. OpenAPI 3.0 использует свой урезанный диалект JSON Schema, а 3.1 полноценную JSON Schema 2020-12, и разница видна в трёх местах, на которых спотыкаются чаще всего. Поле, которое может быть пустым, в 3.0 описывают как type: string с nullable: true; в 3.1 слова nullable нет, и пишут type: [string, 'null']. Пример значения в схеме 3.0 это example, в 3.1 он объявлен устаревшим, и пишут examples списком. И граница диапазона exclusiveMinimum в 3.0 это флаг рядом с minimum, а в 3.1 само число.
Инструменты за стандартом идут с разной скоростью. springdoc по умолчанию отдаёт документ 3.0, а 3.1 включается свойством springdoc.api-docs.version=openapi_3_1. Генераторы клиентов понимают 3.1 не все и не для всех языков, поэтому перед переходом проверяют свой на type: [string, 'null']. Писать 3.0.3, пока цепочка инструментов не готова, не стыдно; стыдно смешивать: nullable в файле с шапкой 3.1.0 линтер отвергнет.
Как спека живёт в репозитории
Файл на три тысячи строк не читается и не ревьюится, поэтому его режут: схемы по одной на файл в schemas/, ответы в responses/, а $ref ведут в соседние файлы, $ref: './schemas/Order.yaml'. Для инструментов, которые хотят один файл, спеку склеивают в сборке: redocly bundle openapi.yaml -o build/openapi.yaml.
Спека живёт как код: меняется через pull request, а линт и сравнение с прошлой версией стоят в CI до ревью. Тогда ревьюер спорит о смысле полей, а не ищет глазами пропущенный operationId.
Две проверки стоят в CI до ревью. Линтер Spectral с набором правил spectral:oas находит операцию без operationId, без тегов, без описания ответа и пример, не проходящий по схеме; к набору добавляют свои правила, например список разрешённых тегов. Сравнение с прошлой версией делает oasdiff breaking main/openapi.yaml openapi.yaml: удалённое поле, новый обязательный параметр, сузившееся перечисление он помечает как ломающие изменения и роняет сборку, и решение «это v2 или совместимая правка» принимается до слияния, а не после жалобы клиента. Что считается ломающим, разобрано в статье про версионирование. Ревьюит контракт та команда, которая будет его вызывать: ей виднее, удобно ли поле, чем автору сервера.
Сама спека при этом не делает API хорошим. Глагол в адресе, версия в параметре запроса, обёртка success/data вокруг ответа и заголовки с префиксом X- прекрасно описываются в OpenAPI и так же прекрасно генерируются. Правила самого дизайна собраны в соседних статьях раздела, начиная с адресов и ресурсов.
Глубже: безопасность в контракте: схемы, области доступа и security на операциирасширенное
securitySchemes выше объявлены, но у безопасности в контракте три уровня, и пропуск любого из них означает, что сгенерированный клиент не знает, как представиться, а Swagger UI не даёт нажать «попробовать».
Первый уровень это сами схемы в components. Для внутреннего API обычно одна, bearerAuth типа http со схемой bearer и bearerFormat: JWT. Для API, которое отдают партнёрам, схема oauth2 с потоком и, главное, с областями доступа: каждая область это имя и описание, и этот список становится частью контракта, который потребитель читает раньше кода.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
partnerOAuth:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth/token
scopes:
orders:read: чтение заказов
orders:write: создание и изменение заказов
security:
- bearerAuth: []
Второй уровень это требование по умолчанию, корневой security: всё API требует bearerAuth. Третий это переопределение на операции: POST /orders требует область orders:write, а GET /health открыт, и для него пишут пустой список security: [], иначе он унаследует общее требование. Это единственный способ сказать в контракте «здесь без токена», и его забывают, после чего сгенерированный клиент шлёт токен на health-проверку.
paths:
/orders:
post:
security:
- partnerOAuth: [orders:write]
/health:
get:
security: []
Ответы 401 и 403 описывают один раз в components/responses и ссылаются из операций, как остальные ошибки; у 401 в описании стоит заголовок WWW-Authenticate. Что означает каждый код и почему чужой идентификатор отвечает 404, разбирает статья про ошибки; контракт лишь фиксирует, что эти ответы есть у каждой закрытой операции. Проверка «у каждой операции без security: [] описаны 401 и 403» это правило линтера спецификации, о котором говорит статья про API-first.
Коротко
- Файл читают машины: документация, генератор сервера и клиента, мок и линтер; пустое поле заполняет генератор, и по-своему.
operationIdэто имя метода в чужом коде: уникально, camelCase, действие плюс ресурс; в springdoc задаётся через@Operation.- Один тег на ресурс, действия под тегом ресурса;
summaryфраза,descriptionтолько там, где есть что объяснить. - Два идентификатора в пути носят разные имена, этого требуют и стандарт, и Spring.
- Схемы живут в
componentsи подключаются через$ref;required,readOnlyи именованные перечисления решают, каким будет сгенерированный код. - Ответы об ошибках и заголовки описывают и выносят в
components/responses; примеры именуют, из них отвечает мок. - 3.1 это полноценная JSON Schema:
type: [string, 'null']вместоnullable,examplesвместоexample; инструменты проверяют до перехода. - Спека режется на файлы, склеивается в сборке, линт и
oasdiffстоят в CI до ревью. - Безопасность в контракте трёхуровневая: схемы с областями доступа в
components, корневойsecurityпо умолчанию, переопределение на операции; открытая операция это явноеsecurity: [], а401и403описаны у каждой закрытой.
Что почитать дальше
- API-first и contract-first — процесс: генератор под Spring Boot 3, где лежит сгенерированный код, моки.
- URL и ресурсы в REST — правила адресов, которые спека только записывает.
- Ошибки и Problem Details — что лежит в теле ответа, на который ссылается
components/responses. - Версионирование API — какие правки контракта ломают клиентов и что с этим делать.