Перейти к содержанию

Описание контракта 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 ресурса и снимок пагинации.

Примеры продолжения серии. Оглавление курса.