JSON Toolkit

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.

Średniozaawansowany~14 min czytaniaOpublikowano 2026-10-30Zaktualizowano 2026-10-30
Okładka ilustrująca konflikty między szkicem JSON-do-Schema a prawdziwymi danymi

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:

  1. 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ą);
  2. Złóż je w tablicę JSON i wklej do generatora;
  3. Wyłącz „wszystkie wymagane” i wybierz draft 2020-12;
  4. Wynik to type: array plus items; weź sekcję items jako kontrakt pojedynczego rekordu;
  5. 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:

  1. Próbkuj: weź 5–20 prawdziwych odpowiedzi, celowo wybierając te z pustymi polami, nullami, pustymi tablicami i dodatkowymi polami;
  2. Wnioskuj: wklej je razem jako tablicę do generatora, wyłącz „wszystkie wymagane” i weź items;
  3. Uzupełnij semantykę: dodawaj format, enum, minimum/maximum, minItems i additionalProperties element po elemencie;
  4. Samo kontrola: oryginalne próbki muszą przejść w 100%, inaczej wróć do etapu 1 i dodaj próbki;
  5. 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;
  6. 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.