OpenAPI, пагинация и совместимость
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.