← назад к разделу

Фронтенд получил сгенерированный клиент, и в нём метод postApiV1OrdersOrderIdConfirm. В Swagger UI сто эндпоинтов идут плоским списком, и партнёр ищет нужный по минуте. Схема заказа скопирована в двенадцать ответов, в трёх из них отстала на поле, и клиенты этих трёх эндпоинтов поля не видят. У всех трёх бед одна причина: файл OpenAPI писали как документацию для людей, а читают его машины, и каждую пустую строку они заполняют по-своему.

Как контракт появляется раньше кода и как из него собирается проект, разобрано в статье про API-first. Здесь про сам формат: что должно стоять в файле, чтобы документация, SDK и мок получались правильными без ручной доводки.

одна операция из спеки шаг 1: метаданные не заполнены шаг 2: добавили три строки /api/v1/orders/{orderId}/confirm: post: operationId: нетtags: нетsummary: нетгенератор придумает имена сам operationId: confirmOrdertags: [Orders]summary: Подтвердить заказимена задал автор контракта Swagger UI openapi-generator код клиента теряется в списке из 100postOrdersOrderIdConfirm()api.postOrdersOrderIdConfirm(id) раздел Orders — эндпоинты рядомconfirmOrder()orderService.confirmOrder(orderId) три строки в спеке — и документация, SDK и код клиента меняются сами

Спецификация — не документация, а входные данные для машин: из одного файла растут Swagger UI, клиентский SDK и коллекции запросов. Поэтому пустые operationId, tags и summary — не мелочь оформления: генератор подставит вместо них свои имена, и они разойдутся по всему коду клиентов.

Обязательно

Один файл, четыре потребителя

OpenAPI это описание REST API в YAML или JSON, у которого есть стандарт и есть читатели. Swagger UI и Redoc рисуют из него документацию с кнопкой «выполнить». Генератор кода, обычно openapi-generator, делает из него интерфейсы контроллеров и классы DTO для сервера и готовый клиент для фронтенда или соседнего сервиса. Мок-сервер, например Prism, поднимает по нему фальшивый бэкенд, который отвечает примерами из файла, и потребители начинают интеграцию до того, как сервис написан. Линтер и проверка совместимости читают его в CI.

openapi.yaml Swagger UI, Redoc документация для людей генератор: сервер интерфейсы контроллеров и DTO генератор: клиент SDK для фронтенда и соседних сервисов Prism мок-сервер из examples

Один файл читают четыре потребителя, и ни один из них не человек. Поэтому пустое поле в спеке это не пробел в документации, а имя, которое за вас придумает генератор, или ответ, которого мок не сможет отдать.

Скелет файла умещается в десять строк, и дальше статья идёт по его разделам:

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]
components/schemas/Order GET /orders → 200 GET /orders/{orderId} → 200 POST /orders → 201 PATCH /orders/{orderId} → 200 одна правка, все ответы

Схема заказа описана один раз, а четыре ответа ссылаются на неё через $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.

правка openapi.yaml pull request Spectral: линт нет operationId — красный oasdiff: сравнение с main удалили поле — красный ревью потребителем контракт принят генерация и сборка

Спека живёт как код: меняется через 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 описаны у каждой закрытой.

Что почитать дальше