281 lines
42 KiB
JavaScript
281 lines
42 KiB
JavaScript
const escapeHtml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||
const p = (text) => `<p>${text}</p>`;
|
||
const h2 = (text) => `<h2>${text}</h2>`;
|
||
const code = (text) => `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||
const ol = (items) => `<ol>${items.map((item) => `<li>${item}</li>`).join('')}</ol>`;
|
||
const figure = (src, alt, caption) => `<figure><img src="${src}" alt="${alt}" loading="lazy" /><figcaption>${caption}</figcaption></figure>`;
|
||
const table = (caption, headers, rows) => `<div class="table-scroll"><table><caption>${caption}</caption><thead><tr>${headers.map((cell) => `<th scope="col">${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||
|
||
function deepFreeze(value) {
|
||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||
Object.values(value).forEach(deepFreeze);
|
||
Object.freeze(value);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function plainText(html) {
|
||
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function bodyText(html) {
|
||
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, ''));
|
||
}
|
||
|
||
const REFERENCES = deepFreeze({
|
||
openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'OpenAPI Initiative, 24 октября 2024 года, версия 3.1.1' },
|
||
jsonSchema: { title: 'JSON Schema Core 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'JSON Schema, draft 2020-12, спецификация Core' },
|
||
http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' },
|
||
});
|
||
|
||
function sources(entries) {
|
||
return `<ul>${entries.map(({ key, use, boundary }) => {
|
||
const ref = REFERENCES[key];
|
||
return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}</li>`;
|
||
}).join('')}</ul>`;
|
||
}
|
||
|
||
export function validateCustomerResponse(payload) {
|
||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { ok: false, reason: 'body-must-be-object' };
|
||
if (typeof payload.id !== 'string' || payload.id.length < 1) return { ok: false, reason: 'id-must-be-non-empty-string' };
|
||
if (!Number.isInteger(payload.revision) || payload.revision < 1) return { ok: false, reason: 'revision-must-be-positive-integer' };
|
||
if (!['active', 'blocked'].includes(payload.state)) return { ok: false, reason: 'state-is-outside-enum' };
|
||
return { ok: true, value: { id: payload.id, revision: payload.revision, state: payload.state } };
|
||
}
|
||
|
||
export function validateFilterInput(input) {
|
||
if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, reason: 'filter-must-be-object' };
|
||
if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100)) return { ok: false, reason: 'limit-out-of-range' };
|
||
if (input.cursor !== undefined && (typeof input.cursor !== 'string' || input.cursor.length > 256)) return { ok: false, reason: 'cursor-invalid' };
|
||
if (input.state !== undefined && !['active', 'blocked'].includes(input.state)) return { ok: false, reason: 'state-is-outside-enum' };
|
||
return { ok: true, value: { limit: input.limit ?? 20, cursor: input.cursor ?? null, state: input.state ?? null } };
|
||
}
|
||
|
||
export function classifyApiChange(change) {
|
||
const removed = Array.isArray(change.removedProperties) ? change.removedProperties : [];
|
||
const addedRequired = Array.isArray(change.addedRequiredProperties) ? change.addedRequiredProperties : [];
|
||
const narrowedEnum = Boolean(change.narrowedEnum);
|
||
const status = removed.length > 0 || addedRequired.length > 0 || narrowedEnum ? 'breaking' : 'compatible';
|
||
return { status, action: status === 'breaking' ? 'version-or-expand-compatibility-window' : 'run-consumer-contract-tests' };
|
||
}
|
||
|
||
function revision(meta, parts, referenceEntries) {
|
||
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`);
|
||
return deepFreeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const contractRefs = [
|
||
{ key: 'openapi', use: 'Фиксирует структуру HTTP-интерфейса, операции, ответы и семантику описания, чтобы контракт был машинно читаемым.', boundary: 'Не доказывает, что сервер действительно отдаёт описанное тело: runtime-проверка и тесты остаются отдельной обязанностью.' },
|
||
{ key: 'jsonSchema', use: 'Задаёт язык типов, обязательных полей, ограничений и ветвления для JSON-документов.', boundary: 'Схема не знает бизнес-состояние, права доступа, задержку или согласованность нескольких запросов.' },
|
||
{ key: 'http', use: 'Разделяет метод, статус, представление ресурса и условия обмена, на которые опирается совместимость.', boundary: 'Не описывает локальную реализацию сервиса, формат внутренней базы или конкретный клиент.' },
|
||
];
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2027-09-practice-mentor-series',
|
||
title: 'API-контракт: как остановить несовместимый ответ до релиза',
|
||
categories: ['Backend', 'API'],
|
||
cover: '/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg',
|
||
excerpt: 'Разбираем контракт HTTP-ответа: какие изменения ломают клиента, как проверить их локальным валидатором и где заканчивается схема.',
|
||
readingMinutes: 14,
|
||
}, [
|
||
p('Проблема проявляется не в файле OpenAPI, а у потребителя: клиент получает 200, пытается прочитать поле и падает на обычном успешном ответе. Цена такой ошибки — не только один дефектный запрос. Нужно одновременно искать версию клиента, выяснять, какой ответ он ожидал, и решать, можно ли откатить сервер без потери данных.'),
|
||
p('Частая причина — считать добавление поля безопасным всегда или проверять только happy path. Несовместимыми бывают удаление свойства, добавление обязательного свойства, сужение enum и изменение типа. Ниже — узкий контракт для ответа клиента, чистая проверка входа и порядок, который позволяет увидеть риск до публикации изменения.'),
|
||
h2('Контракт начинается с формы ответа'),
|
||
p('Контракт — это не комментарий к контроллеру. Он отвечает на четыре вопроса: какой ресурс возвращён, какие поля обязательны, какие значения допустимы и как клиент понимает отказ. Если поле <code>state</code> раньше имело значения <code>active</code> и <code>blocked</code>, добавление <code>deleted</code> может сломать клиентский switch, даже если JSON остаётся валидным. Если поле стало числом вместо строки, ломается уже десериализация.'),
|
||
p('OpenAPI удобно держать источником формы интерфейса, а JSON Schema — точным описанием JSON-части. Но схема не проверит право пользователя и не узнает, что ревизия записи уже устарела. Поэтому в статье разделены синтаксический контракт и бизнес-проверка: первый должен быть быстрым и детерминированным, вторая живёт рядом с доменным кодом и тестируется отдельно.'),
|
||
table('Изменение ответа и риск для клиента', ['Изменение', 'Тип риска', 'Проверка перед выпуском', 'Безопасное действие'], [
|
||
['Добавлено необязательное поле', 'Обычно совместимо', 'Старый клиент игнорирует поле', 'Добавить contract-test на старую форму'],
|
||
['Удалено поле', 'Breaking', 'Поиск чтения поля в клиентах', 'Сначала deprecated-окно, затем удаление'],
|
||
['Добавлено обязательное поле', 'Breaking для отправителя', 'Проверить все request/response builders', 'Сделать поле optional или выпустить версию'],
|
||
['Сужен enum', 'Breaking для ветвлений', 'Прогнать все старые значения', 'Сохранить значение либо объявить несовместимость'],
|
||
['Изменён тип', 'Breaking', 'Сериализация и fixture ответа', 'Добавить новое поле с новым именем'],
|
||
]),
|
||
h2('Маленький контракт лучше общего обещания'),
|
||
p('Возьмём ответ <code>GET /customers/{id}</code>. Клиенту нужны строковый идентификатор, положительная ревизия и закрытый набор состояний. Валидатор не обращается к сети и не угадывает отсутствующие данные. Он принимает JSON-представление, возвращает нормализованный набор полей или ясную причину отказа. Это полезно в unit-тесте, в consumer contract test и в адаптере на границе сервиса.'),
|
||
p('Важно не путать нормализацию с исправлением. Значение <code>limit</code> можно подставить по умолчанию только там, где это прямо разрешено контрактом фильтра. Для ответа клиента отсутствие обязательного <code>revision</code> — ошибка, а не повод поставить единицу. Молчаливое исправление скрывает несовместимость и переносит её на более дорогой этап.'),
|
||
figure('/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', 'Диаграмма API-контракта: JSON-ответ проходит через проверку обязательных полей, перечислений и типа, после чего клиент получает совместимое представление или ясный отказ.', 'Схема показывает границу между описанием ответа, проверкой формы и действием клиента. Она не обещает, что проверка заменяет бизнес-правила или интеграционные тесты.'),
|
||
h2('Runnable-пример: проверяем вход и ожидаемый результат'),
|
||
p('Пример запускается в Node.js и вызывает экспортированную функцию с двумя объектами. Входом служит обычный JavaScript-объект, а результатом — <code>ok: true</code> с нормализованным значением или <code>ok: false</code> с конкретной причиной. В учебном примере нет HTTP-сервера: цель — показать поведение контракта на границе, а не изобразить готовый production-adapter.'),
|
||
code(`import { validateCustomerResponse } from './upgrade-2027-09.mjs';
|
||
|
||
const accepted = validateCustomerResponse({
|
||
id: 'customer-17',
|
||
revision: 4,
|
||
state: 'active',
|
||
});
|
||
const rejected = validateCustomerResponse({
|
||
id: 'customer-17',
|
||
revision: 4,
|
||
state: 'deleted',
|
||
});
|
||
|
||
console.log(accepted.ok, accepted.value.state);
|
||
console.log(rejected.ok, rejected.reason);
|
||
// true active
|
||
// false state-is-outside-enum`),
|
||
h2('Порядок проверки изменения'),
|
||
ol([
|
||
'Сначала назовите endpoint, метод, статус и media type. Без этого слово «контракт» смешивает запрос, ответ и внутреннюю модель.',
|
||
'Снимите текущую форму ответа: обязательные поля, типы, enum, nullable и значения по умолчанию. Зафиксируйте один положительный и несколько отрицательных примеров.',
|
||
'Сравните diff схемы с реальными местами чтения. Особенно ищите удаление поля, изменение типа и сужение перечисления.',
|
||
'Запустите детерминированный валидатор на старой и новой форме. Ошибка должна содержать поле и причину, а не общий «invalid response».',
|
||
'Прогоните consumer contract tests для двух соседних версий клиента. Если старый клиент не проходит, выберите новое поле, совместимое расширение или отдельную версию.',
|
||
'После выпуска добавьте срок удаления deprecated-поля и проверяемый сигнал использования. Не удаляйте его по ощущению, если нет данных о потребителях.',
|
||
]),
|
||
h2('Где заканчивается схема'),
|
||
p('Схема не отвечает на вопрос, можно ли изменить запись. Ответ <code>state: active</code> может быть формально правильным, но устаревшим относительно команды обновления. Для этого нужны версия ресурса, условный запрос вроде <code>If-Match</code>, правила авторизации и транзакционная проверка. Эти условия следует описывать рядом с endpoint, но не выдавать за свойства JSON Schema.'),
|
||
p('Схема также не гарантирует одинаковое поведение всех реализаций. Сервер может вернуть правильный JSON только для одного кода пути, а ошибка сериализации останется в редком исключении. Поэтому проверка формы должна быть дополнена интеграционным тестом, который вызывает реальный handler, и тестом совместимости, который запускает старый клиент против нового ответа. Наличие двух тестов не делает контракт вечным: оно снижает конкретный риск в известной границе.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Учебный валидатор не проверяет OpenAPI-документ, авторизацию, базу данных, компрессию и сетевые ошибки. Он также не доказывает, что всех потребителей нашли. Его задача уже: не пропустить неверный тип, обязательное поле или новое значение enum на границе JSON.'),
|
||
p('Следующий шаг — собрать один реальный endpoint и добавить к нему пару contract-тестов: старый потребитель должен пройти на расширенном ответе, а breaking diff должен завершаться осознанным решением о версии. Если правило нельзя выразить в форме, status или условии запроса, вынесите его в отдельный раздел доменного контракта, не прячьте в описании поля.'),
|
||
], contractRefs);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2027-09-mechanism-mentor-series',
|
||
title: 'JSON Schema и бизнес-правила: где проходит граница валидации',
|
||
categories: ['Backend', 'Контракты данных'],
|
||
cover: '/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg',
|
||
excerpt: 'Почему валидная JSON Schema не гарантирует корректную операцию: разделяем форму данных, бизнес-инвариант и проверку состояния.',
|
||
readingMinutes: 15,
|
||
}, [
|
||
p('Проблема возникает, когда сервис принимает хорошо сформированный JSON, но отклоняет операцию позже: лимит оказался недоступен, курс валюты устарел, а ресурс уже изменился. Цена смешения слоёв — неясная ошибка 400/409, повторные попытки клиента и спор о том, где именно нарушен контракт.'),
|
||
p('Причина обычно в широком слове «валидировать». Им называют проверку JSON-типа, обязательных полей, доступа пользователя и текущего состояния базы одновременно. Такой обработчик трудно тестировать: непонятно, какой вход должен быть отклонён схемой, а какой — доменной проверкой. Разделим эти решения и соберём минимальный фильтр, который можно запустить без сервера.'),
|
||
h2('Три слоя, которые нельзя склеивать'),
|
||
p('Первый слой — структура: объект, строка, число, массив, обязательность, формат и перечисление. JSON Schema хорошо подходит для такого вопроса. Второй слой — локальный инвариант: например, <code>minAmount <= maxAmount</code> или допустимый размер страницы. Его можно проверять кодом после разбора JSON, если правило зависит от нескольких полей. Третий слой — состояние системы: существует ли пользователь, не занят ли ресурс, не истёк ли токен. Этот слой требует доступа к данным и обычно возвращает другой класс ошибки.'),
|
||
p('Если все три проверки спрятаны в одной схеме, описание начинает обещать больше, чем может проверить. Если всё оставить коду контроллера, клиенты теряют раннюю документацию и точное сообщение о форме. Рабочая граница проходит там, где появляется внешний контекст: схема описывает сам документ, доменная функция — связь полей, сервис — состояние и права.'),
|
||
table('Что проверять схемой, а что — кодом', ['Слой', 'Пример', 'Результат ошибки', 'Подход'], [
|
||
['Тип и обязательность', '<code>limit</code> — integer, required', '400: malformed document', 'JSON Schema или генератор клиента'],
|
||
['Диапазон', '<code>1 ≤ limit ≤ 100</code>', '400: invalid value', 'Schema minimum/maximum плюс тест'],
|
||
['Связь полей', '<code>from <= to</code>', '400: inconsistent filter', 'Чистая функция с двумя полями'],
|
||
['Состояние', 'ресурс не изменён после чтения', '409: state conflict', 'Версия, условный запрос, транзакция'],
|
||
['Право', 'роль может менять статус', '403: forbidden', 'Авторизация до изменения состояния'],
|
||
]),
|
||
h2('Schema не делает неизвестное допустимым'),
|
||
p('У JSON Schema есть важное свойство: ограничения должны быть явными. Для API-фильтра можно разрешить <code>limit</code>, <code>cursor</code> и <code>state</code>, а остальные свойства закрыть через <code>additionalProperties: false</code> в нужном месте схемы. Но закрытость должна соответствовать расширяемости интерфейса. Если команда добавляет служебное поле без версионирования, строгая схема станет источником неожиданных отказов.'),
|
||
p('Есть и другая ловушка — использовать <code>format</code> как доказательство полной корректности. Формат даты или URI задаёт синтаксическую подсказку, но не подтверждает, что дата разрешена для операции или что URI принадлежит доверенному домену. Слово «valid» в отчёте должно иметь уточнение: valid по схеме, valid для инварианта или valid в текущем состоянии.'),
|
||
figure('/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', 'Матрица валидации: структура JSON, связь полей, состояние ресурса и право на действие проходят отдельные проверки с разными классами ошибок.', 'Схема помогает не выдавать успешный разбор JSON за разрешение операции. Каждый слой имеет собственный вход, сообщение и границу ответственности.'),
|
||
h2('Runnable-пример: форма и инвариант по отдельности'),
|
||
p('В следующем фрагменте функция принимает фильтр поиска. Она проверяет форму и диапазон, добавляет безопасные значения по умолчанию и возвращает нормализованный объект. Это не библиотека JSON Schema, а маленький учебный аналог, на котором видно место бизнес-правила. Состояние базы и право доступа намеренно не притворяются частью результата.'),
|
||
code(`import { validateFilterInput } from './upgrade-2027-09.mjs';
|
||
|
||
const good = validateFilterInput({ state: 'active', limit: 25 });
|
||
const bad = validateFilterInput({ state: 'active', limit: 250 });
|
||
const unknown = validateFilterInput({ state: 'active', region: 'eu' });
|
||
|
||
console.log(good.ok, good.value.limit, good.value.cursor);
|
||
console.log(bad.ok, bad.reason);
|
||
console.log(unknown.ok, unknown.value.state);
|
||
// true 25 null
|
||
// false limit-out-of-range
|
||
// true active`),
|
||
h2('Порядок разложения проверки'),
|
||
ol([
|
||
'Опишите JSON-документ отдельно от команды, которая его использует. Назовите поля, типы, обязательность и допустимые значения.',
|
||
'Выберите закрытую или расширяемую модель неизвестных полей. Решение должно быть одинаковым для сервера и клиентов, иначе один слой будет отвергать данные другого.',
|
||
'Вынесите связи нескольких полей в чистые функции. Каждая функция должна иметь отрицательный пример и возвращать имя нарушенного правила.',
|
||
'Присвойте класс ошибки: malformed input, invalid value, conflict или forbidden. Не превращайте конфликт состояния в повторную отправку 400.',
|
||
'Проверьте, какие правила требуют чтения базы или другого сервиса. Для них зафиксируйте порядок проверки и условия гонки.',
|
||
'Сверьте документацию и код на одном fixture-наборе. Расхождение между схемой и runtime-валидатором должно ломать сборку тестов.',
|
||
]),
|
||
h2('Почему 409 важнее ещё одного boolean'),
|
||
p('Когда форма запроса корректна, но состояние изменилось, клиенту нужна возможность выбрать действие: перечитать ресурс, показать конфликт или прекратить операцию. Boolean вроде <code>valid: false</code> стирает причину. HTTP-семантика и локальный API-контракт должны различать ошибку документа и невозможность применить правильный документ к текущему состоянию.'),
|
||
p('Это различие помогает и с повторными попытками. Ошибка схемы не станет правильной от второго запроса, а конфликт иногда исчезает после нового чтения. Если оба случая имеют один статус, клиент либо повторяет бесполезную отправку, либо молча теряет возможность безопасного разрешения. Хорошая валидация уменьшает число retry-циклов именно тем, что сообщает границу отказа.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Учебная функция не реализует полный draft 2020–12, не строит JSON Pointer к ошибке и не читает доменное состояние. Она показывает архитектурное разделение, а не заменяет валидатор библиотеки. В реальном API необходимо проверить выбранную библиотеку на <code>oneOf</code>, ссылки, форматы и поведение при неизвестных ключах.'),
|
||
p('Следующий шаг — взять один endpoint с конфликтом состояния и выписать три независимых теста: неправильная форма, нарушенный инвариант и устаревшая версия ресурса. После этого сравните их статусы и сообщения с документацией. Если один тест требует данных, которых нет в запросе, не расширяйте схему вслепую: это сигнал, что правило относится к сервисному слою.'),
|
||
], contractRefs);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2027-09-field-mentor-series',
|
||
title: 'Code review API-изменения: от diff до обратимой миграции',
|
||
categories: ['Code review', 'Миграции'],
|
||
cover: '/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg',
|
||
excerpt: 'Полевой маршрут для API-diff: классифицируем несовместимость, проверяем потребителей и оставляем безопасное окно отката.',
|
||
readingMinutes: 15,
|
||
}, [
|
||
p('Проблема в code review API-изменения редко выглядит как красная строка. Автор добавляет обязательное поле, меняет enum или удаляет старый response-property, а reviewer видит только локально зелёные тесты. Цена ошибки появляется после публикации: разные версии клиента начинают спорить с одним сервером, а быстрый rollback уже не возвращает удалённое поле.'),
|
||
p('Причина — просматривать diff как изменение одного репозитория. API имеет потребителей, кэш, документацию, генераторы типов и иногда асинхронные события. Поэтому проверка должна начинаться с классификации изменения, продолжаться поиском потребителей и заканчиваться обратимой последовательностью. Ниже — практический маршрут, который можно применить к одному pull request.'),
|
||
h2('Сначала классификация, потом обсуждение кода'),
|
||
p('У каждой строки схемы есть направление совместимости. Добавление необязательного response-поля обычно расширяет контракт. Удаление поля сужает его. Добавление обязательного поля в request ломает старого отправителя, а изменение response-типа ломает десериализацию даже при том же имени. Эта классификация не заменяет review, но не даёт обсуждать все изменения одинаково.'),
|
||
p('Функция <code>classifyApiChange</code> ниже намеренно принимает уже выделенные факты diff. Она не пытается сама прочитать OpenAPI и не делает вывод о конкретной команде. Это удобная граница для теста: если генератор diff ошибся, его ошибка находится до классификатора; если классификатор выбрал <code>breaking</code>, reviewer получает повод проверить совместимость.'),
|
||
table('Минимальная карта API-diff', ['Вопрос', 'Признак', 'Что проверить', 'Решение'], [
|
||
['Старый клиент отправит запрос?', 'Новое required request-поле', 'Все builders и fixtures', 'Default, optional или новая версия'],
|
||
['Старый клиент прочитает ответ?', 'Удаление/переименование поля', 'Поиск доступа к property', 'Deprecated-период и новое поле'],
|
||
['Старое значение остаётся допустимым?', 'Сужение enum', 'Ветвления клиентов и событий', 'Расширить enum или сменить версию'],
|
||
['Сохранилась семантика?', 'Тот же тип, другое значение', 'Документация и consumer test', 'Явно описать смысл и миграцию'],
|
||
['Можно вернуть сервер?', 'Изменение хранения или записи', 'Backward read и rollback', 'Сначала expand, затем switch, потом contract'],
|
||
]),
|
||
h2('Обратимость начинается с данных'),
|
||
p('Откат бинарного файла не откатывает базу и сообщения в очереди. Если новый сервер записал только новый формат, старый сервер может не суметь прочитать данные. Поэтому для опасного API-изменения полезен expand/contract: сначала добавить совместимое поле или колонку, затем научить код читать и писать оба формата, переключить потребителей и только после подтверждения удалить старую форму.'),
|
||
p('На review стоит попросить не обещание «rollback возможен», а конкретную матрицу. Какие версии читают старую запись? Как выглядит запись после частичного переключения? Что произойдёт с повторной доставкой события? Где хранится сигнал, что старый consumer ещё жив? Ответы превращают риск в проверяемые условия, а не в уверенность по названию ветки.'),
|
||
figure('/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', 'Маршрут review API-изменения: diff проходит через классификацию совместимости, проверку потребителей и окно обратимой миграции перед удалением старой формы.', 'Диаграмма связывает локальный diff с потребителями и данными. Красная ветка означает остановку до удаления, если старый формат ещё нужен.'),
|
||
h2('Runnable-пример: классифицируем diff'),
|
||
p('Вход функции — объект с тремя признаками: удалённые свойства, новые обязательные свойства и сужение enum. На выходе — <code>breaking</code> или <code>compatible</code> и действие для review. Это не автоматическое разрешение pull request. Пример полезен как первая страховка, после которой нужны реальные consumer tests и проверка данных.'),
|
||
code(`import { classifyApiChange } from './upgrade-2027-09.mjs';
|
||
|
||
const additive = classifyApiChange({
|
||
removedProperties: [],
|
||
addedRequiredProperties: [],
|
||
narrowedEnum: false,
|
||
});
|
||
const risky = classifyApiChange({
|
||
removedProperties: ['displayName'],
|
||
addedRequiredProperties: [],
|
||
narrowedEnum: false,
|
||
});
|
||
|
||
console.log(additive.status, additive.action);
|
||
console.log(risky.status, risky.action);
|
||
// compatible run-consumer-contract-tests
|
||
// breaking version-or-expand-compatibility-window`),
|
||
h2('Порядок review для одного diff'),
|
||
ol([
|
||
'Скопируйте в описание изменения старую и новую форму запроса, ответа и события. Diff схемы без примеров заставляет reviewer восстанавливать смысл по именам.',
|
||
'Запустите классификатор и вручную проверьте каждый breaking-признак: удаление, required, enum, тип и изменение семантики.',
|
||
'Найдите потребителей по сгенерированным типам, сериализаторам, документации и тестовым fixture. Отдельно проверьте неизвестные внешние клиенты.',
|
||
'Составьте матрицу чтения и записи старой и новой формы. Укажите, что произойдёт при частичном rollout и повторной доставке события.',
|
||
'Добавьте отрицательные contract-тесты для старого клиента и положительные для нового. Тест должен падать на конкретном поле, а не на общем статусе.',
|
||
'Опишите условие удаления старой формы: сигнал использования, срок хранения и способ восстановления. Без этого «временное поле» становится вечным.',
|
||
]),
|
||
h2('Ограничения автоматической классификации'),
|
||
p('Классификатор не знает, что <code>displayName</code> обязателен для внешнего клиента, а внутренний клиент его игнорирует. Он не проверяет кэш, подписанные payload, очереди и генерацию SDK. Даже правильный статус <code>breaking</code> не говорит, как долго держать две версии. Это инструмент сортировки риска, не замена архитектурному решению.'),
|
||
p('Не всякая совместимая форма безопасна семантически. Поле может остаться строкой, но начать содержать другой часовой пояс или другую единицу измерения. Поэтому в review нужен отдельный вопрос о значении, а не только о типе. Если смысл изменился, новое имя часто дешевле, чем заставлять клиентов угадывать период перехода.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Статья не описывает конкретный CI, брокер или схему базы. Примеры синтетические и запускаются локально; они показывают форму решений, а не результат изменения внешнего API. Для опасных контрактов потребуется интеграция с registry схем, consumer tests и наблюдаемым сигналом использования старого поля.'),
|
||
p('Следующий шаг — выбрать один настоящий diff и заполнить четыре артефакта: старая/новая схема, таблица потребителей, тест частичного rollout и процедура удаления. Если хотя бы один потребитель неизвестен, оставьте расширение совместимым и не переходите к contract-фазе миграции.'),
|
||
], contractRefs);
|
||
|
||
export const revisions = deepFreeze([practice, mechanism, field]);
|
||
|
||
export function runApiContractFixture() {
|
||
const cases = [
|
||
['response-accepts-known-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'active' }).ok, true],
|
||
['response-rejects-unknown-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'deleted' }).reason, 'state-is-outside-enum'],
|
||
['filter-applies-default', validateFilterInput({ state: 'blocked' }).value.limit, 20],
|
||
['filter-rejects-large-limit', validateFilterInput({ limit: 101 }).reason, 'limit-out-of-range'],
|
||
['diff-detects-breaking', classifyApiChange({ removedProperties: ['name'] }).status, 'breaking'],
|
||
['diff-keeps-additive-change', classifyApiChange({ removedProperties: [], addedRequiredProperties: [], narrowedEnum: false }).status, 'compatible'],
|
||
];
|
||
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
|
||
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
|
||
}
|
||
|
||
export function verifyRevisionsAgainstFixture() {
|
||
const fixture = runApiContractFixture();
|
||
const articleChecks = revisions.map((item) => {
|
||
const text = bodyText(item.contentHtml);
|
||
return text.length >= 5000 && text.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900));
|
||
});
|
||
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) {
|
||
const result = verifyRevisionsAgainstFixture();
|
||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||
if (!result.accepted) process.exitCode = 1;
|
||
}
|
||
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|