revise September 2021 API versioning articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 13:06:48 +03:00
parent 35769c4149
commit 8e078b92ea
7 changed files with 960 additions and 1 deletions
+645
View File
@@ -0,0 +1,645 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
}
// В revision остаются только изменяемые редакционные поля. Дата и автор
// приходят из исходной архивной записи при наложении overlay.
return { ...meta, contentHtml };
}
const openApi303 = {
title: 'OpenAPI Specification 3.0.3 — 20 февраля 2020 года',
url: 'https://spec.openapis.org/oas/v3.0.3.html',
note: 'официальная фиксированная редакция: отличает версию самой спецификации от версии API, описывает request body, parameter и response; это описание контракта, а не готовая проверка конкретного клиента',
};
const openApi310 = {
title: 'OpenAPI Specification 3.1.0 — 15 февраля 2021 года',
url: 'https://spec.openapis.org/oas/v3.1.0.html',
note: 'официальная редакция, доступная к сентябрю 2021 года: Schema Object задаёт форму input/output, а прикладная семантика не выводится автоматически из формы',
};
const sunsetRfc8594 = {
title: 'RFC 8594: The Sunset HTTP Header Field — май 2019 года',
url: 'https://www.rfc-editor.org/rfc/rfc8594.html',
note: 'первичный документ IETF: Sunset сигнализирует вероятную недоступность ресурса в будущем и является подсказкой, а не гарантией или заменой договорённости с потребителем',
};
export const apiVersioningTrainingContract = Object.freeze({
endpoint: 'POST /api/orders/{orderId}/confirm',
responseV1: Object.freeze({
required: Object.freeze(['id', 'status', 'totalMinor']),
knownStatus: Object.freeze(['confirmed']),
unknownFieldPolicy: 'v1 client ignores an unknown response field after required fields pass',
}),
responseV2: Object.freeze({
optional: Object.freeze(['deliveryWindow']),
absence: 'field was not offered by this representation',
null: 'field was evaluated and there is explicitly no delivery window',
object: 'field was evaluated and contains a start/end window',
}),
request: Object.freeze({
required: Object.freeze(['confirmationCode']),
optional: Object.freeze(['deliveryPreference']),
absence: 'keep the previous preference unchanged',
null: 'clear the previous preference',
unknownFieldPolicy: 'current service rejects an unknown request field to expose a typo',
}),
});
const baseResponse = Object.freeze({
id: 'order-417',
status: 'confirmed',
totalMinor: 1500,
});
const additiveResponse = Object.freeze({
...baseResponse,
deliveryWindow: Object.freeze({
from: '2021-09-14T10:00:00Z',
to: '2021-09-14T14:00:00Z',
}),
});
const explicitNoWindowResponse = Object.freeze({
...baseResponse,
deliveryWindow: null,
});
const removedRequiredFieldResponse = Object.freeze({
id: 'order-417',
status: 'confirmed',
amountMinor: 1500,
});
const semanticChangedResponse = Object.freeze({
id: 'order-417',
status: 'accepted',
totalMinor: 1500,
});
const v1Request = Object.freeze({ confirmationCode: 'A7K9' });
const v2Request = Object.freeze({ confirmationCode: 'A7K9', deliveryPreference: 'weekday' });
const clearPreferenceRequest = Object.freeze({ confirmationCode: 'A7K9', deliveryPreference: null });
const misspelledRequest = Object.freeze({ confirmationCode: 'A7K9', deliveryPrefrence: 'weekday' });
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function collectUnknownFields(value, knownFields) {
return Object.keys(value).filter((key) => !knownFields.includes(key));
}
function requireString(value, label) {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(label + ' must be a non-empty string');
}
}
function readV1Response(response) {
if (!response || typeof response !== 'object') {
throw new Error('v1 response must be an object');
}
for (const field of apiVersioningTrainingContract.responseV1.required) {
if (!hasOwn(response, field)) {
throw new Error('v1 response misses required field: ' + field);
}
}
requireString(response.id, 'v1 response id');
if (!apiVersioningTrainingContract.responseV1.knownStatus.includes(response.status)) {
throw new Error('v1 response has unsupported status semantics: ' + response.status);
}
if (!Number.isInteger(response.totalMinor) || response.totalMinor < 0) {
throw new Error('v1 response totalMinor must be a non-negative integer');
}
const knownFields = apiVersioningTrainingContract.responseV1.required;
const ignoredFields = collectUnknownFields(response, knownFields);
return {
client: 'v1',
accepted: true,
view: { id: response.id, status: response.status, totalMinor: response.totalMinor },
ignoredFields,
};
}
function readV2Response(response) {
const v1View = readV1Response(response);
const v2View = {
...v1View,
client: 'v2',
ignoredFields: v1View.ignoredFields.filter((field) => field !== 'deliveryWindow'),
};
if (!hasOwn(response, 'deliveryWindow')) {
return { ...v2View, deliveryWindowState: 'absent' };
}
if (response.deliveryWindow === null) {
return { ...v2View, deliveryWindowState: 'explicit-none' };
}
const window = response.deliveryWindow;
if (!window || typeof window !== 'object') {
throw new Error('v2 deliveryWindow must be an object, null, or absent');
}
requireString(window.from, 'v2 deliveryWindow.from');
requireString(window.to, 'v2 deliveryWindow.to');
return {
...v2View,
deliveryWindowState: 'scheduled',
deliveryWindow: { from: window.from, to: window.to },
};
}
function acceptRequestForCurrentService(request) {
if (!request || typeof request !== 'object') {
return { accepted: false, reason: 'request-is-not-an-object' };
}
const knownFields = [
...apiVersioningTrainingContract.request.required,
...apiVersioningTrainingContract.request.optional,
];
const unknownFields = collectUnknownFields(request, knownFields);
if (unknownFields.length > 0) {
return {
accepted: false,
reason: 'unknown-request-field',
unknownFields,
};
}
if (typeof request.confirmationCode !== 'string' || request.confirmationCode.length < 4) {
return { accepted: false, reason: 'invalid-confirmation-code' };
}
if (!hasOwn(request, 'deliveryPreference')) {
return { accepted: true, preferenceIntent: 'unchanged' };
}
if (request.deliveryPreference === null) {
return { accepted: true, preferenceIntent: 'clear' };
}
if (!['weekday', 'weekend'].includes(request.deliveryPreference)) {
return { accepted: false, reason: 'invalid-delivery-preference' };
}
return {
accepted: true,
preferenceIntent: 'set',
deliveryPreference: request.deliveryPreference,
};
}
function capture(check) {
try {
return { ok: true, value: check() };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
/**
* Детерминированная учебная fixture для одного process Node.js.
* Здесь есть только Array, Map и объекты: это не HTTP-сервер, не база,
* не OpenAPI-validator и не описание реального rollout.
*/
export function runApiVersioningFixture() {
const ledger = new Map();
const timeline = [];
function record(stage, evidence) {
const item = Object.freeze({ step: timeline.length + 1, stage, evidence });
timeline.push(item);
ledger.set(stage, item);
return item;
}
const v1ReadsAdditive = readV1Response(additiveResponse);
const v2ReadsAdditive = readV2Response(additiveResponse);
const v2ReadsAbsent = readV2Response(baseResponse);
const v2ReadsNull = readV2Response(explicitNoWindowResponse);
const v1RequestResult = acceptRequestForCurrentService(v1Request);
const v2RequestResult = acceptRequestForCurrentService(v2Request);
const clearRequestResult = acceptRequestForCurrentService(clearPreferenceRequest);
const misspelledRequestResult = acceptRequestForCurrentService(misspelledRequest);
const removedRequiredCheck = capture(() => readV1Response(removedRequiredFieldResponse));
const semanticChangeCheck = capture(() => readV1Response(semanticChangedResponse));
record('contract-recorded', {
endpoint: apiVersioningTrainingContract.endpoint,
requiredResponse: apiVersioningTrainingContract.responseV1.required,
v1Request: v1RequestResult,
});
record('additive-response-preflight-passed', {
v1: v1ReadsAdditive,
v2: v2ReadsAdditive,
});
record('v2-request-preflight-passed', {
v1Request: v1RequestResult,
v2Request: v2RequestResult,
clearRequest: clearRequestResult,
});
const candidateRetirement = {
change: 'remove totalMinor and publish amountMinor only',
compatible: removedRequiredCheck.ok,
evidence: removedRequiredCheck,
};
record('retirement-candidate-rejected', candidateRetirement);
const rollbackSafeAction = {
state: 'retirement-paused-before-change',
retainedResponseFields: [...apiVersioningTrainingContract.responseV1.required],
v2OnlyRequestMode: 'not-enabled-for-retirement',
dataMutation: false,
reason: 'v1 compatibility preflight rejected the candidate response',
};
record('rollback-safe-pause', rollbackSafeAction);
const assertions = {
oneDeclaredEndpoint: apiVersioningTrainingContract.endpoint === 'POST /api/orders/{orderId}/confirm',
v1SurvivesAdditiveResponse: v1ReadsAdditive.accepted && v1ReadsAdditive.view.totalMinor === 1500,
v1ExplicitlyIgnoresUnknownResponseField: v1ReadsAdditive.ignoredFields.includes('deliveryWindow'),
v2ReadsScheduledOptionalField: v2ReadsAdditive.deliveryWindowState === 'scheduled'
&& v2ReadsAdditive.deliveryWindow.from === '2021-09-14T10:00:00Z',
responseAbsenceAndNullStayDistinct: v2ReadsAbsent.deliveryWindowState === 'absent'
&& v2ReadsNull.deliveryWindowState === 'explicit-none',
currentServiceAcceptsV1Request: v1RequestResult.accepted && v1RequestResult.preferenceIntent === 'unchanged',
currentServiceAcceptsV2Request: v2RequestResult.accepted
&& v2RequestResult.preferenceIntent === 'set'
&& v2RequestResult.deliveryPreference === 'weekday',
requestNullMeansClear: clearRequestResult.accepted && clearRequestResult.preferenceIntent === 'clear',
unknownRequestFieldIsRejected: !misspelledRequestResult.accepted
&& misspelledRequestResult.reason === 'unknown-request-field'
&& misspelledRequestResult.unknownFields[0] === 'deliveryPrefrence',
removedRequiredResponseFieldIsRejected: !removedRequiredCheck.ok
&& removedRequiredCheck.message.includes('totalMinor'),
semanticChangeIsRejected: !semanticChangeCheck.ok
&& semanticChangeCheck.message.includes('unsupported status semantics'),
stagedRetirementStopsAndKeepsLegacyContract: !candidateRetirement.compatible
&& rollbackSafeAction.state === 'retirement-paused-before-change'
&& rollbackSafeAction.retainedResponseFields.includes('totalMinor')
&& rollbackSafeAction.dataMutation === false
&& ledger.size === 5,
};
if (!Object.values(assertions).every(Boolean)) {
throw new Error('api versioning training fixture violated a documented invariant');
}
return {
model: 'deterministic in-memory API contract exercise; not an HTTP, DB, API, or deployment test',
contract: apiVersioningTrainingContract,
samples: {
baseResponse,
additiveResponse,
explicitNoWindowResponse,
removedRequiredFieldResponse,
semanticChangedResponse,
v1Request,
v2Request,
},
timeline,
assertions,
};
}
const endpointContractCode = [
'// Учебный контракт одного endpoint. Не сетевой вызов.',
"const endpoint = 'POST /api/orders/{orderId}/confirm';",
'',
'const v1Response = {',
" id: 'order-417',",
" status: 'confirmed',",
' totalMinor: 1500,',
'};',
'',
'// В v2 поле добавлено и optional. Старый parser его не читает.',
'const v2Response = {',
' ...v1Response,',
" deliveryWindow: { from: '2021-09-14T10:00:00Z', to: '2021-09-14T14:00:00Z' },",
'};',
'',
"// absent: поле не предложено; null: окно проверено, но его нет.",
].join('\n');
const responseReaderCode = [
'function readV1(response) {',
" for (const field of ['id', 'status', 'totalMinor']) {",
" if (!Object.hasOwn(response, field)) throw new Error('missing ' + field);",
' }',
" if (response.status !== 'confirmed') throw new Error('status semantics changed');",
" if (!Number.isInteger(response.totalMinor)) throw new Error('invalid totalMinor');",
'',
' return {',
' id: response.id,',
' status: response.status,',
' totalMinor: response.totalMinor,',
' // Неизвестные response-поля сознательно не попадают в view.',
' };',
'}',
].join('\n');
const requestReaderCode = [
'function acceptRequest(request) {',
" const known = new Set(['confirmationCode', 'deliveryPreference']);",
' const unknown = Object.keys(request).filter((key) => !known.has(key));',
" if (unknown.length) return { ok: false, reason: 'unknown-request-field', unknown };",
" if (!request.confirmationCode) return { ok: false, reason: 'confirmationCode-required' };",
" if (!Object.hasOwn(request, 'deliveryPreference')) return { ok: true, preference: 'unchanged' };",
" if (request.deliveryPreference === null) return { ok: true, preference: 'clear' };",
" return { ok: true, preference: 'set' };",
'}',
'',
"acceptRequest({ confirmationCode: 'A7K9' }); // unchanged",
"acceptRequest({ confirmationCode: 'A7K9', deliveryPreference: null }); // clear",
"acceptRequest({ confirmationCode: 'A7K9', deliveryPrefrence: 'weekday' }); // reject typo",
].join('\n');
const fixtureCode = [
"import { runApiVersioningFixture } from './upgrade-2021-09.mjs';",
'',
'const fixture = runApiVersioningFixture();',
'for (const [name, passed] of Object.entries(fixture.assertions)) {',
" if (!passed) throw new Error('failed: ' + name);",
'}',
'',
"console.log(fixture.timeline.map(({ stage }) => stage));",
'// retirement-candidate-rejected → rollback-safe-pause',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2021-09-practice-api-versioning',
title: 'API. Как менять контракт без внезапного разрыва',
excerpt: 'Практический маршрут: выделить допустимое изменение, проверить старого и нового потребителя, остановить опасный retirement до выпуска.',
readingMinutes: 12,
},
[
paragraph('Проблема изменения API часто выглядит безобидно: переименовать <code>totalMinor</code> в <code>amountMinor</code> или вернуть более «понятный» статус. Но старый клиент может читать поле как обязательное и перестать строить экран после одного ответа. Цена здесь не в названии версии: пользователь видит пустую сумму, а команда получает повод срочно менять сервер без доказательства, какой договор был нарушен.'),
paragraph('Добавить <code>deliveryWindow</code> в ответ обычно безопаснее, но только если конкретный v1-клиент действительно игнорирует незнакомые поля. У другого клиента строгий декодер может отклонить тот же JSON. Поэтому первым артефактом должен быть не маршрут <code>/v2</code>, а маленький контракт: кто читает request, кто читает response, что означает отсутствие поля и какой тест остановит removal до изменения.'),
heading('Сначала отделяем четыре версии'),
paragraph('В разговоре «версия API» смешивают как минимум четыре вещи: версию документа OpenAPI, номер маршрута, форму JSON и поведение клиента. OpenAPI 3.0.3 прямо различает версию спецификации в поле <code>openapi</code> и версию самого API в <code>info.version</code>. Ни одно из этих полей не доказывает, что потребитель переживёт переименование или иной смысл значения.'),
dataTable(
'Что именно меняется и какую проверку это требует',
['Слой', 'Пример изменения', 'Риск', 'Проверяем до продолжения'],
[
['Маршрут', '<code>/api/orders</code> остаётся прежним', 'URL не говорит, какие ключи читает клиент', 'Зафиксировать request и response для двух клиентов'],
['Форма response', 'Добавить optional <code>deliveryWindow</code>', 'старый parser может быть строгим', 'v1 читает обязательные поля и явно игнорирует добавленное'],
['Форма request', 'Добавить <code>deliveryPreference</code>', 'старый сервер может принять опечатку или отклонить новое поле', 'новый сервер принимает v1 request, а unknown key отклоняет'],
['Семантика', '<code>confirmed</code> заменить на <code>accepted</code>', 'ключ сохранён, но ветка клиента меняет смысл', 'проверить допустимые значения, а не только список ключей'],
],
),
paragraph('Я не считаю сегмент URL ни единственным, ни автоматическим средством совместимости. Он может быть удобен для разведения крупных договоров, но не делает совместимым ни тело запроса, ни body ответа, ни ожидание статуса. Схема тоже описывает только часть формы. Если клиент использует <code>confirmed</code> как разрешение показать следующий шаг, замена на <code>accepted</code> является разрывом даже при одинаковом JSON type.'),
heading('Один endpoint и два потребителя'),
paragraph('Для учебной модели беру один endpoint: <code>POST /api/orders/{orderId}/confirm</code>. v1 отправляет только <code>confirmationCode</code>. Текущий сервис принимает этот request без optional-поля. v2 может передать <code>deliveryPreference</code>; отсутствие означает «ничего не менять», <code>null</code> — «явно очистить», строка <code>weekday</code> или <code>weekend</code> — «задать». Эти значения не являются правилом HTTP: это письменный договор данного endpoint.'),
codeBlock(endpointContractCode),
paragraph('В response v1 требуются <code>id</code>, <code>status</code> и <code>totalMinor</code>. v2 понимает optional <code>deliveryWindow</code>. Здесь зафиксирована важная тройка: field отсутствует — эта representation его не предлагает; field равен <code>null</code> — вычисление произошло и окна нет; object — окно известно. Если продукту не нужна эта разница, лучше не вводить <code>null</code> «на всякий случай»: неясность позже станет семантическим разрывом.'),
dataTable(
'Договор учебного response',
['Поле', 'v1', 'v2', 'Правило изменения'],
[
['<code>id</code>', 'обязательное', 'обязательное', 'не удалять без переходного договора'],
['<code>status</code>', '<code>confirmed</code> имеет известный смысл', 'тот же смысл', 'новое значение проверяется отдельным compatibility test'],
['<code>totalMinor</code>', 'обязательное число', 'обязательное число', 'переименование в <code>amountMinor</code> — breaking change'],
['<code>deliveryWindow</code>', 'неизвестное поле игнорируется в этой модели', 'optional: absent / null / object различаются', 'добавлять только после проверки v1 parser'],
],
),
heading('Rollout — это последовательность ворот, а не календарная дата'),
paragraph('Сначала записываю базовый contract и примеры для v1/v2. Затем добавляю optional response-поле, но ещё не полагаюсь на него в request. Только после зелёных проверок разрешаю v2 optional request. Retirement обязательного <code>totalMinor</code> ставлю последним: кандидатный response проходит тот же v1 test. Если test отклонил response, retirement не начинается, legacy field остаётся, а v2-only режим не включается ради «проверки вживую». Это и есть rollback-safe шаг: остановка происходит до мутации учебного состояния.'),
figure(
'/assets/editorial/2021/api-versioning-rollout-2021.svg',
'Вертикальный маршрут эволюции API: базовый контракт, additive response, v2 request, отклонённый retirement и безопасная пауза с сохранением totalMinor.',
'Рисунок 1. Сначала доказываем additive-изменение, затем проверяем v2 request; удаление обязательного поля не проходит v1 gate и останавливается до изменения.',
),
paragraph('RFC 8594 уже к 2021 году описывал <code>Sunset</code> как сигнал будущей вероятной недоступности ресурса. Это может быть полезной частью retirement-коммуникации, но не заменяет список потребителей, срок поддержки и проверку. Сам документ отделяет «больше не предпочтительный вариант» от фактического вывода из эксплуатации; timestamp остаётся подсказкой, а не обещанием доступности до секунды.'),
heading('Минимальная проверка до большого решения'),
paragraph('Ниже не сервер и не генератор SDK. Это детерминированная in-memory fixture из этого пакета. Она проверяет, что v1 переживает additive response, v2 различает absent/null/object, текущий обработчик принимает v1 и v2 request, опечатка request-поля отклоняется, а removal/semantic change не проходят. Сценарий занимает один Node-процесс и оставляет evidence в <code>timeline</code>.'),
codeBlock(fixtureCode),
paragraph('Смысл fixture не в количестве assertions. Она заставляет назвать направление совместимости. «Новый сервер читает старый request» и «старый клиент читает новый response» — два разных условия. Третье условие — понимание нового поля новым клиентом. Четвёртое — запретить change, который оставляет shape похожей, но меняет допустимое значение <code>status</code>. Пока эти условия не разделены, общий «schema passed» может скрыть нужный разрыв.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> После candidate response v1 не показывает сумму или не завершает сценарий. Не добавлять новую URL-версию как рефлекторную реакцию.',
'<strong>Причина.</strong> Сравнить не только JSON keys: обязательное <code>totalMinor</code> могло исчезнуть, а значение <code>status</code> — изменить смысл при том же type.',
'<strong>Проверка.</strong> Прогнать v1 reader на base, additive и candidate response. Отдельно прогнать request validator на v1 request, v2 request, отсутствие поля, <code>null</code> и опечатку.',
'<strong>Действие.</strong> Если removal не проходит v1 gate, сохранить legacy response, зафиксировать отказ в evidence и поставить retirement на паузу. Сначала согласовать миграцию, затем повторить те же tests.',
]),
heading('Как объявить поддержку без ложной точности'),
paragraph('В документе контракта нужны owner, перечисление поддерживаемых representation, условие перехода и способ проверить каждый пункт. Формулировка «поддерживаем v1 до даты» неполна, пока не названы endpoint, request и response, которые относятся к v1, и проверяемый шаг после даты. Лучше написать: «обязательный <code>totalMinor</code> остаётся в response, пока v1 compatibility test является входным условием; кандидат removal отклонён fixture». Так договор можно проверить в review без доступа к чужому клиенту.'),
paragraph('Не стоит объявлять, что optional field всегда безопасен, что все consumers терпимы к unknown keys или что OpenAPI сам подскажет режим migration. Эти свойства принадлежат конкретным parsers, правилам валидации и ожиданиям пользователя. В этой модели unknown response field v1 игнорирует, а unknown request field сервис отклоняет; другая система вправе выбрать иной договор, но тогда ей нужен другой test.'),
heading('Ограничения'),
paragraph('Fixture не запускает HTTP, базу, SDK, gateway или настоящий deployment. В ней нет реальных клиентов, SLA, метрик и инцидента. Она не проверяет code generation, media type, авторизацию, retries и конкуренцию изменения заказа. Если endpoint изменяет состояние, проект отдельно решает вопрос повторного запроса и идентификатора операции. Этот материал даёт форму разговора и минимальный guard, а не универсальную политику версионирования.'),
],
[openApi303, openApi310, sunsetRfc8594],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-09-mechanism-api-versioning',
title: 'Под капотом: совместимость API идёт в две стороны',
excerpt: 'Разбираем request и response отдельно: когда optional поле добавляется безопасно, почему schema diff не ловит смену смысла и как это фиксирует contract test.',
readingMinutes: 12,
},
[
paragraph('Команда может увидеть одинаковую схему JSON и решить, что API совместим. Затем v1-клиент получает <code>status: "accepted"</code> вместо привычного <code>confirmed</code>, выбирает другую ветку или останавливается. Цена — не обязательно ошибка парсинга: тихая смена смысла способна показать пользователю неверное действие и оставить в логах «успешный» HTTP-ответ.'),
paragraph('Обратная сторона не менее опасна. Новый клиент отправляет optional <code>deliveryPreference</code>, а старый сервер либо молча выбрасывает поле, либо принимает опечатку <code>deliveryPrefrence</code> как неизвестные данные. Цена молчания — отсутствие объяснения, почему предпочтение не применилось. Поэтому request и response нельзя проверять одной фразой «всё валидно по схеме». У них разные читатели и разные направления эволюции.'),
heading('Граница механизма: кто кого читает'),
paragraph('Пусть есть <code>POST /api/orders/{orderId}/confirm</code>. Для request совместимость смотрит вперёд: текущий сервис обязан принимать старый v1 request без нового optional field. Для response она смотрит назад: существующий v1-клиент обязан читать current representation, пока обещана его поддержка. v2 добавляет знание о <code>deliveryWindow</code>, но не получает права переименовать v1 <code>totalMinor</code> или подменить смысл <code>status</code>.'),
dataTable(
'Направления contract test',
['Поток', 'Изменение', 'Допустимый результат в модели', 'Что test обязан отклонить'],
[
['v1 client ← current response', 'добавлен optional <code>deliveryWindow</code>', 'v1 собирает прежний view и игнорирует неизвестный response key', 'отсутствие <code>totalMinor</code>'],
['v2 client ← current response', '<code>deliveryWindow</code> absent / null / object', 'три состояния читаются различно и явно', 'строка или неполный object вместо оговорённой формы'],
['v1 client → current service', 'не прислан <code>deliveryPreference</code>', 'request принят, preference не меняется', 'требование нового optional field задним числом'],
['v2 client → current service', 'прислано <code>deliveryPreference</code>', 'значение проходит ограниченный словарь', 'неизвестный request key и опечатка'],
],
),
paragraph('OpenAPI описывает interface HTTP API и его input/output. Но сам по себе OpenAPI document не выполняет за команду контрактный тест. В редакции 3.1.0 Schema Object может описать типы, а прикладная семантика остаётся у приложения. Значит, наличие ключа <code>status</code> и его type <code>string</code> не доказывают, что <code>confirmed</code> и <code>accepted</code> взаимозаменяемы. Это отдельное правило reader-а.'),
heading('Форма ответа: additive не равно автоматически compatible'),
paragraph('В fixture v1 reader требует три поля и считает <code>confirmed</code> единственным ожидаемым значением. Остальные response keys он не переносит в свой view. Поэтому добавленный <code>deliveryWindow</code> не ломает именно этого v1. В то же время тот же reader отклоняет candidate с <code>amountMinor</code> вместо <code>totalMinor</code>. Он также отклоняет сохранённый ключ <code>status</code>, если значение получило новый смысл. Так один test показывает два вида break: structural и semantic.'),
codeBlock(responseReaderCode),
paragraph('Здесь unknown response field игнорируется намеренно и локально. Это не совет «всегда игнорируйте всё». Если клиент хранит весь объект, применяет строгую схему или делает exhaustive match, добавление ключа может стать incompatibility. Сначала надо подтвердить поведение конкретного reader-а. Даже когда v1 может проигнорировать новое поле, v2 должен проверить его форму: object содержит <code>from</code> и <code>to</code>, <code>null</code> не подменяется отсутствием, а absence не превращается в пустое окно.'),
figure(
'/assets/editorial/2021/api-versioning-compatibility-2021.svg',
'Матрица совместимости v1 и v2 клиентов: additive deliveryWindow проходит, удаление totalMinor и semantic change status отклоняются, request проверяется отдельным направлением.',
'Рисунок 1. Contract test смотрит не на одну «версию», а на четыре пересечения reader-а и writer-а. Зеленая ячейка — доказанный учебный case, красная — stop signal.',
),
heading('Форма запроса: unknown key лучше сделать наблюдаемым'),
paragraph('С request выбираю другой policy. Current service знает <code>confirmationCode</code> и optional <code>deliveryPreference</code>. Unknown key отвергается, потому что <code>deliveryPrefrence</code> похож на полезное поле, но не является им. Если сервис проглотит опечатку, клиент получит формально успешный result без обещанного поведения. Это не универсальная строгость: публичный API может иметь иной договор. Важно записать policy и покрыть её test-ом.'),
codeBlock(requestReaderCode),
paragraph('Здесь absence и <code>null</code> имеют разные side effect намерения, хотя fixture не меняет реальный заказ. Absence означает «сохранить прежнее предпочтение», <code>null</code> — «очистить». В response разница другая: absence означает, что representation не предлагает поле, <code>null</code> — сервис явно сообщает отсутствие окна. Одинаковый JSON token <code>null</code> не имеет магического смысла; его нельзя вводить без текста рядом с контрактом и проверкой reader-а.'),
heading('Contract test как узкий факт, а не имитация всей системы'),
paragraph('Вместо теста «все поля на месте» fixture строит набор фиксированных объектов и прогоняет функции reader/validator. Нет сети, базы и code generator. Это ограничение полезно: мы видим только контрактную ветку и не приписываем результату свойства transport. В <code>timeline</code> остаются этапы базового контракта, additive response, v2 request, отклонённого retirement и rollback-safe паузы. Любая assertion падает до решения о change.'),
codeBlock(fixtureCode),
dataTable(
'Что именно доказывает fixture',
['Assertion', 'Положительный случай', 'Отрицательный случай', 'Вывод'],
[
['v1 response', '<code>deliveryWindow</code> добавлен', 'нет <code>totalMinor</code>', 'не путать additive и removal'],
['v2 response', 'object / absent / null различаются', 'неверная форма окна', 'новая семантика требует собственного reader-а'],
['request', 'v1 без optional и v2 с valid optional приняты', 'опечатка unknown key', 'request policy должна быть явной'],
['retirement', 'candidate проверен до change', 'v1 reader его отверг', 'остановить migration без изменения учебных данных'],
],
),
heading('Почему /v2 и schema diff не закрывают задачу'),
paragraph('Новый маршрут может разделить документы или deployments, но сам не переводит клиента и не объясняет, что происходит с прежним endpoint. Можно сломать v1 внутри старого URL, а можно сохранить v1 semantics на новом URL. Аналогично, schema diff замечает required key, но не знает, что строка стала означать другое, что unknown field необходимо отклонить именно в request или что <code>null</code> теперь имеет отдельный бизнес-смысл. Поэтому этот материал использует API versioning как управление договором, а не как выбор одной нотации URL.'),
paragraph('Документирование нужно, но недостаточно: v1/v2 примеры и negative examples входят в test suite. В OpenAPI 3.0.3 request body имеет собственную отметку <code>required</code>, которая по умолчанию false. Это свойство описания request body, а не автоматическое разрешение заставить старого клиента посылать новое поле. Решение о required/new optional принимается на границе конкретного endpoint и подтверждается старым request sample.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Schema diff «чистый», но reader v1 ломается либо выполняет другой переход после response.',
'<strong>Причина.</strong> Определить направление: исчезло обязательное response-поле, появилось неизвестное request-поле или изменилась semantics знакомого значения.',
'<strong>Проверка.</strong> Составить fixtures: base, additive, absent, null, removed field, semantic change, v1 request, v2 request и request с опечаткой. Не заменять их общим валидным JSON.',
'<strong>Действие.</strong> Добавить только change, который прошёл нужные reader/validator tests. При break оставить legacy representation, отклонить candidate и записать условие повторной попытки migration.',
]),
heading('Ограничения'),
paragraph('Проверка не доказывает, как реальный framework сериализует <code>undefined</code>, что делает прокси с body или как SDK обрабатывает unknown fields. Она не охватывает authorization, rate limit, cache, idempotency и фактическое распространение клиента. OpenAPI 3.0.3 и 3.1.0 используются как исторические первичные источники терминов и границ спецификации, а не как подтверждение этого учебного endpoint. Перед применением нужны фактические parser tests и договор с владельцами consumers.'),
],
[openApi303, openApi310, sunsetRfc8594],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-09-field-api-versioning',
title: 'Разбор: клиент перестал читать ответ API',
excerpt: 'Полевой маршрут диагностики: собрать evidence, различить missing field и semantic change, поставить retirement на паузу без рискованного «быстрого исправления».',
readingMinutes: 12,
},
[
paragraph('После изменения ответа клиент может показать пустой итог, остановиться на обработке статуса или отправить request без ожидаемого эффекта. Симптом похож на «сломалась версия API», но это ещё не причина. Цена поспешного исправления высока: можно удалить новый путь, не сохранив body и parser rule, либо повторить state-changing request и скрыть исходный разрыв за другой ошибкой.'),
paragraph('Начинать надо с evidence, которое не требует доступа к реальному production. Для одного наблюдаемого случая достаточно contract ID или revision, endpoint, method, sanitized request, status, response body с исключёнными секретами, версии reader-а и результата compatibility test. Если не разделить missing key, unknown request key и semantic change, команда выберет rollback вслепую и не узнает, какой consumer должен получить переходный договор.'),
heading('Сначала фиксируем границу случая'),
paragraph('Учебный endpoint здесь тот же: <code>POST /api/orders/{orderId}/confirm</code>. v1 response обязан содержать <code>id</code>, <code>status: "confirmed"</code> и <code>totalMinor</code>. v2 добавляет optional <code>deliveryWindow</code>. У request свои правила: <code>confirmationCode</code> обязателен, <code>deliveryPreference</code> optional; absence означает «не менять», <code>null</code> — «очистить», а неизвестный key отклоняется. Такая запись делает следующую проверку конечной: можно сравнить конкретный body с одним contract sample.'),
dataTable(
'Evidence packet до любого исправления',
['Артефакт', 'Что записать', 'Чего не заключать без проверки'],
[
['Граница', 'method, endpoint, contract revision, reader v1/v2', 'что URL сам определяет совместимость'],
['Response', 'status и sanitized JSON body', 'что HTTP success означает корректную semantics'],
['Request', 'keys, presence/absence/null без секретов', 'что unknown field был применён, если ответ успешный'],
['Reader result', 'missing field, unsupported value или ignored key', 'что любой parse error вызван сервером'],
['Fixture', 'assertion и candidate revision', 'что in-memory result описывает реальный deployment'],
],
),
paragraph('Особенно важно не подменять evidence рассказом. Если в body нет <code>totalMinor</code>, это structural break для v1 contract. Если key есть, но <code>status</code> равен <code>accepted</code>, это semantic break в данной модели. Если client v1 получает extra <code>deliveryWindow</code> и reader выбирает прежние три поля, additive response прошёл именно для этого reader-а. Эти три случая оставляют разный след и требуют разных действий.'),
heading('Короткий диагностический reader'),
paragraph('Ниже минимальная функция, которая превращает body в наблюдаемый вывод. Она не знает HTTP и не даёт совет повторять запрос. Её задача — назвать первое нарушенное правило: обязательное поле, ожидаемую semantics или форму денежного значения. Unknown response fields остаются вне view; это явный policy данной fixture, а не предположение о каждом JSON parser-е.'),
codeBlock(responseReaderCode),
paragraph('Для request доказательство строится отдельно. Если v2 послал новое known field, current service принимает его после проверки значения. Если пришла опечатка <code>deliveryPrefrence</code>, результат должен быть отказом <code>unknown-request-field</code>, а не невидимой потерей намерения. В пакете absence <code>deliveryPreference</code> и <code>null</code> также не склеиваются: первое оставляет прежнее предпочтение, второе просит очистить. В логике изменения это разные команды даже при одинаковом endpoint.'),
codeBlock(requestReaderCode),
heading('Матрица симптомов не заменяет raw evidence'),
dataTable(
'Как отличить похожие симптомы',
['Наблюдение', 'Вероятная причина', 'Минимальная проверка', 'Rollback-safe действие'],
[
['v1 не видит сумму', '<code>totalMinor</code> удалён или переименован', 'v1 reader на candidate body', 'оставить legacy field, остановить retirement'],
['HTTP success, но ветка клиента другая', '<code>status</code> изменил смысл', 'проверить допустимый vocabulary reader-а', 'вернуть прежний semantic contract или подготовить явный переход'],
['Новое предпочтение не применилось', 'unknown/опечатанное request-поле', 'validator на keys и absence/null', 'не ретраить вслепую; отклонить key и исправить request contract'],
['v2 не показывает окно', 'absent и <code>null</code> перепутаны либо object неполный', 'v2 reader на три representations', 'сохранить старый response и уточнить смысл optional field'],
],
),
figure(
'/assets/editorial/2021/api-versioning-diagnosis-2021.svg',
'Диагностическое дерево: собрать sanitized request и response, различить missing field, semantic change и unknown request field, затем остановить retirement или уточнить контракт.',
'Рисунок 1. Путь разбора не начинается с rollback. Сначала сохраняется evidence, затем выбирается узкое compatibility condition и только после него — обратимое действие.',
),
paragraph('Удобный формат evidence — один object с неизменяемыми samples, именем assertion и коротким выводом. В fixture такой object не пытается моделировать мониторинг: он хранит только contract facts. Это защищает от знакомой ошибки «мы уже знаем причину»: пока v1 reader не запущен на candidate response, отсутствие <code>totalMinor</code> остаётся гипотезой, а не диагнозом.'),
heading('Пауза перед retirement — нормальное техническое действие'),
paragraph('В учебной fixture candidate retirement удаляет <code>totalMinor</code> и публикует только <code>amountMinor</code>. v1 reader обязан его отклонить. После этого timeline фиксирует не «rollback deployment», а <code>retirement-paused-before-change</code>: legacy response fields сохранены, v2-only request mode не включён ради removal, data mutation равна false. Это обратимый шаг, потому что он не требует восстанавливать состояние и не выдаёт непроверенный candidate за новую норму.'),
codeBlock(fixtureCode),
paragraph('Если причина не в removal, пауза всё равно помогает. При semantic change нужно вернуть договорённое значение или явно подготовить reader к новому vocabulary. При unknown request key нужно исправить contract/клиент, а не «добавить совместимость» через молчаливое игнорирование. При путанице absent/null нужно выбрать один письменный смысл и обновить v2 tests. В каждом случае evidence остаётся рядом с решением, поэтому следующая проверка не начинается заново.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Зафиксировать один конкретный contract case: endpoint, method, reader revision, sanitized request/response и наблюдаемое поведение. Не смешивать несколько клиентов в один incident-like рассказ.',
'<strong>Причина.</strong> Классифицировать first failing rule: required response field отсутствует, known value изменил semantics, optional field имеет неверную форму или request содержит unknown key.',
'<strong>Проверка.</strong> Запустить compatibility fixture на base/additive/candidate response и v1/v2 request. Сверить absence, <code>null</code> и object отдельно. Для retirement проверить именно старый reader.',
'<strong>Действие.</strong> При failed gate остановить retirement до change, сохранить legacy representation и записать условие повторного рассмотрения. При request error вернуть явный rejection, а не повторять state-changing operation без отдельного правила проекта.',
]),
heading('Как сообщить о deprecation без ложного обещания'),
paragraph('Сообщение о deprecation должно содержать не только слово «устарело», но и границу: какой resource или representation затронут, какие fields остаются, какой migration sample считается готовым и где проверяется старый consumer. RFC 8594 описывает <code>Sunset</code> как hint о вероятной будущей недоступности определённого resource; сам заголовок не гарантирует момент вывода и не заменяет переходный контракт. Поэтому его можно использовать только как дополнительный сигнал к проверяемому плану.'),
paragraph('Не стоит заявлять, что fallback «вернём /v1» всегда безопасен. Если v1 и v2 меняют side effect request, простой маршрутизатор не создаёт совместимость. Здесь endpoint и данные учебные, а pause происходит до data mutation. В реальной системе сначала нужно определить owner запроса, повторяемость операции, авторизацию и способы обнаружить потребителя — это другие проверки, которых данная fixture не выполняет.'),
heading('Ограничения'),
paragraph('В этой статье нет реального инцидента, deployment, клиентов, SLA, метрик, HTTP-трафика, базы или API-вызовов. Мы не утверждаем, что любой parser игнорирует unknown response fields и что любой сервер обязан отвергать unknown request fields. OpenAPI и RFC используются как исторические первичные источники для описания API, request body и retirement signal, а не как сертификат совместимости. Практическое решение требует проверить конкретные consumers и их parsers на изолированных examples.'),
],
[openApi303, openApi310, sunsetRfc8594],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
process.stdout.write(JSON.stringify(runApiVersioningFixture(), null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2021-09.mjs --print-revisions | --verify-fixture\n');
}
}