Dlaczego automatycznie wygenerowany JSON Schema odrzuca prawdziwe dane
Podsumowanie: Wygenerowane schematy często blokują poprawne dane. Dowiedz się, dlaczego bogatsze próbki dają gorsze schematy, a potem przejdź przez proces: próbkowanie wielu rekordów, uzupełnienie semantyki, precyzyjne wskazywanie błędów.

Najważniejsze: najpierw ręcznie uzgodnij granicę wymagane/opcjonalne — podaj 5–20 rekordów o różnych kształtach z wyłączonym „wszystkie wymagane” — potem wygeneruj schemat i dopisz format, enum i additionalProperties ręcznie; pojedyncza próbka z pełnymi polami zawsze będzie zbyt ścisła.
Generator widzi tylko ten jeden rekord, który wkleiłeś. Czyta „ten rekord ma e-mail” jako „każdy rekord musi mieć e-mail”, więc im pełniejsze próbki, tym więcej wymaga.
1. Generator robi odwzorowanie strukturalne, a nie zrozumienie biznesowe
Podaj fragment JSON-a generatorowi, a zrobi dokładnie jedną rzecz: przejdzie strukturę wartości i przetłumaczy każdy węzeł na słowa kluczowe schematu. Cała tabela odwzorowania jest stała:
| Wartość w próbce | Wnioskowany typ | Uwagi |
|---|---|---|
"hello" |
{"type": "string"} |
Nie rozróżnia e-maila, daty, UUID |
42 |
{"type": "number"} |
Liczba całkowita nie jest wnioskowana jako integer |
3.14 |
{"type": "number"} |
Jak powyżej |
true |
{"type": "boolean"} |
— |
null |
{"type": "null"} |
Rozpoznaje tylko null, nigdy „nullable” |
[1, "a"] |
{"type": "array", "items": {"type": ["number", "string"]}} |
Typy elementów są łączone w unię |
{"a": 1} |
{"type": "object", "properties": {...}, "required": [...]} |
required to sedno |
Cały kłopot tkwi w ostatnim wierszu. required nie jest wyprowadzane z typów; jest wyprowadzane z „ten klucz istnieje w tej próbce”. Gdy próbka to pojedynczy rekord, to stwierdzenie jest równoważne „każdy klucz jest obowiązkowy”.
A dane zwracane przez prawdziwe API niemal zawsze mają połowę pól opcjonalnych. Tu zaczyna się konflikt.
2. Pięć rekordów o tym samym kształcie, pięć wskaźników akceptacji
Zacznij od pomiarów. Każdy, kto patrzy na pięć poniższych rekordów, powiedziałby „to ten sam kształt”:
[
{
"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 występuje w 3 z 5 rekordów, admin tylko raz, a rekord 2 ma pustą tablicę tags.
Teraz wygeneruj schemat z każdego rekordu osobno, a potem użyj każdego wyniku do walidacji wszystkich pięciu rekordów (walidator: ajv 8):
| Wygenerowany z | Liczba kluczy | Wygenerowane required |
Akceptuje z 5 |
|---|---|---|---|
| Rekord 1 | 5 | id, name, email, tags, score |
3 rekordy |
| Rekord 2 | 4 | id, name, tags, score |
5 rekordów |
| Rekord 3 | 5 | id, name, email, tags, score |
3 rekordy |
| Rekord 4 | 5 | id, name, email, tags, score |
1 rekord |
| Rekord 5 | 6 | id, name, email, tags, score, admin |
1 rekord |
Żaden z pięciu schematów nie jest poprawny, ale jedyny, który akceptuje wszystkie dane, pochodzi z rekordu 2 — rekordu, w którym brakuje email. Wygrywa z prostego powodu: klucze, które nigdy nie występują, nigdy nie trafiają do required.
Rekord 4 jest jeszcze bardziej pouczający: jego wartość email to null, więc generator twardo koduje {"type": "null"}, a pozostałe cztery rekordy z łańcuchowymi e-mailami są odrzucane.
3. Dlaczego „wklej najpełniejszy rekord” to najgorszy wybór
Intuicja mówi „wklej próbkę z największą liczbą pól, a schemat będzie kompletny”. Pomiary pokazują dokładnie odwrotnie — a strona trzecia przeprowadziła większy test kontrolowany.
absurdtools użyło 3000 losowych „rodzin danych”, 8 rekordów na rodzinę, przy czym każde pole opcjonalne występowało mniej więcej w połowie przypadków, i zmierzyło, ile danych może zaakceptować schemat wygenerowany z próbek o różnej liczbie kluczy:
| Liczba kluczy w próbce | Udział danych rodziny zaakceptowanych |
|---|---|
| 2 | 100,0% |
| 3 | 56,8% |
| 4 | 34,8% |
| 5 | 23,4% |
| 6 | 17,7% |
| 7 | 14,9% |
Wniosek to nie „lekki spadek”, ale całkowite odwrócenie: minimalna próbka produkowała najluźniejszy schemat w 95% rodzin, podczas gdy najpełniejsza próbka dokonała tego tylko w 0,1% przypadków.
Powód leży w semantyce required, które znaczy „wszystkie”; opcjonalność można zaobserwować tylko przez brak. Wklejenie rekordu z każdym polem mówi generatorowi „żadnego z tych kluczy nie wolno pominąć”; wklejenie rekordu z brakującym polem daje mu dowód, że „ten klucz może być nieobecny”.
Zapamiętaj to w jednym zdaniu: wartością próbki nie jest kompletność, ale to, które braki obejmuje.
Ile rekordów wystarczy
Ta sama partia eksperymentów zmierzyła też szybkość zbieżności: dokładając rekordy pojedynczo według reguły „klucz pozostaje wymagany tylko wtedy, gdy każdy dotychczasowy rekord go ma”, mediana liczby rekordów, po której required zbiega do prawdziwego zestawu wymaganych, wynosi 3; 6 rekordów pokrywa około dziewięćdziesięciu procent rodzin, a 12 pokrywa wszystkie.
Więc próbkowanie 5–10 rekordów na co dzień daje najlepszy stosunek wartości do wysiłku; wymuszanie dwudziestu lub więcej daje bardzo mało dodatkowego zysku.
Ale jest warunek wstępny: te rekordy muszą pochodzić z naprawdę różnych sytuacji. Pięć rekordów skopiowanych z tego samego szablonu przestaje dostarczać nowej informacji od drugiego wzwyż — ich zestawy pól są identyczne, a required nie może zwężyć się o ani jeden klucz.
4. Poprawka: podaj kilka rekordów naraz i pozwól mówić brakom
Skoro próbkowanie pojedynczego rekordu jest z konieczności zbyt ścisłe, właściwym podejściem jest pozwolić generatorowi patrzeć na wiele rekordów naraz i uzgadniać required według reguły „wymagany tylko wtedy, gdy każdy rekord go ma”.
Generator w tej witrynie przyjmuje tablicę bezpośrednio i ma przełącznik „wszystkie wymagane”. Wyłącz go, a zdecyduje po liczbie wystąpień: tylko klucze obecne w każdym rekordzie trafią do required.
Wciąż te same pięć rekordów — tym razem połącz je w tablicę i wywnioskuj razem, a następnie weź items jako kontrakt elementu:
| Podane rekordy | Wnioskowane required |
Walidacja wszystkich 5 |
|---|---|---|
| 1 rekord | id, name, email, tags, score |
3 przechodzą |
| 2 rekordy | id, name, tags, score |
4 przechodzą |
| 3 rekordy | id, name, tags, score |
4 przechodzą |
| 4 rekordy | id, name, tags, score |
5 przechodzą |
| 5 rekordów | id, name, tags, score |
5 przechodzą |
required zbiega już po dodaniu czwartego rekordu i nie zmienia się później. email i admin są poprawnie umieszczone w properties, ale nie w required. A ponieważ e-mail z rekordu 4 to null, wnioskowanie wielorekordowe łączy go w {"type": ["string", "null"]}, więc zarówno łańcuchy, jak i null przechodzą — czego próbkowanie pojedynczego rekordu nigdy nie potrafi.
To ten sam rząd wielkości co odkrycie absurdtools, że „required zbiega po medianie 3 rekordów”.
Wnioskowanie wielorekordowe ma jednak własne martwe punkty: jeśli email w tych rekordach przypadkiem jest null we wszystkich (powiedzmy, że pochodzą z kont, które nie wypełniły profilu), wynik wnioskowania staje się {"type": "null"}, a łańcuchowe e-maile nadal są odrzucane. Podobnie, jeśli każda wartość tags to pusta tablica, typu elementu nie da się w ogóle wywnioskować.
Więc próbkowanie ma jeszcze jeden twardy wymóg: każda postać każdego pola musi wystąpić co najmniej raz. Weź jeden łańcuch, jedną liczbę, jeden null i jedną pustą tablicę; nie pozwól, by dowolna postać była całkowicie nieobecna.
Konkretnie:
- Wyciągnij 5–20 odpowiedzi z logów, mocków lub prawdziwych żądań i koniecznie uwzględnij wiersze nieprawidłowe (ten z pustymi polami, ten z null, ten z pustą tablicą);
- Złóż je w tablicę JSON i wklej do generatora;
- Wyłącz „wszystkie wymagane” i wybierz draft 2020-12;
- Wynik to
type: arrayplusitems; weź sekcjęitemsjako kontrakt pojedynczego rekordu; - Natychmiast zwaliduj oryginalne próbki tym schematem — 100% musi przejść. Jeśli choć jedna zawiedzie, twoje próbkowanie jest niewystarczające; wróć i dodaj rekordy.
Etap 5 to najtańsza samokontrola. Jeśli generator nie potrafi pomieścić nawet danych, które mu podałeś, twoja metoda próbkowania jest zła i nie ma sensu iść dalej.
5. Cztery rzeczy, których generator nie uzupełni — musisz je napisać ręcznie
Nawet po podaniu wielu rekordów poniższych rzeczy nadal nie da się wywnioskować i trzeba je dodać ręcznie:
| Brakujący element | Zmierzone zachowanie | Konsekwencja | Jak naprawić |
|---|---|---|---|
| Format łańcucha | "a@b.com" produkuje tylko {"type": "string"} |
Walidacja "not an email" przechodzi |
Dodaj "format": "email" i włącz asercje formatu |
| Zakresy i wyliczenia | Nie widzi, że score jest ograniczone do 0–100, ani że status ma tylko trzy wartości |
Liczby ujemne, ogromne wartości i śmieciowe wyliczenia przechodzą | Dodaj minimum / maximum / enum |
| Polityka nieznanych pól | additionalProperties nie jest generowane |
Błędnie zapisany klucz jest po cichu akceptowany, co jest groźniejsze niż błąd | Dodaj "additionalProperties": false dla plików konfiguracyjnych |
| Semantyka nullowalności i pustych kolekcji | Pojedyncza próbka z null zamyka się na {"type": "null"}; pusta tablica produkuje "items": {} |
Poprawne wartości są odrzucane albo elementy tablicy całkowicie nieskrępowane | Zmień na ["string", "null"] i dawaj pustym tablicom prawdziwe elementy |
Trzeci wiersz zasługuje na osobne omówienie: domyślną wartością additionalProperties jest „zezwalaj”. To znaczy, że plik konfiguracyjny, który zapisze timeout jako timeuot, przechodzi walidację, a następnie w czasie wykonania po cichu nic nie robi. Dla schematów w stylu konfiguracji to element, który musisz jawnie wyłączyć.
format też ma pułapkę: od draft 2019-09 format należy do słownika adnotacji i domyślnie tylko adnotuje, zamiast wymuszać. Jeśli dodałeś format: "email", a nielegalne wartości nadal przechodzą, to w ośmiu przypadkach na dziesięć walidator nie włączył validateFormats lub odpowiedniej wtyczki formatu — a nie schemat jest zły.
Skoro mowa o wyborze draftu: jeśli schemat trafia do OpenAPI 3.1, musi to być draft 2020-12, bo 3.1 osadza tę wersję bezpośrednio; starsze łańcuchy narzędzi (ekosystem Swagger 2.0 i część implementacji $ref) nadal koncentrują się na draft-07. Wersje zachowują się różnie dla sąsiednich słów kluczowych $ref i słów kluczowych związanych z tablicami, więc nie mieszaj ich w jednym zestawie schematów.
6. Dla nieudokumentowanych API zewnętrznych odtwórz kontrakt z próbek odpowiedzi
Integracja wywołania zwrotnego zewnętrznego podmiotu, praca z partnerem, który nie pisze dokumentacji, albo utrzymanie dziesięcioletniego systemu odziedziczonego — wszystko to znaczy „dane są w ręku, specyfikacji nie ma”. Tutaj generator nie jest tylko wygodą; jest najszybszą drogą do kontraktu.
Metoda jest taka sama jak powyżej, z dwoma dodatkowymi warunkami:
Pierwszy: próbkuj w czasie i w różnych stanach. Pola zewnętrzne często zmieniają się ze stanem biznesowym — promocja dodaje discount, zamówienie zwrócone dodaje refundAt, a odpowiedź błędu nie ma w ogóle data. Pojedynczy snapshot oznacza widzenie tylko jednego stanu.
Podaj razem kilka sukcesów, kilka porażek i kilka pustych wyników: error i data często występują wzajemnie wyłącznie, a wnioskowanie wielorekordowe umieszcza oba w properties, pozostawiając je poza required — dokładnie zgodnie z rzeczywistością.
Drugi: przechowuj schemat w repozytorium i podłącz codzienną kontrolę. Waliduj nim dzisiejsze prawdziwe odpowiedzi, a dowiesz się o polach po cichu znikających lub zmieniających typ, zanim dowiedzą się użytkownicy.
// Run once a day: contract-drift alert
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,
);
}
Wartością tej praktyki jest nie „oszczędzenie wysiłku ręcznego pisania”, ale zamiana ustalenia słownego w wykonywalny alert. Strona trzecia nigdy nie powie ci z góry, że zmieniła pole, ale zmiana z pewnością pojawi się w twoich logach.
Jedną granicę trzeba jasno wyznaczyć: odtworzony schemat opisuje, co druga strona zwraca teraz, a nie co obiecuje zwracać. Pierwsze służy monitoringowi; tylko drugie należy do kontraktu lub dokumentacji API.
7. Po uzupełnieniu spraw, by błędy walidacji wskazywały konkretne pola
Gdy schemat jest użyteczny, następną troską są czytelne błędy. Obiekt błędu ajv ma pięć pól, a pierwsze cztery są użyteczne:
| Pole | Znaczenie | Przykład (id: 0 naruszające minimum: 1) |
|---|---|---|
instancePath |
JSON Pointer do naruszającej wartości w danych | "/id" |
schemaPath |
Położenie wyzwalającej reguły w schemacie | "#/properties/id/minimum" |
keyword |
Nazwa zawiedzionego ograniczenia | "minimum" |
params |
Czytelne maszynowo szczegóły tego ograniczenia | { "comparison": ">=", "limit": 1 } |
message |
Angielska prosa zmieniająca się między wersjami, nie parsuj jej | "must be >= 1" |
Jedną asymetrię najłatwiej potknąć: gdy required zawodzi, instancePath wskazuje obiekt nadrzędny (zwykle pusty łańcuch, czyli korzeń), a nie brakujące pole. Nazwa brakującego klucza jest w params.missingProperty. Podobnie, gdy additionalProperties zawodzi, nazwa zbędnego klucza jest w params.additionalProperty.
Więc mapując błędy na „pole → komunikat”, najpierw obsłuż te dwa przypadki specjalne:
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 nie jest opcjonalne. W domyślnej konfiguracji ajv zatrzymuje się na pierwszym błędzie, więc użytkownik poprawia jedno pole, wysyła ponownie, dostaje kolejny błąd i potrzebuje trzech rund, by wypełnić jeden formularz. Jeśli używasz też ajv-errors do niestandardowych komunikatów, bez tej flagi wtyczka po cichu zastąpi tylko część komunikatów.
8. Wielokrotnego użytku proces wdrożenia
Złóż powyższe etapy w stałą rutynę i podążaj nią przy każdej integracji nowego API:
- Próbkuj: weź 5–20 prawdziwych odpowiedzi, celowo wybierając te z pustymi polami, nullami, pustymi tablicami i dodatkowymi polami;
- Wnioskuj: wklej je razem jako tablicę do generatora, wyłącz „wszystkie wymagane” i weź
items; - Uzupełnij semantykę: dodawaj
format,enum,minimum/maximum,minItemsiadditionalPropertieselement po elemencie; - Samo kontrola: oryginalne próbki muszą przejść w 100%, inaczej wróć do etapu 1 i dodaj próbki;
- Dowód negatywny: przygotuj dla każdego ograniczenia dane wejściowe „powinno zawieść” i potwierdź, że naprawdę zawodzą — testowanie tylko ścieżki przejścia jest jak brak testowania;
- Commit: umieść schemat w kontroli wersji, zachowaj zawodzące próbki jako przypadki testowe i uruchamiaj testy, gdy tylko API zmieni pole.
Etap 5, testowanie negatywne, najłatwiej pominąć. Dodanie klucza do required bez kiedykolwiek zweryfikowania, że brakujący klucz daje błąd, to zakład, że walidator zgadza się z twoim modelem mentalnym.
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);
});
});
To ostatnie asercowanie ma znaczenie. Asercowanie tylko false może pozostać zielone nawet po zepsuciu schematu — porażka może wtedy pochodzić z zupełnie innej przyczyny.
9. FAQ
Czy schemat wygenerowany przez generator może trafić prosto na produkcję
Nie. To tylko snapshot struktury. Musisz co najmniej dodać formaty, zakresy, wyliczenia i politykę nieznanych pól, a następnie przepuścić przez niego zarówno oryginalne próbki, jak i próbki negatywne. Generator rozwiązuje „ręczne wypisanie properties jest nużące”, a nie „decyduje o regułach biznesowych”.
O systemie słów kluczowych autorskich schematów, dzieleniu przez $ref i słowach kompozycji przeczytasz w JSON Schema: od podstaw do produkcji.
Dlaczego rekord z największą liczbą pól odrzuca więcej danych
Bo required jest zbierane z „co wystąpiło w próbce”. Próbka z pełnymi polami zamienia każdy opcjonalny klucz w obowiązkowy, podczas gdy większości tych kluczy brakuje w prawdziwych danych. Podanie wielu rekordów albo rekordu, który celowo pomija pola, zbliża cię do prawdy bardziej niż najpełniejsza próbka.
Czy liczba całkowita zostanie wywnioskowana jako integer
Generator w tej witrynie tego nie robi: zarówno liczby całkowite, jak i zmiennoprzecinkowe są wypisywane jako number, więc schemat wygenerowany z {"price": 5} akceptuje 5.99. To unika wypadków typu „próbka przypadkiem była liczbą całkowitą, więc produkcja odrzuca ceny z ułamkami”. Niektóre narzędzia zewnętrzne wnioskują integer z wartości, więc szczególnie uważaj na pola pieniężne.
Szczegóły precyzji liczb i granic typów znajdziesz w Sześciu typach danych JSON i pułapkach precyzji liczb.
Dodałem format, ale nie ma efektu
Od draft 2019-09 format jest domyślnie tylko adnotacją. Albo jawnie włącz asercje formatu w walidatorze (ajv wymaga ajv-formats), albo użyj pattern do twardego ograniczenia. Nie zakładaj, że dodanie go cokolwiek zablokuje.
required zgłasza błąd, ale pole wyraźnie jest w danych
Najpierw spójrz na instancePath. Błąd required wskazuje obiekt nadrzędny, co znaczy, że brakującego klucza nie ma na tym poziomie — częste przyczyny to zły poziom zagnieżdżenia (/profile/name to nie to samo co name w korzeniu), różna wielkość liter (userId versus userID) albo upstream serializacji pomijający undefined całkowicie.
Przed śledztwem potwierdź, że sam dokument jest poprawnym JSON-em: komentarze, przecinki końcowe i podobne problemy zawodzą na etapie parsowania, zanim schemat w ogóle dostanie szansę. Parsowanie i formatowanie JSON wskazuje bezpośrednio naruszający wiersz.
Ile rekordów próbkować i którego draftu użyć?
Próbkowanie 5–10 rekordów na co dzień daje najlepszą wartość; required zbiega przy medianie 3 rekordów, a 12 rekordów pokrywa każdą rodzinę. Każdy rekord musi pochodzić z naprawdę różnej sytuacji, a każda postać każdego pola musi wystąpić co najmniej raz. Przypnij draft do 2020-12, bo OpenAPI 3.1 akceptuje tylko tę wersję.
Dlaczego permisywna domyślna wartość additionalProperties jest groźniejsza?
Domyślnie nie emituje żadnego słowa kluczowego, więc błędnie zapisany klucz jest po cichu akceptowany, przechodzi walidację, a potem zawodzi w czasie wykonania — trudniejsze do zdiagnozowania niż bezpośredni błąd. Schematy w stylu konfiguracji muszą jawnie ustawić "additionalProperties": false, a schematy API powinny najpierw rozważyć koszt zgodności.
Czy odtworzony schemat powinien być twardą bramką?
Nie. Odtworzony schemat opisuje, co druga strona zwraca teraz, a nie co obiecuje zwracać, więc należy do monitoringu i alertów dryfu. To, co trafia do kontraktu lub dokumentacji API, musi być obiecanym kontraktem.