Описание контракта OpenAPI
После множества уроков договор каталога распределён между текстом и сообщениями. Клиентскому разработчику полезно видеть адреса, поля и ответы в одном документе. OpenAPI позволяет описать HTTP API в машиночитаемой форме, однако сама запись не создаёт сервер, проверку прав или атомарное изменение.
В этом уроке подготовим ограниченную спецификацию двух операций: GET и PUT /api/courses/{id}. Результат — вы сможете связать схемы с выбранным представлением и альтернативными режимами credentials. Используем опубликованную версию OpenAPI 3.1.1, без утверждения, что она новейшая. Документ не компилировался, генераторы и команды не запускались.
Область документа должна быть честной
У нашего каталога уже есть список, создание, файлы, задания и события. Ниже они не описываются. Это полный документ для двух выбранных операций, а не полная спецификация всего сервиса. Его файл openapi-core.yaml лежит среди примеров данного урока.
GET публичен и может вернуть JSON либо HTML по Accept. PUT принимает три редактируемых поля, требует конкретный сильный If-Match и выполняется Alice только в допустимой области frontend. Вход нормализуется, а представление чтения добавляет id, поэтому успешный PUT не выдаёт ETag: новый валидатор получают отдельным GET.
Следующий листинг является целым ограниченным документом. Его нужно читать как единое описание, а не вставлять отдельный components в неизвестную прежнюю спецификацию.
openapi: 3.1.1
info:
title: Catalog core API
version: 1.0.0
description: GET and conditional PUT only; not the entire service.
servers:
- url: https://catalog.example
paths:
/api/courses/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: string
pattern: '^c[0-9]{3,}$'
get:
operationId: getCourse
description: Select JSON or HTML by Accept; no acceptable variant returns empty 406.
security: []
parameters:
- name: If-None-Match
in: header
required: false
schema: {type: string}
description: Validator list or wildcard, evaluated with weak comparison.
responses:
'200':
description: Selected public representation.
headers:
ETag:
schema: {type: string}
description: Strong validator of the selected representation.
Vary:
schema: {type: string}
Cache-Control:
schema: {type: string}
content:
application/json:
schema: {$ref: '#/components/schemas/Course'}
text/html:
schema: {type: string}
'304':
description: Conditional GET has no response body.
headers:
ETag:
schema: {type: string}
Vary:
schema: {type: string}
Cache-Control:
schema: {type: string}
'400': {$ref: '#/components/responses/Problem'}
'404': {$ref: '#/components/responses/Problem'}
'406':
description: No acceptable representation; empty body by the established contract.
put:
operationId: replaceCourseInput
description: >-
Existing resource only. Requires a concrete strong JSON ETag.
Cookie mode also requires the allowed Origin and session CSRF token.
Both credential modes together are rejected. Successful PUT has no ETag.
security:
- cookieAuth: []
csrfToken: []
- bearerAuth: []
parameters:
- name: If-Match
in: header
required: true
schema: {type: string}
description: Concrete strong JSON ETag; wildcard is unsupported here.
- name: Origin
in: header
required: false
schema: {type: string}
description: Required and checked against the allowlist in cookie mode.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CourseInput'}
responses:
'200':
description: Stored normalized representation; no ETag in this PUT response.
content:
application/json:
schema: {$ref: '#/components/schemas/Course'}
'400': {$ref: '#/components/responses/Problem'}
'401':
description: Missing or invalid credentials.
headers:
WWW-Authenticate:
schema: {type: string}
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
'403': {$ref: '#/components/responses/Problem'}
'404': {$ref: '#/components/responses/Problem'}
'412': {$ref: '#/components/responses/Problem'}
'415': {$ref: '#/components/responses/Problem'}
'422': {$ref: '#/components/responses/Problem'}
'428': {$ref: '#/components/responses/Problem'}
'429':
description: Write budget exhausted before the business operation.
headers:
Retry-After:
schema: {type: string}
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: __Host-catalog_sid
csrfToken:
type: apiKey
in: header
name: X-CSRF-Token
bearerAuth:
type: http
scheme: bearer
schemas:
CourseInput:
type: object
additionalProperties: false
required: [title, topic, lessons]
properties:
title:
type: string
description: After trimming, 1 to 120 Unicode code points; normalization is server-side.
topic:
type: string
enum: [frontend, backend, quality, tools]
lessons:
type: integer
minimum: 1
maximum: 1000
Course:
type: object
required: [id, title, topic, lessons]
properties:
id: {type: string}
title: {type: string}
topic: {type: string, enum: [frontend, backend, quality, tools]}
lessons: {type: integer, minimum: 1, maximum: 1000}
Problem:
type: object
required: [type, title, status]
properties:
type: {type: string, format: uri}
title: {type: string}
status: {type: integer, minimum: 100, maximum: 599}
detail: {type: string}
instance: {type: string, format: uri-reference}
responses:
Problem:
description: Operation failed; problem details explain the selected status.
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Paths задаёт операции, схема — данные
Путь содержит параметр id, обязательный для обеих операций. Pattern ограничивает его форму, но не доказывает существование курса: c999 всё ещё способен получить 404. operationId даёт стабильное имя операции для документации и возможных будущих инструментов.
У responses ключами служат HTTP-статусы. Для 200 GET перечислены два типа содержимого, а не одновременно два тела. Выбор осуществляется по Accept прежним договором. 304 не имеет content, поскольку тело не передаётся. Его поля валидатора и кеширования остаются значимыми даже без тела.
If-None-Match описан необязательным параметром GET. Его строковый тип сам по себе не реализует разбор списка тегов или слабое сравнение: эти правила заданы базовым контрактом и уроком 15. If-Match у PUT описывает другое условие, где требуется конкретный сильный JSON-валидатор. Одинаковое schema:type:string не означает одинаковую семантику двух полей. OpenAPI 3.1.1.
Accept не задаётся отдельным header Parameter: OpenAPI специально игнорирует такие определения для Accept, Content-Type и Authorization. Типы ответа описываются через responses.content, вход — через requestBody.content, а Authorization — через security scheme. Подробное правило выбора q и ветка 406 сохраняются в нашем базовом договоре; здесь description напоминает, что неприемлемый Accept даёт пустой 406, без обещания problem-тела, которое клиент тоже мог не принять.
Вход отличается от представления чтения
CourseInput требует title, topic и lessons и запрещает лишние поля. Course требует ещё id, который назначается сервером. Не стоит объединять эти две формы через закрытый вход с additionalProperties:false и затем случайно запрещать id в ответе.
Для входного title намеренно нет minLength и maxLength на исходную строку. Наше правило применяется после trim и считает кодовые точки нормализованного значения. Простое ограничение сырой длины отвергло бы допустимую строку с пробелами вокруг. Описание фиксирует правило, а реальный обработчик должен выполнить его явно.
JSON Schema integer описывает математически целое значение; особенности JSON-парсера и различение записи 40 от 40.0 не решаются этим словом автоматически. Отказ от повторных JSON-ключей, нормализация и проверка размера тела также требуют собственного слоя. Нельзя ожидать, что документ заменит способ безопасного разбора входа.
Альтернативы credentials выражаются структурой
В security два элемента списка означают альтернативы. Первый содержит cookieAuth и csrfToken вместе: для этого режима нужны обе схемы. Второй требует bearerAuth. Пустой security у GET явно допускает анонимное чтение.
Название type:apiKey у cookie и CSRF является способом описать расположение значения, а не утверждением, что сессионная cookie стала долговечным API key. Bearer не имеет bearerFormat:JWT, поскольку наш токен непрозрачный и формат JWT не обещан.
Условная обязательность Origin описана текстом: cookie-режим проверяет его, явный Bearer без сессионной cookie использует другую модель. OpenAPI-список альтернатив сам по себе не запрещает прислать оба режима; выбранный отказ 400 ambiguous-credentials также записан в объяснении и реализуется сервером. Роль editor и область frontend не возникают из названия схемы.
Статусы не заменяют причины
Общий Problem позволяет выразить поля RFC 9457 и будущие расширения. Однако одинаковая схема не означает одинаковую обработку всех отказов. При 412 нужно разбирать текущую версию, при 422 — исправлять вход, при 429 — учитывать бюджет и Retry-After, при 401 — credentials. Problem Details в RFC 9457.
Схема ответа Course оставлена открытой для совместимых дополнительных полей, если клиент следует договору игнорирования неизвестных. Это отличается от строгого входа: сервер не должен молча принять misspelled title или дополнительный admin. Политика чтения и политика отправки могут обоснованно различаться.
Документ пока не исчерпывает заголовки Cache-Control, CORS и все специфические problem types. Он не прошёл программную валидацию. При будущем выпуске потребуется сверка полной спецификации с реализацией и её наблюдаемым обменом; сейчас мы выполнили только авторское чтение.
Пример дополняет схему, но не переопределяет её
Схема CourseInput допускает все четыре направления каталога. Она не говорит, что Alice вправе отправить любое из них: её область frontend проверяется отдельно. Если поместить в общий enum только frontend ради конкретного пользователя, документ перестанет описывать API других редакторов и смешает данные с персональным разрешением.
Пример с title, topic и lessons полезен для чтения, но один пример не ограничивает все допустимые значения. Аналогично конкретный ETag r1 демонстрирует форму, а не объявляет вечный единственный валидатор. Клиент не должен зашивать эти значения в будущую программу только потому, что они стоят рядом с машиночитаемой схемой.
Для ошибок общий response component сокращает повторение YAML. Перед полным выпуском его можно дополнить конкретными problem types и заголовками no-store, сохранив правила RFC. Сам $ref — ссылка на часть документа, а не сетевой запрос к серверу. Локальная ссылка должна разрешаться внутри этого же файла; здесь генератор для её проверки не запускался.
Полезно отдельно записывать требования, которые схемы не выражают: атомарное сравнение версии, запрет смешанных credentials, текущие права, отказ до бизнес-эффекта при исчерпании бюджета. Даже если инструмент принимает документ, это не доказывает выполнение этих требований. Для будущего клиента спецификация задаёт формы обмена, для сервера — часть проверяемого договора, а пояснения сохраняют причины выбранных границ.
Как спецификация помогает изменению
У нового требования стоит спросить, какой путь, вход, ответ или security оно затрагивает. Если изменение не помещается в документ без противоречия прежним словам, возможно, оно меняет сам договор. Например, обязательный новый input field нельзя объявить совместимым только потому, что YAML по-прежнему синтаксически похож.
Сохранённый файл позволяет обсуждать эти решения предметно, без запуска генератора клиента. Следующий урок отделит расширение представления от несовместимого изменения смысла, а также различит версию API, revision ресурса и снимок пагинации.