Учебник веб-разработки
Разделы учебника
На этой странице

VII. HTTP и API

OpenAPI, пагинация и совместимость

Оглавление · Лаборатория API

OpenAPI описывает HTTP operations, параметры, ответы и схемы. Из него можно генерировать документацию и клиенты, но описание должно соответствовать исполняемому серверу. JSON Schema и Zod имеют разные API; изменение одного источника требует проверки другого. Полная генерация pipeline в Atmanki не включена.

Контракт учебного ресурса

Ниже самостоятельный OpenAPI 3.1.1 контракт: list, read и условное обновление. Он расширяет HTTP-часть лаборатории пагинацией и bearer-auth. Лабораторный сервер пока использует заданную сервером роль; не выдавайте его за реализацию этого auth и list. Для сопоставления реализуйте недостающие границы в своей ветке.

openapi: 3.1.1
info:
  title: Learning Publications
  version: 1.0.0
paths:
  /publications:
    get:
      operationId: listPublications
      parameters:
        - { name: after, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - {
            name: limit,
            in: query,
            schema: { type: integer, minimum: 1, maximum: 100, default: 20 },
          }
      responses:
        "200":
          description: Ordered by ascending ID; nextAfter is null at the end
          content:
            application/json:
              schema:
                type: object
                required: [items, nextAfter]
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Publication" } }
                  nextAfter: { type: [integer, "null"], minimum: 1 }
        "400": { $ref: "#/components/responses/BadInput" }
  /publications/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer, minimum: 1 } }
    get:
      operationId: readPublication
      responses:
        "200":
          description: Current publication
          headers:
            ETag: { description: Strong version tag, schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Publication" }
        "404": { $ref: "#/components/responses/Missing" }
        "400": { $ref: "#/components/responses/BadInput" }
    put:
      operationId: updatePublication
      security: [{ bearerAuth: [] }]
      parameters:
        - {
            name: If-Match,
            in: header,
            required: true,
            schema: { type: string, pattern: '^"v[1-9][0-9]*"$' },
          }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              additionalProperties: false
              properties:
                title: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "200":
          description: Updated publication; version increases by one
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Publication" }
        "400": { $ref: "#/components/responses/BadInput" }
        "401": { description: Missing or invalid authentication }
        "403": { description: Authenticated principal lacks write permission }
        "404": { $ref: "#/components/responses/Missing" }
        "412": { description: Version differs from If-Match }
        "428": { description: If-Match is required }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
  schemas:
    Publication:
      type: object
      required: [id, title, version]
      properties:
        id: { type: integer, minimum: 1 }
        title: { type: string, minLength: 1, maxLength: 120 }
        version: { type: integer, minimum: 1 }
  responses:
    BadInput: { description: Input violates the contract }
    Missing: { description: Publication does not exist }

Структура не описывает всё поведение: title из пробелов тоже должен быть отвергнут после trim; ETag относится к выбранному представлению. Схема не объясняет права на конкретный объект. Опишите это в контрактных сценариях. Добавьте Content-Type/413/415/405 для реализуемого transport, если он их использует; подтверждайте конкретные ответы тестами, а не предположением по генератору.

Keyset вместо смещения

Для фиксированного фильтра и сортировки ID по возрастанию: WHERE id > :after ORDER BY id LIMIT :limitPlusOne. Лишняя строка показывает, есть ли следующая страница. Курсор — последний выданный ID, не лишняя строка.

import assert from "node:assert/strict";
function page(rows, after = 0, limit = 2) {
  if (
    !Number.isSafeInteger(after) ||
    after < 0 ||
    !Number.isSafeInteger(limit) ||
    limit < 1 ||
    limit > 100
  )
    throw new Error("BAD_REQUEST");
  const batch = rows
    .filter((row) => row.id > after)
    .sort((a, b) => a.id - b.id)
    .slice(0, limit + 1);
  const items = batch.slice(0, limit);
  return { items, nextAfter: batch.length > limit ? items.at(-1).id : null };
}
const rows = [1, 2, 4, 7, 9].map((id) => ({ id }));
const first = page(rows);
assert.deepEqual(first, { items: [{ id: 1 }, { id: 2 }], nextAfter: 2 });
const second = page(rows, first.nextAfter);
assert.deepEqual(second, { items: [{ id: 4 }, { id: 7 }], nextAfter: 7 });
assert.deepEqual(page(rows, second.nextAfter), { items: [{ id: 9 }], nextAfter: null });
assert.deepEqual(page(rows, 99), { items: [], nextAfter: null });
assert.throws(() => page(rows, 0, 0), /BAD_REQUEST/);
console.log("Pagination: 1,2 | 4,7 | 9");

Этот пример — проверка алгоритма, не реализация SQL или HTTP. При сортировке по времени нужен уникальный tie-breaker (publishedOn, id). Курсор должен кодировать обе части и соответствовать фильтру; base64 не делает его секретным или защищённым от подделки. Проверяйте структуру и контекст, при необходимости подписывайте. Между запросами записи могут появиться, исчезнуть или менять ключ сортировки: keyset не гарантирует snapshot всего набора. Для snapshot нужна отдельная политика версии/транзакции/экспорта.

Матрица совместимости

ИзменениеСтарый клиент с новым серверомНовый клиент со старым сервером
Необязательное поле ответаОбычно читает, если допускает лишние поляДолжен обработать отсутствие
Новое обязательное поле запросаСтарый запрос отвергаетсяСтарый сервер может не понимать поле
Новое значение enumМожет сломать exhaustive switchДолжен не отправлять неподдерживаемое значение
Смена единицы времениМожет пройти типы и дать неверный смыслТа же семантическая проблема

Приёмка: валидируйте документ инструментом OpenAPI выбранной версии, проверьте все $ref, сгенерируйте учебный клиент во временный каталог и сравните реальные запросы/ответы с описанием. Затем проверьте пустую/последнюю страницу, ошибочный курсор, равные даты, удаление строки между запросами и старый клиент. Отдельно фиксируйте, выполнены ли генерация, HTTP-вызовы и SQL-план. Успешный разбор YAML не равен полной проверке спецификации или совместимости.

Источник: OpenAPI 3.1.1.