Ważny JSON, uszkodzony kontrakt: warstwowe sprawdzania w celu wykrycia regresji w treści przesyłanej
Naucz się wykrywać regresje w treści JSON, które są poprawnie parsowane: różnice semantyczne, skoncentrowane JSON Schema, twierdzenia dotyczące reguł biznesowych w Node oraz ograniczenia typów generowanych.
Źródła: Catch JSON Contract Regressions Before They Reach Your App
Odpowiedź może przyjść z kodem HTTP 200, zostać przetłumaczona bez żadnego błędu, a mimo to uszkodzić twoją aplikację. Walidacja składni potwierdza jedynie, że tekst jest w formacie JSON; nie mówi nic o tym, czy treść nadal odpowiada temu, czego oczekuje twój kod. W tym przewodniku tworzymy małą, wielowarstwową kontrolę regresji (porównanie semantyczne, sprecyzowany schemat oraz wyraźne zasady biznesowe), dzięki czemu naruszenie umowy objawia się jako precyzyjne komunikat, a nie dziwny błąd późniejszego etapu działania aplikacji.
Treść, która się przetłumaczyła, ale nadal zawodzi
Rozważmy usługę obsługi zamówień. Koniec ścieżki odpowiada pomyślnie, JSON zostaje zinterpretowane, a następnie kod sumujący ilości pozycji generuje "021" zamiast 3. Scenariusz jest wymyślony, ale ten sposób awarii jest powszechny: w przepełnionym treściu przesyłki łatwo pominąć ciąg znaków "2" oraz liczbę 2; oba są dopuszczalne w JSON, a tylko jedno z nich spełnia warunki umowy.
Poniższy przykład reprezentuje przychodzące zamówienie. Przyjrzyj się uważnie pierwszej pozycji: jej quantity to ciąg znaków w cudzysłowach, podczas gdy druga pozycja używa zwykłej liczby. Wszystko inne to prawdopodobne dane zamówienia, w tym null jako instrukcja wysyłki oraz z góry obliczona wartość totalCents.
{
"orderId": "ORD-1042",
"createdAt": "2026-09-21T09:30:00Z",
"customer": {"id": "C-017", "name": "Avery Example", "vip": false},
"items": [
{"sku": "MUG-BLUE", "quantity": "2", "unitPriceCents": 1250},
{"sku": "NOTE-A5", "quantity": 1, "unitPriceCents": 650}
],
"shipping": {"city": "Exampleton", "instructions": null},
"paid": true,
"totalCents": 3150
}
Surowa kontrola składni akceptuje ten dokument bez żadnych zastrzeżeń, a to jest prawidłowy wynik: tekst jest zgodny z gramatyką JSON określoną w RFC 8259, gdzie ciągi znaków, liczby, wartości logiczne oraz null stanowią ważne wartości. Gramatyka nie może jednak znać reguł dodanych przez Twoją aplikację:
quantitymusi być liczbą całkowitą większą od zerapaidmusi być wartością logiczną- suma każdej ilości pomnożona o jej cenę jednostkową musi być równa
totalCents
Załóżmy, że poprzednia wersja tego zamówienia różniła się dokładnie w dwóch miejscach: quantity miało wartość 2, a paid było równe false. Zmiana flagi płatności na true jest oczekiwaną zmianą stanu. Natomiast przekształcenie ilości z liczby na ciąg znaków stanowi regresję.
Wartości różnic według ścieżki, a nie wierszy tekstu
Istnieją dwa zupełnie różne rodzaje pytań, które można zadać podczas porównywania treści. Porównanie semantyczne analizuje oba dokumenty i porównuje wartości w każdej ścieżce. Traktuje kolejność kluczy obiektów jako nieważną, więc zamiana pozycji customer.id i customer.name nie stanowi zmiany, natomiast zachowuje znaczenie kolejności elementów tablicy, więc przeniesienie elementu na inny indeks może być zmianą. W tym przypadku semantyczne porównanie pokazuje dokładnie dwie ścieżki: $.items[0].quantity i $.paid. To odpowiada na pytanie „jakie dane się zmieniły?”
Różnica tekstowa odpowiada na inne pytanie: „Które znaki lub wiersze się zmieniły?”. Nadal jest to przydatny dowód, a najpierw normalizacja wcięć i formatowania po obu stronach eliminuje dużą część zbędnych informacji. Jednak zgodny lub różniący się tekst nie jest dowodem na to, że dwa dokumenty reprezentują lub nie reprezentują tego samego wartości JSON, ponieważ odstępy, kolejność kluczy oraz formatowanie liczb mogą się różnić bez zmiany znaczenia.
Odtwarzanie sprawdzeń w zwykłym Node
Nie potrzebujesz żadnej zewnętrznej usługi do weryfikacji tych faktów. Poniższy skrypt odczytuje plik order.json z własnego katalogu (za pomocą import.meta.url), odbudowuje poprzednią wersję za pomocą structuredClone i sprawdza różnice przy użyciu node:assert/strict. Następnie demonstruje błąd, naprawia plik testowy na kopii i sprawdza zasadę biznesową, czy obliczona suma odpowiada totalCents. Zapisz go jako check-order.mjs obok pliku testowego i uruchom node check-order.mjs.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const incoming = JSON.parse(
readFileSync(new URL('./order.json', import.meta.url), 'utf8')
);
const before = structuredClone(incoming);
before.items[0].quantity = 2;
before.paid = false;
assert.equal(before.items[0].quantity, 2);
assert.equal(incoming.items[0].quantity, '2');
assert.equal(before.paid, false);
assert.equal(incoming.paid, true);
const naiveCount = incoming.items.reduce(
(sum, item) => sum + item.quantity,
0
);
assert.equal(naiveCount, '021');
const corrected = structuredClone(incoming);
corrected.items[0].quantity = 2;
const count = corrected.items.reduce((sum, item) => sum + item.quantity, 0);
const computedTotal = corrected.items.reduce(
(sum, item) => sum + item.quantity * item.unitPriceCents,
0
);
assert.equal(count, 3);
assert.equal(computedTotal, corrected.totalCents);
console.log('Contract checks passed');
Ciekawa jest operacja z naiveCount. Redukcja rozpoczyna się od liczby 0, do której dodawana jest strona "2", co daje wynik w postaci strony "02", ponieważ operator + przechodzi w działanie łączenia, gdy którykolwiek z operandów jest stroną. W następnym kroku dodawany jest 1, co daje "021". Liczbowa wartość początkowa nie wymusza konwersji późniejszych operandów; gdy tylko do akumulatora trafi strona, wszystko, co następuje po niej, jest łączone.
Zwróć uwagę na to, czego skrypt nie zaleca. Modyfikacja elementu testowego tak, by przyjmował liczbę, to sposób na określenie poprawnego kontraktu, a nie wskazówka do otaczania każdego przychodzącego pola funkcją Number(). Cicha konwersja ukrywa błędy w kodzie źródłowym. Należy albo poprawić usługę wytwarzającą strony, albo zdefiniować wyraźną, udokumentowaną regułę normalizacji w miejscu, gdzie dane trafiają do systemu.
Zmiana reguły typu na schemat
Reguły dotyczące prozy łatwo zapominać, natomiast te wykonywalne – nie. Poniższy schemat Draft 2020-12 koduje wymagania istotne dla tej regresji. Wymaga on orderId, items, paid oraz totalCents, wymusza obecność co najmniej jednego elementu, a także ogranicza quantity do liczby całkowitej nie mniejszej niż 1 oraz unitPriceCents do liczby całkowitej nieujemnej.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["orderId", "items", "paid", "totalCents"],
"properties": {
"orderId": {"type": "string"},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "quantity", "unitPriceCents"],
"properties": {
"sku": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1},
"unitPriceCents": {"type": "integer", "minimum": 0}
}
}
},
"paid": {"type": "boolean"},
"totalCents": {"type": "integer", "minimum": 0}
}
}
Walidacja dostarczonego danych według tego schematu kończy się błędem o treści podobnej do $.items[0].quantity: expected type integer. Zmiana tej jednej wartości na liczbę 2 sprawia, że walidacja przebiega pomyślnie. Właśnie taki precyzyjny ślad jest tym, czego oczekuje się od sprawdzenia regresji.
Wiedz, co faktycznie obejmuje twój walidator
Walidatory różnią się tym, w jakim stopniu implementują specyfikację. Niektóre celowo pomijają rozwiązywanie odległych celów $ref, ocenę wyrażeń regularnych pattern czy weryfikację słów kluczowych format, a domyślnie specyfikacja traktuje format jako adnotację, a nie twierdzenie. Upewnij się, że walidator używany w testach obejmuje te same słowa kluczowe, od których zależy twój kontrakt produkcyjny; w przeciwnym razie schemat może wydawać się bardziej rygorystyczny niż faktyczne sprawdzania, które go egzekwują.
Kasusy graniczne wymagające osobnego testu
Dobry kontrakt odrzuca błędne dane z różnych powodów. Każdy z nich powinien sam w sobie spowodować niepowodzenie:
- Całkowite brakowanie
items(naruszenie wymogurequired) itemsjako pusta tablica (minItems)- Ilość
quantityrówna zero (minimum)
paid wysyłany jako ciąg znaków "true" (type)Równie ważne jest to, co powinno zostać przekazane: nieopłacony zamówienie z wartością paid: false. Słowo kluczowe required sprawdza jedynie obecność tej właściwości, a nie to, czy jej wartość jest prawdziwa. Oficjalne przewodniki JSON Schema wyjaśniają, w jaki sposób required odnosi się do właściwości obiektu oraz jak każdy element tablicy jest sprawdzany pod kątem subschematu items.
To, co schemat celowo pominął
To jest spersonalizowane schema, a nie pełna specyfikacja zamówienia. Szczegóły klienta, dostawa, semantyka zapisu czasu oraz dodatkowe pola nie są ograniczone. Zasada arytmetyczna również celowo nie jest objęta zakresem: to, czy pozycje sumują się na totalCents, stanowi logikę aplikacji, której standardowe JSON Schema nie może wyrazić, dlatego skrypt Node sprawdza to oddzielnie. Rozdzielenie tych dwóch elementów pozwala każdemu narzędziu wykonywać to, w czym jest najlepsze.
Generowane typy opisują jeden przykład
Kuszące jest wklejenie treści do generatora JSON-to-TypeScript i traktowanie wyniku jako umowy. Po uruchomieniu na skorygowanym przykładzie taki generator generuje wartości takie jak quantity: number, createdAt: string oraz instructions: null w ramach zagnieżdżonych typów. Są to dokładne opisy tego jednego przykładu i nic więcej:
- JSON ma tylko jeden typ liczbowy, więc
numbernie może określać „dodatniego liczb całkowitej”. - Jeden przykład nie może pokazać, które pola są opcjonalne.
- Pole, które obecnie ma wartość
null, jutro może legalnie zawierać łańcuch znaków. - Typy w TypeScript znikają podczas wykonywania i nigdy nie sprawdzają przychodzących bajtów; przewodnik TypeScript dotyczący asercji typów jasno stwierdza, że asercje nie przeprowadzają żadnych sprawdzeń w czasie wykonywania.
Jeśli szukasz jednej definicji, która zarządza zarówno walidacją w czasie wykonywania, jak i typami statycznymi, przeczytaj nasz artykuł o dzielonym schemacie Zod pomiędzy frontendem a backendem.
Dokładność powinna obowiązywać również na granicach
Liczby stwarzają jeszcze jedną pułapkę. JavaScript interpretuje {"id":9007199254740993} jako 9007199254740992, ponieważ ta wartość przekracza Number.MAX_SAFE_INTEGER i jest zaokrąglana do najbliższego reprezentowalnego liczby zmiennoprzecinkowej. Jeśli identyfikator musi zachować każdą cyfrę, kontrakt twórcy powinien określić go jako ciąg znaków. Próba naprawy po wywołaniu JSON.parse jest zbyt późna – cyfry już zniknęły.
Główne wnioski
- Zachowuj oczyszczone przykłady poprawnych danych wejściowych i nigdy nie używaj prawdziwych haseł ani danych osobistych w danych testowych.
- Traktuj sprawdzania jako kolejne warstwy: ścisła składnia, porównanie wartości semantycznych, walidacja schematu, a następnie reguły biznesowe, których schemat nie może wyrazić.
- Generuj typy TypeScript dopiero po ustaleniu kontraktu i nigdy nie myl ich z walidacją w czasie wykonywania.
$.items[0].quantity zmieniono z liczby całkowitej na ciąg znaków, przez co walidacja zawodzi i operacja dodawania nie działa poprawnie”, zamiast „kolejność wygląda niewłaściwie”.Literatura pokrewna
- Jedna torba JSON dla każdej trasy NestJS: Interceptory plus globalny filtr — Otaczaj każdą odpowiedź NestJS typowaną torbą za pomocą generycznego interceptora, dekoratora wiadomości i filtru wyjątków, jednocześnie pozostawiając kontrolery wolne od kodu szablonowego.
- Złapanie nieuchwytnego driftu w umowie API za pomocą type’ów wywnioskowanych z przykładów i Zod — Dlaczego ręcznie napisane type’y TypeScript dla API third-party stają się przestarzałe, jak wywnioskowywanie type’ów i schematów Zod z rzeczywistych odpowiedzi pomaga, oraz jak różnice w snapshotach ujawniają drift.