function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '

' + text + '

'; } function heading(text) { return '

' + text + '

'; } function codeBlock(lines) { return '
' + escapeHtml(lines.join('\n')) + '
'; } function figure(src, alt, caption) { return '
' + alt + '
' + caption + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function bulletList(items) { return ''; } function dataTable(caption, headers, rows) { const head = '' + headers.map((header) => '' + header + '').join('') + ''; const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; return '
' + head + body + '
' + caption + '
'; } function sourceList(items) { return ''; } function visibleText(html) { return html .replace(/<[^>]*>/g, ' ') .replaceAll(' ', ' ') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('&', '&') .replace(/\s+/g, ' ') .trim(); } function proseText(html) { return visibleText( html .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
      .replace(/
[\s\S]*?<\/figure>/g, '') .replace(/
[\s\S]*?<\/div>/g, ''), ); } function createRevision(meta, bodyParts, sources) { const bodyHtml = bodyParts.join('\n'); const proseLength = proseText(bodyHtml).length; if (proseLength < 5000 || proseLength > 15000) { throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength); } if (sources.length < 2) { throw new Error(meta.slug + ': at least two primary sources are required'); } return { ...meta, contentHtml: [ bodyHtml, heading('Проверяемые источники'), sourceList(sources), ].join('\n'), }; } const rfcHttp = { title: 'IETF RFC 7231, HTTP/1.1 Semantics and Content', url: 'https://www.rfc-editor.org/rfc/rfc7231', note: 'семантика методов, представлений, Content-Type и кодов ответа, действовавшая в 2019 году', }; const rfcStatus = { title: 'IETF RFC 7231, раздел 6: Response Status Codes', url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6', note: 'коды 2xx, 4xx и 5xx сообщают результат конкретного HTTP-запроса; их смысл нельзя заменять произвольным полем JSON', }; const rfcProblem = { title: 'IETF RFC 7807, Problem Details for HTTP APIs', url: 'https://www.rfc-editor.org/rfc/rfc7807', note: 'стандартизированная форма problem detail с type, title, status, detail и instance; расширения остаются контрактом API', }; const rfcLink = { title: 'IETF RFC 8288, Web Linking', url: 'https://www.rfc-editor.org/rfc/rfc8288', note: 'модель ссылочных отношений HTTP; конкретная форма пагинации должна быть явно выбрана командой', }; const openApi = { title: 'OpenAPI Specification 3.0.2', url: 'https://spec.openapis.org/oas/v3.0.2.html', note: 'версия спецификации, доступная в 2019 году; описывает пути, операции, ответы, content и Schema Object', }; const openApiOperation = { title: 'OpenAPI 3.0.2, Operation Object', url: 'https://spec.openapis.org/oas/v3.0.2.html#operation-object', note: 'каждая операция объявляет параметры и ожидаемые ответы, а не только URL и метод', }; const openApiResponse = { title: 'OpenAPI 3.0.2, Responses Object', url: 'https://spec.openapis.org/oas/v3.0.2.html#responses-object', note: 'ответы задаются по HTTP-коду или default; у каждого можно описать content и схему тела', }; const openApiSchema = { title: 'OpenAPI 3.0.2, Schema Object', url: 'https://spec.openapis.org/oas/v3.0.2.html#schema-object', note: 'required относится к свойствам объекта, а nullable разрешает null только при явно заданном type', }; const practiceArticle = createRevision( { slug: 'editorial-2019-06-practice-rest-api', title: 'REST API без угадывания: фиксируем статусы, ошибки и страницу списка', categories: ['HTTP', 'API', 'Практика'], cover: '/assets/editorial/2019/rest-api-contract-map-2019.svg', excerpt: 'Клиент падает не из-за адреса, а когда 200 содержит ошибку, следующая страница исчезает или необязательное поле внезапно становится null. Собираем короткий контракт операции и проверяем его на локальных фикстурах.', readingMinutes: 12, }, [ paragraph('Симптом обычно выглядит как ошибка интерфейса: список заказов перестал листаться, карточка падает на customer.name, а форма показывает «неизвестную ошибку». URL /api/orders при этом не менялся. Цена такого сбоя выше одной красной строки в консоли: клиент повторяет запрос, пользователь не понимает, сохранено ли действие, а backend и frontend спорят о том, кто «сломал API». Причина почти всегда в незафиксированном ответе: статус говорит одно, тело другое, а пагинация или необязательное поле существуют только в чьей-то памяти.'), paragraph('В июне 2019 я бы начал не с генератора клиента и не с большой документации. Для одной операции нужен короткий договор, который можно прочитать за несколько минут и прогнать на данных. Возьмём GET /api/v1/orders: он возвращает страницу заказов, принимает limit и непрозрачный cursor, а при плохом курсоре отдаёт problem document. Пример учебный: он не выполняет запрос к серверу и не доказывает поведение production. Его задача — показать, какие признаки должны совпасть до интеграции.'), heading('Сначала описываем наблюдаемый сбой и стоимость'), paragraph('Плохой договор часто начинается с фразы «успешный ответ — JSON». Она не отвечает на четыре вопроса. Что считать успехом: только 200 или ещё 204? Как клиент узнаёт о неправильном параметре? Чем последняя страница отличается от временно пустого списка? И допустимо ли отсутствие поля customer, либо оно должно быть null? Если эти решения не записаны, каждая библиотека подставляет собственное: fetch не считает 400 исключением, сериализатор может опустить ключ, а UI делает доступ к вложенному свойству без проверки.'), paragraph('HTTP уже задаёт язык для результата операции. RFC 7231 описывает метод, целевой ресурс, представление и коды статуса; 200 означает успешный ответ, а 4xx и 5xx сообщают разные классы ошибки запроса и сервера. Наш проектный контракт не должен переопределять этот язык флагом ok: false внутри ответа с 200. Он должен уточнять его: для какого статуса какое представление приходит, какой Content-Type ожидается и какие поля клиент вправе читать.'), dataTable( 'Карта рисков для одной операции списка заказов', ['Наблюдение у клиента', 'Незакрытая граница', 'Что фиксируем в контракте', 'Цена, если не зафиксировать'], [ ['Кнопка «ещё» исчезла раньше времени', 'Последняя страница смешана с пустым результатом', 'Обязательный объект page и явный nextCursor: null', 'Пользователь не видит часть заказов'], ['Экран падает на вложенном свойстве', 'Неизвестно, обязательны ли customer и его поля', 'required для ядра заказа; правило для отсутствующего customer', 'Падение или ложная пустая карточка'], ['Форма показывает общий баннер', 'Ошибка параметра не имеет стабильной формы', 'application/problem+json, type и расширение errors', 'Нельзя привязать действие к полю'], ['Клиент продолжает парсить 200', 'Статус и тело противоречат друг другу', 'Список допустимых статусов на операцию', 'Сбой маскируется как «пустые данные»'], ], ), heading('Выбираем маленький, но полный контракт операции'), paragraph('Для начала достаточно одного пути, одного метода и нескольких ответов. Наша операция читает коллекцию, поэтому договор включает параметры, а не только тело 200. limit имеет диапазон; cursor либо отсутствует, либо является строкой, которую клиент не разбирает; ответ всегда содержит items и page. В page.nextCursor строка означает, что следующий запрос возможен, а null означает конец снимка. Мы не используем отсутствие ключа как отдельный сигнал.'), paragraph('Это проектное решение, а не требование REST или HTTP. Пагинация не задана RFC 7231: команда могла бы использовать offset, Link header или отдельный объект links. Важно выбрать одну форму и описать её до кода. Cursor здесь непрозрачен намеренно. Если UI начинает вырезать из него дату или ID, сервер уже не сможет изменить кодирование без поломки клиента. Клиент должен только передать полученную строку в следующий запрос.'), codeBlock([ 'GET /api/v1/orders?limit=2 HTTP/1.1', 'Accept: application/json', '', 'HTTP/1.1 200 OK', 'Content-Type: application/json', '', '{', ' "items": [', ' {', ' "id": "ord_1042",', ' "status": "paid",', ' "total": { "amount": 9900, "currency": "RUB" },', ' "customer": { "id": "cus_17", "name": "Ирина" }', ' }', ' ],', ' "page": { "limit": 2, "nextCursor": "ord_1042" }', '}', ]), paragraph('В примере id, status, total и page — обязательное ядро. customer — необязательное поле: если его нет, это не ошибка транспорта и не строка null. Если поле присутствует, оно обязано быть объектом с теми свойствами, которые нужны текущему экрану. Это важнее, чем кажется: «может прийти всё что угодно» делает любой клиент вынужденным угадывать, а строгий контракт позволяет обработать отсутствие ровно в одном месте.'), heading('Статус и ошибка образуют один результат'), paragraph('Для неправильного cursor не нужно возвращать 200 с массивом errors. Запрос не выполнен как запрос списка, поэтому выбираем клиентскую ошибку 400 Bad Request. RFC 7807 задаёт переносимую оболочку problem detail: поля type, title, status, detail и instance; дополнительные поля разрешены как extension members. В договоре ниже errors — именно расширение приложения, а не тайный стандарт поля.'), codeBlock([ 'HTTP/1.1 400 Bad Request', 'Content-Type: application/problem+json', '', '{', ' "type": "https://api.example.test/problems/invalid-cursor",', ' "title": "Параметр cursor недействителен",', ' "status": 400,', ' "detail": "Курсор не принадлежит этому списку заказов",', ' "instance": "/api/v1/orders?limit=2&cursor=broken",', ' "errors": [', ' { "path": "query.cursor", "code": "invalid_cursor" }', ' ]', '}', ]), paragraph('Клиенту не следует сопоставлять логику с русским title или английским detail. Текст пригодится человеку и журналу, но стабильным ключом решения становится type или наш errors[0].code. Например, invalid_cursor означает: очистить сохранённый курсор, загрузить первую страницу и не повторять тот же запрос в цикле. Нераспознанный type должен показать общий сбой и оставить диагностический след, а не притвориться пустым списком.'), figure( '/assets/editorial/2019/rest-api-contract-map-2019.svg', 'Вертикальная схема контракта GET списка заказов: параметры limit и cursor ведут к двум развилкам 200 application/json и 400 application/problem+json; у успеха выделены items и page.nextCursor, у ошибки type и errors.', 'Контракт начинается на входе запроса и заканчивается тем, что клиент может проверить в статусе, Content-Type и схеме тела. Один URL не покрывает эти границы.', ), heading('Записываем операцию в OpenAPI, а не в комментарий'), paragraph('OpenAPI 3.0.2 уже позволяет записать этот договор рядом с API. Operation Object связывает путь, метод, параметры и responses. Responses Object, в свою очередь, привязывает конкретный HTTP-код к content и Schema Object. Это не гарантирует, что сервер исполняет YAML автоматически. Зато файл становится единым местом, где видно: 200 — страница, 400 — problem document, а тело не описывается абстрактным словом object.'), codeBlock([ '/api/v1/orders:', ' get:', ' parameters:', ' - in: query', ' name: cursor', ' schema: { type: string }', ' - in: query', ' name: limit', ' schema: { type: integer, minimum: 1, maximum: 100 }', ' responses:', ' "200":', ' description: Страница заказов', ' content:', ' application/json:', ' schema: { $ref: "#/components/schemas/OrdersPage" }', ' "400":', ' description: Неподходящий cursor или limit', ' content:', ' application/problem+json:', ' schema: { $ref: "#/components/schemas/Problem" }', ]), paragraph('Не надо делать OpenAPI файлом «на потом». В ревью к изменению операции должны попасть одновременно: изменение схемы, пример ответа и правило для клиента. Если backend добавляет поле, которое может отсутствовать, это чаще всего обратно совместимо для терпимого клиента, но только после проверки его потребления. Если он удаляет required-поле, меняет тип или переносит ошибку из 400 в 200, это уже изменение поведения, для которого нужен согласованный переход.'), heading('Проверяем договор на локальных данных'), paragraph('До доступа к стенду можно поймать часть расхождений на фикстурах. В пакете есть небольшой запуск node web/scripts/upgrade-2019-06.mjs --run-fixture. Он не открывает сеть: берёт три заранее заданных response-объекта и проверяет обязательные поля страницы, явный конец пагинации, форму problem document и отрицательный случай с потерянным nextCursor. Такой тест не заменяет интеграционный: он не знает о роутинге, авторизации или сериализаторе сервера. Но он делает документированную границу исполнимой до подключения реального API.'), orderedList([ 'Выберите одну операцию и запишите её ожидаемое действие, а не общий список «API должно быть RESTful».', 'Назовите допустимые HTTP-статусы, Content-Type и форму тела для каждого статуса.', 'Для коллекции отдельно зафиксируйте первый запрос, окончание страницы и правило передачи cursor или offset.', 'Отметьте required-поля и одно точное правило для необязательного поля: отсутствует, null или объект; не оставляйте все три варианта одновременно.', 'Добавьте пример успеха, пример ошибки и минимальную фикстуру, которая ломается при изменении этих признаков.', 'Перед выпуском выполните такой же запрос к тестовому серверу и сравните статус, заголовок и тело со спецификацией.', ]), heading('Границы решения и короткий вывод'), paragraph('Этот контракт не решает авторизацию, повторную доставку команд, лимиты нагрузки и версионирование всех ресурсов. Он также не утверждает, что cursor безопасен как токен доступа: его формат и срок жизни остаются отдельной задачей. Зато он убирает базовую неопределённость на границе frontend и backend. Когда 200, 400, items, nextCursor и optional-поля можно прочитать и проверить, ошибка перестаёт выглядеть как мистический «сломанный REST».'), bulletList([ 'URL и метод идентифицируют операцию, но не описывают все её успешные и ошибочные представления.', 'Статус, Content-Type и схема тела проверяются вместе; флаг ошибки внутри 200 не заменяет HTTP-семантику.', 'Пагинация и optional-поля — явные проектные решения, которым нужен один проверяемый вариант.', 'Локальная фикстура полезна как ранняя проверка контракта, но не является отчётом о работе production-сервера.', ]), ], [rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema], ); const mechanismArticle = createRevision( { slug: 'editorial-2019-06-mechanism-rest-api', title: 'OpenAPI 3.0.2 под капотом: операция, статус, схема и совместимость клиента', categories: ['HTTP', 'OpenAPI', 'Архитектура'], cover: '/assets/editorial/2019/rest-api-response-matrix-2019.svg', excerpt: 'Схема API полезна не как каталог URL. Разбираем, как в OpenAPI 3.0.2 связать операцию с кодами ответов, problem details, required-полями и окончанием cursor-пагинации.', readingMinutes: 13, }, [ paragraph('Симптом более опасный, чем опечатка в URL: backend выкатывает «небольшое» изменение, и старый клиент получает ответ, который синтаксически остаётся JSON, но семантически стал другим. status превратился из строки в объект, пустая страница потеряла nextCursor, а ошибка валидации стала 200. Цена — тихая поломка: мониторинг видит успешные HTTP-запросы, а пользователь видит пустой экран или повторную отправку формы. Причина — у операции нет границы совместимости, есть только маршрут и пример из happy path.'), paragraph('Разберём механизм на той же коллекции заказов, но не как рецепт одного контроллера. Нам нужно понять, что именно фиксирует спецификация и что остаётся проектным решением. В 2019 году для этого подходит OpenAPI 3.0.2: она описывает Path Item, Operation, параметры, Responses, content и Schema Object. HTTP остаётся транспортным контрактом, а OpenAPI собирает выбранный договор операции в один документ. Ни одна YAML-схема сама не проверит живой сервер, если команда не подключит её к тесту или ревью.'), heading('Операция — это не только path и метод'), paragraph('Когда в описании есть только GET /orders, человек всё ещё не знает, какие параметры разрешены и что приходит при каждом результате. Operation Object в OpenAPI объединяет эти части. У cursor фиксируется расположение in: query, тип и условная необязательность; у limit — числовые границы. В responses фиксируется не один пример, а карта статусов. Клиент тогда строит ветвление не по догадке «если в JSON есть error», а по документированному результату HTTP.'), paragraph('Практический минимум: 200 для страницы, 400 для недопустимого запроса, 401 для отсутствующего или недействительного контекста доступа, 500 для непредвиденного сбоя. Не нужно объявлять все коды, которые может вернуть любой proxy в мире. Но каждый код, который сознательно производит приложение, должен иметь форму ответа или честную пометку, что тела нет. Иначе мобильный клиент может ждать JSON от 401, а gateway отправит HTML, который парсер примет за сетевую ошибку.'), dataTable( 'Матрица результата одной операции GET /api/v1/orders', ['HTTP-статус', 'Content-Type', 'Обязательная форма', 'Действие клиента', 'Чего не делать'], [ ['200', 'application/json', 'items, page.limit, page.nextCursor', 'Показать элементы; передать строковый cursor дальше только при наличии', 'Не считать пустой items концом без проверки page'], ['400', 'application/problem+json', 'type, title, status и project errors', 'Сбросить только проблемный параметр или показать объяснение', 'Не парсить ошибку как страницу'], ['401', 'Описывается отдельно', 'Статус и согласованный ответ/заголовок', 'Запустить известный поток авторизации', 'Не повторять запрос бесконечно'], ['500', 'application/problem+json либо общий ответ', 'Без внутренних деталей и стека', 'Показать общий сбой и сохранить trace identifier', 'Не выдавать пользователю SQL или stack trace'], ], ), heading('Responses Object связывает код и представление'), paragraph('В OpenAPI ответ задан ключом HTTP-кода или default. У него есть description, headers, links и content. Самая полезная часть для клиента — content: она связывает media type с schema. Если 200 объявлен как application/json, а 400 как application/problem+json, граница становится наблюдаемой даже до чтения каждого поля. Серверу не стоит отдавать HTML-страницу ошибки под тем же публичным API-путём молча: это нарушает ожидание парсера и скрывает источник проблемы.'), codeBlock([ 'responses:', ' "200":', ' description: Страница заказов', ' content:', ' application/json:', ' schema:', ' $ref: "#/components/schemas/OrdersPage"', ' "400":', ' description: Параметры списка не проходят проверку', ' content:', ' application/problem+json:', ' schema:', ' $ref: "#/components/schemas/Problem"', ' "500":', ' description: Непредвиденная ошибка обработки', ' content:', ' application/problem+json:', ' schema:', ' $ref: "#/components/schemas/Problem"', ]), paragraph('Файл не обязан делать все ошибки одинаковыми. Например, ошибка авторизации может прийти с заголовком, который понятен используемому механизму доступа, а у асинхронной команды может быть другой ожидаемый успешный статус. Важно не прятать различие. Если две операции возвращают разные формы ошибки, это надо назвать в их responses. Если команда сознательно выбирает один Problem schema для нескольких операций, то её расширения — errors, traceId, code полей — тоже становятся частью совместимого контракта.'), heading('Schema Object: required и nullable решают разные вопросы'), paragraph('Самая частая ловушка — назвать поле «необязательным», не указав, что это означает на проводе. В OpenAPI 3.0.2 массив required принадлежит объекту: в нём перечислены имена свойств, которые должны присутствовать. nullable: true отвечает на другой вопрос: можно ли передать значение null, если у schema явно указан type. Отсутствующий ключ и ключ со значением null — разные состояния; клиенту нельзя считать их одинаковыми, если это не записано в договоре.'), paragraph('Для страницы заказов выберем строгую форму. items и page обязательны, потому что клиент всегда должен отличить ответ коллекции от произвольного объекта. В page обязательны limit и nextCursor; последний имеет тип string и nullable, поэтому конец списка выражается null, а не пропущенным ключом. customer у заказа не входит в required: его может не быть, но если он есть, он — object, не null. Такое решение можно поменять, но менять его надо как изменение контракта, а не как побочный эффект ORM.'), codeBlock([ 'OrdersPage:', ' type: object', ' required: [items, page]', ' properties:', ' items:', ' type: array', ' items: { $ref: "#/components/schemas/Order" }', ' page:', ' type: object', ' required: [limit, nextCursor]', ' properties:', ' limit: { type: integer, minimum: 1 }', ' nextCursor: { type: string, nullable: true }', 'Order:', ' type: object', ' required: [id, status, total]', ' properties:', ' id: { type: string }', ' customer: { $ref: "#/components/schemas/Customer" }', ]), paragraph('Эта схема не говорит, что JSON Schema валидатор в проекте обязан полностью понимать любую возможность JSON Schema. OpenAPI 3.0.2 определяет собственный Schema Object с расширенным подмножеством. Поэтому до выбора генератора или validator надо сверить, какую версию и какую часть спецификации он реально поддерживает. В противном случае на бумаге появится nullable, а в рантайме проверка пропустит другой вариант или, наоборот, отвергнет законный ответ.'), figure( '/assets/editorial/2019/rest-api-response-matrix-2019.svg', 'Схема слева направо: один GET с параметрами limit и cursor входит в Operation Object, затем ветвится в Responses 200 application/json и 400 application/problem+json; внизу показано, как Schema Object различает required property и nullable value.', 'У операции есть два слоя: HTTP сообщает, какой результат пришёл, а schema определяет, какие данные разрешено читать внутри выбранного представления.', ), heading('Problem Details не отменяет проектную ошибку'), paragraph('RFC 7807 полезен тем, что проблема перестаёт быть бесформенным { "error": "..." }. Поле type является URI reference, title — кратким названием, status отражает HTTP-код, detail поясняет конкретный случай, instance помогает различать экземпляры. Но RFC не выдаёт команде готовые коды полей. Если в UI важно выделить query.cursor, это безопаснее сделать явным расширением errors с документированными path и code, чем извлекать смысл из локализованного текста.'), paragraph('Не называйте каждый бизнес-конфликт «400» только потому, что клиент передал JSON. Нужный статус зависит от семантики операции; его стоит сверять с HTTP и договором продукта. В этой статье мы ограничили пример недопустимым cursor, поэтому 400 понятен: сообщение запроса нельзя обработать как корректную страницу. Для конфликта версии ресурса команда может выбрать другой документированный путь. Главное — не менять статус между релизами без проверки клиентов и не посылать известную ошибку как успешный JSON.'), heading('Пагинация — часть представления, а не свойство базы'), paragraph('Наличие LIMIT 20 в SQL ещё не создаёт API-пагинацию. Клиенту нужно знать порядок, размер страницы, признак конца и поведение cursor после изменения данных. В нашем компактном договоре сервер возвращает текущий limit и opaque nextCursor. Мы не обещаем стабильный total и не выводим его из длины items: короткая страница может быть последней, но это решение подтверждает именно nextCursor: null. Если продукту нужен total, он становится отдельным полем с отдельной стоимостью и условиями точности.'), paragraph('RFC 8288 описывает Web Linking, и команда может выбрать Link header для relation next. Это допустимый, но другой контракт: тогда нужно зафиксировать relation, относительность URL, порядок параметров и способ, которым клиент читает header. Не смешивайте Link и page.nextCursor наполовину. Один доступный путь быстрее тестируется и не заставляет frontend искать несколько несогласованных признаков конца списка.'), dataTable( 'Совместимость изменений тела 200 для терпимого клиента', ['Изменение', 'Почему риск есть', 'Что проверить до выпуска', 'Безопасный переход'], [ ['Добавить необязательное поле', 'Старый клиент может игнорировать его, новый — ошибочно ожидать', 'Парсер не требует поле до согласованного релиза', 'Сначала добавить и наблюдать, затем использовать'], ['Удалить required-поле', 'Старый клиент делает прямой доступ', 'Все поддерживаемые клиенты и контрактные фикстуры', 'Новая версия или период двух полей'], ['Изменить string на object', 'JSON парсится, но логика ломается позже', 'Потребители, schema и примеры', 'Новое поле или новая операция'], ['Заменить null отсутствием', 'Это два разных состояния schema', 'Проверки terminal page и UI ветвления', 'Сохранить один вариант до миграции клиентов'], ], ), heading('Делаем изменение проверяемым'), paragraph('Техническая ценность OpenAPI начинается, когда на её основе появляется проверка. В минимальном варианте это ревью diff: изменились ли status, media type, required или nullable? Затем — пример каждого ответа и локальная fixture, которая намеренно отвергает потерянный nextCursor или problem document с 200. После подключения тестового сервера те же случаи становятся запросами к живому endpoint. Так документ не обещает автоматически совместимость, а даёт список точек, где она может быть нарушена.'), orderedList([ 'Опишите Operation Object вместе с параметрами, а не добавляйте responses после реализации контроллера.', 'Для каждого сознательно возвращаемого статуса укажите description, Content-Type и schema тела или явное отсутствие тела.', 'Разведите отсутствующее свойство и null через required и nullable; зафиксируйте выбор примером.', 'Опишите окончание пагинации как часть 200, не выводите его из случайной длины массива.', 'Добавьте локальные positive и negative fixtures, затем перенесите те же ожидания на тестовый сервер.', 'При изменении schema оцените поддержку старых клиентов до слияния, а не после первых ошибок пользователя.', ]), heading('Границы механизма и короткий вывод'), paragraph('OpenAPI не заменяет авторизацию, миграцию данных или проверку таймаутов. Она также не делает любое изменение YAML обратно совместимым. Но спецификация даёт инженерный язык для спора: не «у нас же JSON», а «операция больше не возвращает required page.nextCursor на 200». В 2019 это уже достаточный шаг от договорённостей в чате к T-shaped работе на границе frontend, backend и HTTP.'), bulletList([ 'Операция состоит из параметров, HTTP-кодов, media types и schemas; URL — только вход в этот договор.', 'Responses Object связывает код с представлением, а Schema Object делает форму тела проверяемой.', 'required и nullable не взаимозаменяемы; отсутствие ключа и null надо выбирать осознанно.', 'Пагинация и extension-поля problem detail принадлежат проектному контракту и требуют теста.', ]), ], [rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema], ); function hasOwn(object, key) { return Object.prototype.hasOwnProperty.call(object, key); } function mediaType(headerValue) { return typeof headerValue === 'string' ? headerValue.split(';', 1)[0].trim().toLowerCase() : ''; } function assertFixture(condition, message) { if (!condition) { throw new Error(message); } } function assertProblem(response, expectedStatus, expectedCode) { const contentType = response.headers['content-type']; const body = response.body; assertFixture(response.status === expectedStatus, 'ожидался HTTP ' + expectedStatus); assertFixture(mediaType(contentType) === 'application/problem+json', 'ошибка должна иметь application/problem+json'); assertFixture(body && typeof body === 'object', 'problem body должен быть объектом'); assertFixture(typeof body.type === 'string' && body.type.indexOf('https://') === 0, 'problem.type должен быть URI'); assertFixture(body.status === expectedStatus, 'problem.status должен совпадать с HTTP-статусом'); assertFixture(Array.isArray(body.errors) && body.errors.length > 0, 'problem.errors должен быть непустым массивом'); assertFixture(body.errors[0].code === expectedCode, 'ожидался project error code ' + expectedCode); } function assertOrdersPage(response) { const contentType = response.headers['content-type']; const body = response.body; assertFixture(response.status === 200, 'страница списка должна иметь HTTP 200'); assertFixture(mediaType(contentType) === 'application/json', 'страница должна иметь application/json'); assertFixture(body && typeof body === 'object', 'body страницы должен быть объектом'); assertFixture(Array.isArray(body.items), 'items должен быть массивом'); assertFixture(body.page && typeof body.page === 'object', 'page должен быть объектом'); assertFixture(Number.isInteger(body.page.limit) && body.page.limit > 0, 'page.limit должен быть положительным целым'); assertFixture(hasOwn(body.page, 'nextCursor'), 'page.nextCursor должен присутствовать даже на последней странице'); assertFixture( body.page.nextCursor === null || typeof body.page.nextCursor === 'string', 'page.nextCursor должен быть строкой или null', ); body.items.forEach((order, index) => { assertFixture(order && typeof order === 'object', 'items[' + index + '] должен быть объектом'); assertFixture(typeof order.id === 'string' && order.id.length > 0, 'items[' + index + '].id обязателен'); assertFixture(typeof order.status === 'string' && order.status.length > 0, 'items[' + index + '].status обязателен'); assertFixture(order.total && Number.isInteger(order.total.amount), 'items[' + index + '].total.amount обязателен'); assertFixture(order.total && typeof order.total.currency === 'string', 'items[' + index + '].total.currency обязателен'); if (hasOwn(order, 'customer')) { assertFixture(order.customer && typeof order.customer === 'object', 'customer при наличии должен быть объектом, не null'); assertFixture(typeof order.customer.id === 'string', 'customer.id обязателен при наличии customer'); assertFixture(typeof order.customer.name === 'string', 'customer.name обязателен при наличии customer'); } }); } function expectFixtureFailure(action, expectedText) { try { action(); } catch (error) { assertFixture(String(error.message).indexOf(expectedText) !== -1, 'fixture должен упасть по ожидаемой причине'); return; } throw new Error('fixture должен был обнаружить нарушение: ' + expectedText); } function runContractFixture() { const finalPage = { status: 200, headers: { 'content-type': 'application/json' }, body: { items: [ { id: 'ord_1043', status: 'paid', total: { amount: 9900, currency: 'RUB' }, }, ], page: { limit: 2, nextCursor: null }, }, }; const invalidCursor = { status: 400, headers: { 'content-type': 'application/problem+json' }, body: { type: 'https://api.example.test/problems/invalid-cursor', title: 'Параметр cursor недействителен', status: 400, detail: 'Курсор не принадлежит этому списку заказов', instance: '/api/v1/orders?cursor=broken', errors: [{ path: 'query.cursor', code: 'invalid_cursor' }], }, }; const brokenTerminalPage = { status: 200, headers: { 'content-type': 'application/json' }, body: { items: [], page: { limit: 2 }, }, }; assertOrdersPage(finalPage); assertProblem(invalidCursor, 400, 'invalid_cursor'); expectFixtureFailure( () => assertOrdersPage(brokenTerminalPage), 'page.nextCursor должен присутствовать', ); return { fixture: 'rest-api-contract-v1', transport: 'local response objects only; no server request was made', cases: [ { name: '200 final page with explicit null cursor', result: 'pass' }, { name: '400 invalid cursor with problem details', result: 'pass' }, { name: '200 page without nextCursor is rejected', result: 'pass' }, ], }; } const fieldArticle = createRevision( { slug: 'editorial-2019-06-field-rest-api', title: 'Полевой разбор: контрактный тест REST API без подмены его production-проверкой', categories: ['HTTP', 'Тестирование', 'Диагностика'], cover: '/assets/editorial/2019/rest-api-contract-fixture-2019.svg', excerpt: 'В API «всё работает», пока UI не получает 200 без nextCursor или 400 в чужом формате. Собираем три воспроизводимых response-фикстуры, отделяем их от запроса к стенду и готовим маршрут интеграционной проверки.', readingMinutes: 13, }, [ paragraph('Симптом на интеграции прост: frontend получает ответ, JSON успешно распарсился, но следующий экран уже не знает, что делать. В одном релизе последняя страница приходит без nextCursor, в другом backend отдаёт HTML от proxy вместо problem document, в третьем optional customer становится null. Цена — не только падение компонента. Клиент может считать данные окончательными, показать неверный текст ошибки или повторять запрос, который никогда не станет успешным.'), paragraph('Ниже — полевой сценарий для маленького контракта GET /api/v1/orders. Он строится вокруг двух вещей: воспроизводимого HTTP-запроса, который следует выполнить на разрешённом тестовом URL, и локальной fixture, которую можно прогнать без сети. Важно не перепутать их. Фикстура доказывает, что наши правила отличают допустимое тело от недопустимого. Она не доказывает, что сервер, gateway, авторизация и production уже ведут себя так же.'), heading('Собираем симптомы в проверяемые случаи'), paragraph('Сначала вырезаем из инцидента общие слова. «Пагинация сломалась» превращается в утверждение: у 200 application/json обязан быть объект page, а у него — ключ nextCursor, равный строке или null. «Ошибка непонятна» превращается в другое утверждение: у 400 ожидается application/problem+json, поле status совпадает с HTTP-кодом, а локальный errors[0].code даёт UI стабильный повод для действия.'), paragraph('Такая декомпозиция позволяет тестировать не весь сервис, а границу, которая уже известна из сбоя. Для выборки заказов хватит трёх cases: корректная последняя страница с nextCursor: null; корректный problem document для плохого cursor; намеренно испорченная страница, где cursor пропал. Третий case особенно полезен: если проверка его принимает, тест на деле проверяет лишь наличие JSON и не защищает договор.'), dataTable( 'Минимальная матрица контрактной fixture', ['Case', 'Статус и Content-Type', 'Ключевое ожидание', 'Ожидаемый итог'], [ ['Последняя страница', '200 / application/json', 'page.nextCursor присутствует и равен null', 'Принять ответ'], ['Недопустимый cursor', '400 / application/problem+json', 'status совпадает; errors[0].code равен invalid_cursor', 'Принять управляемую ошибку'], ['Регрессия пагинации', '200 / application/json', 'Ключ nextCursor отсутствует', 'Отклонить ответ с понятной причиной'], ['Случай customer', '200 / application/json', 'customer отсутствует либо является объектом, но не null', 'Принять или отклонить строго по схеме'], ], ), heading('Фикстура должна проверять статус до тела'), paragraph('Порядок проверок важен. Нельзя сначала читать body.items, а затем мимоходом заметить, что статус был 400. Так код начинает парсить ошибку как список и рождает вторичную ошибку вроде «map is not a function». В fixture сначала сравниваются статус и content-type, потом только форма соответствующего тела. Для success нужен 200 и JSON. Для known validation error нужен 400 и problem+json. Неизвестный статус оставляем нераспознанным, чтобы интерфейс показал общий сбой и команда увидела новый случай.'), codeBlock([ 'function assertOrdersPage(response) {', ' assert(response.status === 200, "ожидался HTTP 200");', ' assert(mediaType(response.headers["content-type"]) === "application/json", "ожидался JSON");', ' assert(Array.isArray(response.body.items), "items должен быть массивом");', ' assert(response.body.page, "page обязателен");', ' assert(Object.prototype.hasOwnProperty.call(response.body.page, "nextCursor"),', ' "page.nextCursor должен присутствовать");', ' assert(response.body.page.nextCursor === null ||', ' typeof response.body.page.nextCursor === "string",', ' "nextCursor должен быть строкой или null");', '}', ]), paragraph('Этот фрагмент намеренно не делает сетевой запрос. response — обычный объект с status, headers и body. В полном пакете запускается та же идея: проверка принимает локальную финальную страницу, принимает 400 problem detail и убеждается, что сама отвергает 200 без nextCursor. Если такой negative case вдруг проходит, мы знаем, что защита ослабла до подключения API. Это корректный результат unit-level fixture, а не отчёт об endpoint.'), heading('Различаем optional, null и неизвестное поле'), paragraph('В реальной выдаче часто спорят о customer: пользователю без привязанного профиля объект не нужен, но UI может захотеть написать «клиент не указан». Это не повод разрешить все представления сразу. В выбранном договоре customer необязателен. Если ключ отсутствует, экран выбирает запасной текст. Если ключ присутствует, он обязан быть объектом с id и name. Значение null считается нарушением, потому что добавляет третью ветку без продукта и без причины.'), paragraph('Это правило не универсально. Другая команда может сделать customer обязательным и nullable, если null имеет отдельный бизнес-смысл. Тогда schema и fixture должны принять null, а интерфейс — назвать его. Плохой вариант один: backend меняет отсутствие на null «потому что так сериализатор отдал», а frontend должен догадаться. Contract test ценен тем, что такое изменение становится красным до того, как попадёт в карточку.'), codeBlock([ 'function assertCustomer(order) {', ' var hasCustomer = Object.prototype.hasOwnProperty.call(order, "customer");', ' if (!hasCustomer) return;', '', ' assert(order.customer && typeof order.customer === "object",', ' "customer при наличии должен быть объектом, не null");', ' assert(typeof order.customer.id === "string", "customer.id обязателен");', ' assert(typeof order.customer.name === "string", "customer.name обязателен");', '}', ]), paragraph('Проверка не обязана быть сложной библиотекой schema validation. В 2019 маленькая функция на assert часто полезнее, когда она живёт рядом с тремя fixtures и легко читается разработчиком обоих слоёв. Позже её можно заменить валидатором на основе OpenAPI, но только после сравнения поддержки версии Schema Object. Цель текущего теста скромнее: удержать реально важные условия — статус, media type, required-поля и смысл optional-поля.'), figure( '/assets/editorial/2019/rest-api-contract-fixture-2019.svg', 'Вертикальная схема контрактной проверки: локальные response fixtures проходят сначала через проверку HTTP-статуса и Content-Type, затем через схему success или problem; отдельная красная ветка показывает 200 без nextCursor, который должен быть отвергнут. Справа отмечен отдельный последующий запрос к тестовому стенду.', 'Фикстура проверяет договор на заранее заданных данных. Реальный HTTP-запрос — следующий независимый этап, поэтому диаграмма не выдаёт локальный тест за production-проверку.', ), heading('Добавляем воспроизводимый запрос, но не выдумываем его результат'), paragraph('После fixture берём разрешённый тестовый host и выполняем один запрос к первой странице. Команда ниже сохраняет заголовки и тело отдельно. Это важно: status и Content-Type видны в headers, а body можно показать в ревью без шума curl. В примере нет токена, cookies и адреса production. Подставлять их в статью, коммит или CI-лог нельзя; для закрытого API команда должна использовать безопасный тестовый способ аутентификации и скрытие секретов.'), codeBlock([ '# Выполнять только на разрешённом тестовом URL и с безопасной авторизацией.', 'curl -sS -D /tmp/orders.headers -o /tmp/orders.json \\', ' -H "Accept: application/json" \\', ' "https://api.example.test/api/v1/orders?limit=2"', '', 'grep -Ei "^(HTTP/|content-type:)" /tmp/orders.headers', 'node -e "const fs=require(\\"fs\\"); const body=JSON.parse(fs.readFileSync(\\"/tmp/orders.json\\")); console.log(body.page)"', ]), paragraph('Этот запрос надо читать по шагам. Сначала сверяем фактический HTTP-код. Затем Content-Type; заголовок application/json; charset=utf-8 может содержать параметры, поэтому production-парсер должен сравнивать media type корректно, а не полную строку, если сервер его допускает. Потом смотрим наличие items, page и nextCursor. Если ответ — 400, мы не запускаем проверку success, а сравниваем problem document с отдельной веткой. Результат фиксируем как запись факта, не как «API работает».'), heading('Связываем fixture с OpenAPI-описанием'), paragraph('У теста не должно быть второго тайного контракта. Перед запуском сверяем его условия с OpenAPI: 200 указывает на OrdersPage, 400 — на Problem, required содержит items и page, а nullable у nextCursor разрешает только null помимо string. Если fixture и YAML расходятся, сначала решаем, какой из них описывает продукт, и исправляем один источник. Нельзя чинить тест под случайный текущий ответ сервера и оставить спецификацию прежней: это вернёт спор в следующем релизе.'), dataTable( 'Порядок диагностики при расхождении теста и сервера', ['Наблюдение', 'На что указывает', 'Проверка', 'Действие'], [ ['Fixture падает на локальном положительном case', 'Ошибка в тесте или собственном примере', 'Сверить fixture с зафиксированным schema', 'Исправить тест/пример до сетевого запуска'], ['Fixture принимает отрицательный case', 'Контракт не защищён от известной регрессии', 'Добавить конкретное assertion', 'Не продолжать с зелёным, но пустым тестом'], ['Тестовый сервер отдаёт другой status', 'Нарушен Responses contract или выбран иной сценарий', 'Сохранить headers и запрос без секретов', 'Согласовать изменение или исправить endpoint'], ['Status верный, тело другое', 'Сериализация/schema не совпали', 'Сравнить required, nullable и Content-Type', 'Исправить schema или mapper и повторить запрос'], ], ), heading('Маршрут от локального случая к интеграционной проверке'), orderedList([ 'Возьмите один пользовательский сбой и выразите его в одном проверяемом условии ответа.', 'Добавьте успешную fixture, управляемую ошибку и отрицательный case, который обязан завершиться с ошибкой проверки.', 'Сначала проверяйте HTTP-статус и media type, затем schema соответствующего тела.', 'Зафиксируйте одно правило optional-поля и добавьте его в fixture и OpenAPI одновременно.', 'Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.', 'Если результат расходится, не подгоняйте UI: сначала укажите, какая строчка контракта изменилась, и согласуйте переход.', ]), heading('Что этот сценарий не обещает'), paragraph('Фикстура не измеряет latency, не проверяет права, не запускает gateway и не подтверждает, что cursor защищён от перебора. Она также не заменяет end-to-end сценарий, где UI действительно нажимает «ещё». Это сознательная граница: один быстрый локальный тест должен ловить разрыв контракта раньше, а серверный и браузерный уровни подтверждают другие свойства. Объявить fixture production-тестом означало бы скрыть эти пробелы, а не уменьшить риск.'), paragraph('Зато сценарий даёт команде чёткий предметный артефакт. Когда следующий change удалит page.nextCursor, поставит другой Content-Type или заменит optional object на null, можно показать конкретный case и конкретный пункт спецификации. Для автора 2019 года это уже не «проверим руками после релиза», а аккуратный мост от фронтенд-обработки ответа к договору backend и HTTP.'), heading('Короткий вывод'), bulletList([ 'Contract fixture должна принимать ожидаемые cases и обязательно отвергать известный плохой ответ.', 'Проверка начинается со status и Content-Type; одинаковый JSON не делает 200 и 400 взаимозаменяемыми.', 'Отсутствующее optional-поле и null — разные данные, если команда не зафиксировала обратное.', 'Локальные response objects полезны до сети, но тестовый запрос к серверу остаётся отдельным и честно названным этапом.', ]), ], [rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema], ); export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; if (process.argv.includes('--print-revisions')) { process.stdout.write(JSON.stringify(revisions)); } else if (process.argv.includes('--run-fixture')) { process.stdout.write(JSON.stringify(runContractFixture(), null, 2) + '\n'); }