Почему автогенерируемая JSON Schema продолжает отвергать реальные данные
Главное: Сгенерированные схемы часто блокируют валидные данные. Узнайте, почему более богатые образцы дают худшие схемы, затем workflow: выборка по нескольким записям, семантическое дополнение, точное указание ошибок.

Главное: сначала вручную сойдитесь на границе required/необязательного — подайте 5–20 записей разной формы с выключенным «всё обязательно» — затем генерируйте схему и вручную добавьте format, enum и additionalProperties; один образец со всеми полями всегда будет слишком строгим.
Генератор видит только одну запись, которую вы вставили. Он читает «у этой записи есть email» как «у каждой записи должен быть email», поэтому чем полнее ваши образцы, тем больше он требует.
1. Генератор делает структурное отображение, а не понимание бизнеса
Дайте кусок JSON генератору, и он делает ровно одно: обходит структуру значений и переводит каждый узел в ключевые слова схемы. Вся таблица отображения фиксирована:
| Значение в образце | Выводимый тип | Примечания |
|---|---|---|
"hello" |
{"type": "string"} |
Не различает email, дату, UUID |
42 |
{"type": "number"} |
Целое не выводится как integer |
3.14 |
{"type": "number"} |
То же |
true |
{"type": "boolean"} |
— |
null |
{"type": "null"} |
Распознаёт только null, никогда «nullable» |
[1, "a"] |
{"type": "array", "items": {"type": ["number", "string"]}} |
Типы элементов объединяются |
{"a": 1} |
{"type": "object", "properties": {...}, "required": [...]} |
required — вот загвоздка |
Вся проблема в последней строке. required не выводится из типов; он выводится из «этот ключ существует в этом образце». Когда образец — одна запись, это утверждение эквивалентно «каждый ключ обязателен».
А данные, возвращаемые реальными API, почти всегда имеют половину полей необязательными. Вот где начинается конфликт.
2. Пять записей одной формы, пять уровней принятия
Начните с измерений. Любой, глядя на пять записей ниже, скажет «это одна форма»:
[
{
"id": 1,
"name": "Ada",
"email": "ada@example.com",
"tags": ["a"],
"score": 10
},
{ "id": 2, "name": "Bob", "tags": [], "score": 7 },
{
"id": 3,
"name": "Cy",
"email": "cy@example.com",
"tags": ["a", "b"],
"score": 8.5
},
{ "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
{
"id": 5,
"name": "Eve",
"email": "eve@example.com",
"tags": ["d"],
"score": 6,
"admin": true
}
]
email появляется в 3 из 5 записей, admin появляется только один раз, а у записи 2 пустой массив tags.
Теперь генерируйте схему из каждой записи отдельно, затем используйте каждый результат для валидации всех пяти записей (валидатор: ajv 8):
| Сгенерировано из | Число ключей | Сгенерированный required |
Принимает из 5 |
|---|---|---|---|
| Запись 1 | 5 | id, name, email, tags, score |
3 записи |
| Запись 2 | 4 | id, name, tags, score |
5 записей |
| Запись 3 | 5 | id, name, email, tags, score |
3 записи |
| Запись 4 | 5 | id, name, email, tags, score |
1 запись |
| Запись 5 | 6 | id, name, email, tags, score, admin |
1 запись |
Ни одна из пяти схем не корректна, но единственная, принимающая все данные, сгенерирована из записи 2 — записи, в которой отсутствует email. Она выигрывает по простой причине: ключи, которые никогда не появляются, никогда не записываются в required.
Запись 4 ещё поучительнее: её значение email равно null, поэтому генератор закрепляет {"type": "null"}, и остальные четыре записи со строковыми email все отвергаются.
3. Почему «вставить самую полную запись» — худший выбор
Интуиция — «вставьте образец с наибольшим числом полей, и схема будет полной». Измерения показывают прямо обратное — и третья сторона провела более крупный контролируемый тест.
absurdtools использовал 3000 случайных «семейств данных», по 8 записей на семейство, с каждым необязательным полем, присутствующим примерно в половине случаев, и измерил, сколько данных может принять схема, сгенерированная из образцов с разным числом ключей:
| Число ключей в образце | Доля данных семейства, принятая |
|---|---|
| 2 | 100.0% |
| 3 | 56.8% |
| 4 | 34.8% |
| 5 | 23.4% |
| 6 | 17.7% |
| 7 | 14.9% |
Вывод — не «небольшой спад», а полный разворот: минимальный образец дал самую свободную схему в 95% семейств, тогда как самый полный образец достиг этого только в 0.1% случаев.
Причина в семантике required, которая означает «все»; необязательность можно наблюдать только через отсутствие. Вставка записи со всеми полями говорит генератору «ни один из этих ключей нельзя опустить»; вставка записи с отсутствующим полем даёт ему доказательство, что «этот ключ может отсутствовать».
Запомните одним предложением: ценность образца — не полнота, а то, какие отсутствия он покрывает.
Сколько записей достаточно
Та же партия экспериментов также измерила скорость сходимости: накапливая записи одну за другой по правилу «ключ остаётся required, только если каждая запись до сих пор его имеет», медианное число записей для сходимости required к истинному обязательному набору — 3; 6 записей покрывают около девяноста процентов семейств, а 12 покрывают все.
Поэтому выборка 5–10 записей изо дня в день — лучшее соотношение усилий и результата; заставлять двадцать или больше даёт очень мало дополнительного.
Но есть предпосылка: эти записи должны приходить из действительно разных ситуаций. Пять записей, скопированных из одного шаблона, перестают давать новую информацию со второй — их наборы полей идентичны, и required не может сузиться ни на один ключ.
4. Исправление: подайте несколько записей сразу и дайте отсутствиям говорить
Поскольку выборка по одной записи неизбежно слишком строга, правильный подход — дать генератору посмотреть на несколько записей сразу и сойтись required по правилу «обязательно, только если есть в каждой записи».
Генератор на этом сайте принимает массив напрямую и имеет переключатель «всё обязательно». Выключите его, и он решает по числу вхождений: только ключи, которые есть в каждой записи, записываются в required.
Всё те же пять записей — на этот раз объедините их в массив и выводите вместе, затем возьмите items как контракт элемента:
| Подано записей | Выведенный required |
Валидация всех 5 |
|---|---|---|
| 1 запись | id, name, email, tags, score |
3 проходят |
| 2 записи | id, name, tags, score |
4 проходят |
| 3 записи | id, name, tags, score |
4 проходят |
| 4 записи | id, name, tags, score |
5 проходят |
| 5 записей | id, name, tags, score |
5 проходят |
required сходится, как только добавлена четвёртая запись, и не меняется в дальнейшем. email и admin правильно помещены в properties, но не в required. И поскольку email записи 4 равен null, вывод по нескольким записям объединяет его в {"type": ["string", "null"]}, так что и строки, и null проходят — то, чего выборка по одной записи никогда не может.
Это тот же порядок величины, что вывод absurdtools «required сходится после медианных 3 записей».
Однако у вывода по нескольким записям есть свои слепые зоны: если email в этих записях случайно равен null в каждой (скажем, все они из аккаунтов без заполненного профиля), результат вывода станет {"type": "null"}, и строковые email всё равно отвергаются. Аналогично, если каждое значение tags — пустой массив, тип элемента вообще не может быть выведен.
Поэтому у выборки есть ещё одно жёсткое требование: каждая форма каждого поля должна появиться хотя бы один раз. Принесите одну строку, одно число, один null и один пустой массив; не дайте ни одной форме полностью отсутствовать.
Конкретно:
- Вытяните 5–20 ответов из логов, моков или реальных запросов и обязательно включите аномальные строки (ту с пустыми полями, ту с null, ту с пустым массивом);
- Соберите их в массив JSON и вставьте в генератор;
- Выключите «всё обязательно» и выберите драфт 2020-12;
- Результат —
type: arrayплюсitems; возьмите секциюitemsкак контракт для одной записи; - Сразу валидируйте исходные образцы этой схемой — 100% должны проходить. Если хоть один падает, выборки недостаточно; вернитесь и добавьте записи.
Шаг 5 — самая дешёвая самопроверка. Если генератор не может даже вместить данные, которые вы ему дали, ваш метод выборки неверен, и нет смысла идти дальше.
5. Четыре вещи, которые генератор не может заполнить — вы должны написать их вручную
Даже после подачи нескольких записей следующее всё ещё не может быть выведено и должно быть добавлено вручную:
| Отсутствующий кусок | Измеренное поведение | Последствие | Как исправить |
|---|---|---|---|
| Формат строки | "a@b.com" производит только {"type": "string"} |
Валидация "not an email" проходит |
Добавьте "format": "email" и включите проверки формата |
| Диапазоны и перечисления | Не может видеть, что score ограничен 0–100, ни что у status только три значения |
Отрицательные числа, огромные значения и мусорные значения перечислений все проходят | Добавьте minimum / maximum / enum |
| Политика неизвестных полей | additionalProperties не генерируется |
Опечатанный ключ молча принимается, что опаснее ошибки | Добавьте "additionalProperties": false для файлов в стиле конфигурации |
| Семантика nullable и пустых коллекций | Один образец с null закрепляет {"type": "null"}; пустой массив производит "items": {} |
Валидные значения отвергаются, или элементы массива полностью не ограничены | Смените на ["string", "null"] и дайте пустым массивам реальные элементы |
Третья строка заслуживает отдельного упоминания: значение по умолчанию additionalProperties — «разрешать». Это означает, что конфигурационный файл, пишущий timeout как timeuot, проходит валидацию и затем молча ничего не делает во время выполнения. Для схем в стиле конфигурации это пункт, который вы обязаны явно отключить.
У format тоже есть ловушка: с драфта 2019-09 format принадлежит словарю аннотаций и по умолчанию только аннотирует, а не проверяет. Если вы добавили format: "email", а нелегальные значения всё равно проходят, в восьми случаях из десяти валидатор не включил validateFormats или соответствующий плагин форматов — это не схема неверна.
Замечание о выборе драфта, раз мы здесь: если схема идёт в OpenAPI 3.1, она должна быть draft 2020-12, потому что 3.1 встраивает эту версию напрямую; старые инструментальные цепочки (экосистема Swagger 2.0 и некоторые реализации $ref) всё ещё центрируются на draft-07. Два ведут себя по-разному для соседних ключевых слов $ref и для ключевых слов, связанных с массивами, поэтому не смешивайте их в одном наборе схем.
6. Для недокументированных сторонних API реконструируйте контракт по образцам ответов
Интеграция стороннего колбэка, работа с партнёром, который не пишет документацию, или поддержание десятилетней наследуемой системы — всё это означает «данные в руках, спецификации нет». Здесь генератор — не просто удобство; это самый быстрый путь к контракту.
Метод тот же, что выше, с двумя дополнительными обязанностями:
Первое: выборка по времени и по состояниям. Сторонние поля часто меняются с бизнес-состоянием — промоакция добавляет discount, заказ с возвратом добавляет refundAt, а ответ об ошибке вообще не имеет data. Захват одного снимка означает видеть только одно состояние.
Подайте несколько успехов, несколько сбоев и несколько пустых результатов вместе: error и data часто появляются взаимоисключающе, и вывод по нескольким записям помещает оба в properties, оставляя их вне required — точно соответствует реальности.
Второе: храните схему в репозитории и прикрепите ежедневную проверку. Валидируйте сегодняшние реальные ответы по ней, и вы узнаете о молча исчезающих или меняющих тип полях раньше своих пользователей.
// Запускать раз в день: оповещение о дрейфе контракта
const drifted = records.filter((record) => !validate(record));
if (drifted.length > 0) {
console.warn(
`${drifted.length} responses do not match the contract; first difference:`,
validate.errors,
);
}
Ценность этой практики — не «экономия усилий на ручном написании», а превращение словесной договорённости в исполняемое оповещение. Третья сторона никогда не сообщает заранее, что изменила поле, но изменение обязательно появится в ваших логах.
Одну границу стоит очертить ясно: реконструированная схема описывает, что другая сторона возвращает сейчас, а не что она обещает возвращать. Первое — для мониторинга; только второе принадлежит контракту или документации API.
7. После завершения заставьте ошибки валидации указывать обратно на конкретные поля
Когда схема пригодна, следующая забота — читаемые ошибки. Объект ошибки ajv имеет пять полей, и первые четыре полезны:
| Поле | Значение | Пример (id: 0, нарушающий minimum: 1) |
|---|---|---|
instancePath |
JSON Pointer к проблемному значению в данных | "/id" |
schemaPath |
Местоположение сработавшего правила в схеме | "#/properties/id/minimum" |
keyword |
Имя провалившегося ограничения | "minimum" |
params |
Машиночитаемые детали этого ограничения | { "comparison": ">=", "limit": 1 } |
message |
Английская проза, меняющаяся между версиями, не парсите её | "must be >= 1" |
Одна асимметрия легче всего спотыкает: когда падает required, instancePath указывает на родительский объект (обычно пустая строка, т.е. корень), а не на отсутствующее поле. Имя отсутствующего ключа в params.missingProperty. Аналогично, когда падает additionalProperties, имя лишнего ключа в params.additionalProperty.
Поэтому при отображении ошибок на «поле → сообщение» сначала обрабатывайте эти два особых случая:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
/** Преобразовать массив ошибок ajv в { fieldPath: [message] } для подсветки формы */
function groupByField(errors) {
const grouped = {};
for (const error of errors) {
// Для required и additionalProperties проблемное поле живёт в params, а не в instancePath
const field =
error.keyword === "required"
? error.params.missingProperty
: error.keyword === "additionalProperties"
? error.params.additionalProperty
: error.instancePath.replace(/^\//u, "").split("/").join(".") ||
"(root)";
(grouped[field] ??= []).push(error.message);
}
return grouped;
}
if (!validate(data)) console.log(groupByField(validate.errors));
allErrors: true не является опциональным. При конфигурации по умолчанию ajv останавливается на первой ошибке, поэтому пользователь исправляет одно поле, отправляет повторно, получает следующую ошибку и нуждается в трёх раундах только чтобы заполнить одну форму. Если вы также используете ajv-errors для пользовательских сообщений, без этого флага плагин молча заменит лишь часть сообщений.
8. Многократно используемый процесс раскатки
Сшейте шаги выше в фиксированную рутину и следуйте ей каждый раз, когда интегрируете новый API:
- Выборка: вытяните 5–20 реальных ответов, намеренно выбирая те с пустыми полями, null, пустыми массивами и лишними полями;
- Вывод: вставьте их вместе как массив в генератор, выключите «всё обязательно» и возьмите
items; - Дополните семантику: добавьте
format,enum,minimum/maximum,minItemsиadditionalPropertiesпо одному пункту; - Самопроверка: исходные образцы должны проходить на 100%, иначе вернитесь к шагу 1 и добавьте образцы;
- Негативное доказательство: подготовьте «должно падать» ввод для каждого ограничения и подтвердите, что оно действительно падает — тестирование только проходящего пути равносильно отсутствию тестирования;
- Коммит: поместите схему в контроль версий, держите падающие образцы как тестовые случаи и запускайте тесты первыми всякий раз, когда API меняет поле.
Негативное тестирование шага 5 легче всего пропустить. Добавление ключа в required без когда-либо проверки, что отсутствующий ключ даёт ошибку, означает ставку на то, что валидатор согласен с вашей мысленной моделью.
import { describe, expect, it } from "vitest";
describe("user schema", () => {
it("accepts a complete record", () => {
expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
});
it("rejects a record without name", () => {
expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
// Также проверьте причину, чтобы тест не проходил из-за сбоя другого поля
expect(
validate.errors?.some((e) => e.params?.missingProperty === "name"),
).toBe(true);
});
});
Последнее утверждение важно. Проверка только false может оставаться зелёной даже после того, как схема была сломана — сбой к тому времени может происходить от совершенно другой причины.
9. Вопросы и ответы
Может ли схема, которую производит генератор, сразу идти в продакшен
Нет. Это только снимок структуры. Вы должны как минимум добавить форматы, диапазоны, перечисления и политику неизвестных полей, а затем прогнать через неё и исходные образцы, и негативные образцы. Генератор решает «печатать properties вручную утомительно», а не «он решает бизнес-правила».
О системе ключевых слов написания схем, разделении $ref и ключевых словах композиции см. JSON Schema: от основ до продакшена.
Почему запись с наибольшим числом полей отвергает больше данных
Потому что required собирается из «что появилось в образце». Образец со всеми полями превращает каждый необязательный ключ в обязательный, тогда как большинство этих ключей отсутствует в реальных данных. Подача нескольких записей или записи, которая намеренно опускает поля, приближает вас к истине больше, чем самый полный образец.
Будет ли целое выведено как integer
Генератор на этом сайте этого не делает: и целые, и числа с плавающей точкой выводятся как number, поэтому схема, сгенерированная из {"price": 5}, принимает 5.99. Это избегает аварий вроде «образец случайно был целым, поэтому продакшен отвергает цены с десятичными дробями». Некоторые сторонние инструменты действительно выводят integer из значения, поэтому будьте особенно осторожны с полями денег.
О точности чисел и границах типов см. Шесть типов данных JSON и ловушки точности чисел.
Я добавил format, но он не действует
С драфта 2019-09 format по умолчанию только аннотация. Либо явно включите проверки формата в валидаторе (ajv нужен ajv-formats), либо используйте pattern как жёсткое ограничение. Не предполагайте, что добавление его что-либо заблокирует.
required сообщает об ошибке, но поле явно присутствует в данных
Смотрите сначала instancePath. Ошибка required указывает на родительский объект, что означает, что отсутствующий ключ не на этом уровне — частые причины: неверный уровень вложенности (/profile/name не то же, что name в корне), различающийся регистр (userId против userID) или сериализация вышестоящей системы, полностью отбрасывающая undefined.
Перед расследованием подтвердите, что сам документ — валидный JSON: комментарии, висячие запятые и подобные проблемы падают на этапе парсинга, до того как схема получает шанс. Парсинг и форматирование JSON указывает прямо на проблемную строку.
Сколько записей выбирать и какой драфт использовать?
Выборка 5–10 записей изо дня в день даёт лучший результат; required сходится на медианных 3 записях, а 12 записей покрывают каждое семейство. Каждая запись должна приходить из действительно другой ситуации, и каждая форма каждого поля должна появиться хотя бы один раз. Закрепите драфт на 2020-12, потому что OpenAPI 3.1 принимает только эту версию.
Почему снисходительное значение по умолчанию additionalProperties опаснее?
Значение по умолчанию не испускает ключевое слово вовсе, поэтому опечатанный ключ молча принимается, проходит валидацию и затем падает во время выполнения — труднее диагностировать, чем прямую ошибку. Схемы в стиле конфигурации должны устанавливать "additionalProperties": false явно, тогда как схемы API должны сначала взвесить цену совместимости.
Стоит ли использовать реконструированную схему как жёсткий шлюз?
Нет. Реконструированная схема описывает, что другая сторона возвращает сейчас, а не что она обещает возвращать, поэтому она принадлежит мониторингу и оповещениям о дрейфе. То, что идёт в контракт или документацию API, должно быть обещанным контрактом.