JSON Toolkit

Чому автоматично згенерована JSON Schema постійно відхиляє реальні дані

Головне: Згенеровані схеми часто блокують валідні дані. Дізнайтеся, чому багатші зразки дають гірші схеми, а потім робочий процес: вибірка з кількох записів, семантичне доповнення, локалізація помилок.

Середній~14 хв читанняОпубліковано 2026-10-30Оновлено 2026-10-30
Обкладинка, що ілюструє конфлікти між чернеткою JSON-у-Schema і реальними даними

Коротко: спершу зведіть межу required/optional вручну — подайте 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, ніколи «може бути null»
[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 до справжнього набору обовʼязкових, — 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 і один порожній масив; не дозволяйте жодній формі бути повністю відсутньою.

Конкретно:

  1. Витягніть 5–20 відповідей із логів, моків або реальних запитів і обовʼязково включіть аномальні рядки (той із порожніми полями, той із null, той із порожнім масивом);
  2. Зберіть їх у масив JSON і вставте в генератор;
  3. Вимкніть «усе обовʼязкове» й оберіть чернетку 2020-12;
  4. Результат — type: array плюс items; візьміть секцію items як контракт одного запису;
  5. Одразу перевірте оригінальні зразки цією схемою — 100% мусять пройти. Якщо хоч один зазнає збою, вашої вибірки не досить; поверніться й додайте записи.

Крок 5 — найдешевша самоперевірка. Якщо генератор не може вмістити навіть дані, які ви йому подали, ваш метод вибірки неправильний, і йти далі немає сенсу.

5. Чотири речі, які генератор не може заповнити — їх треба писати вручну

Навіть після подання кількох записів наступне все ще не виводиться й має бути додане вручну:

Відсутній шматок Виміряна поведінка Наслідок Як виправити
Формат рядка "a@b.com" дає лише {"type": "string"} Перевірка "not an email" проходить Додайте "format": "email" і ввімкніть перевірку форматів
Діапазони й переліки Не бачить, що score обмежений 0–100, а status має лише три значення Відʼємні числа, величезні значення й сміттєві значення переліку всі проходять Додайте minimum / maximum / enum
Політика невідомих полів additionalProperties не генерується Неправильно написаний ключ мовчки приймається, що небезпечніше за помилку Додайте "additionalProperties": false для файлів у стилі конфігурації
Семантика nullability і порожніх колекцій Один зразок із null фіксує {"type": "null"}; порожній масив дає "items": {} Валідні значення відхиляються, або елементи масиву цілком необмежені Змініть на ["string", "null"] і дайте порожнім масивам справжні елементи

Третій рядок вартий окремої згадки: типове значення additionalProperties — «дозволити». Це означає, що конфігураційний файл, який пише timeout як timeuot, проходить перевірку, а потім мовчки нічого не робить під час виконання. Для схем у стилі конфігурації це пункт, який ви мусите явно вимкнути.

format теж має пастку: від чернетки 2019-09 format належить словнику анотацій, і за замовчуванням він лише анотує, а не забезпечує. Якщо ви додали format: "email", а недопустимі значення все одно проходять, у восьми випадках із десяти валідатор не ввімкнув validateFormats або відповідний плагін формату — не те, що схема неправильна.

Нотатка про вибір чернетки, поки ми тут: якщо схема йде в OpenAPI 3.1, вона мусить бути чернеткою 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);

/** Convert the ajv error array into { fieldPath: [message] } for form highlighting */
function groupByField(errors) {
  const grouped = {};

  for (const error of errors) {
    // For required and additionalProperties, the offending field lives in params, not in 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:

  1. Вибірка: витягніть 5–20 реальних відповідей, навмисно обираючи ті з порожніми полями, null, порожніми масивами й зайвими полями;
  2. Виведення: вставте їх разом як масив у генератор, вимкніть «усе обовʼязкове» й візьміть items;
  3. Доповніть семантику: додайте format, enum, minimum/maximum, minItems і additionalProperties пункт за пунктом;
  4. Самоперевірка: оригінальні зразки мусять пройти на 100%, інакше поверніться до кроку 1 і додайте зразки;
  5. Негативний доказ: підготуйте вхід «має зазнати збою» для кожного обмеження й підтвердьте, що він справді зазнає збою — перевіряти лише шлях успіху те саме, що не перевіряти;
  6. Коміт: покладіть схему під контроль версій, залиште збійні зразки як тест-кейси й запускайте тести насамперед щоразу, коли 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);
    // Also assert the reason, so the test doesn't pass because some other field failed
    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, мусить бути обіцяним контрактом.