למה JSON Schema שנוצר אוטומטית ממשיך לדחות נתונים אמיתיים
שורה תחתונה: schemas שנוצרים חוסמים לעיתים קרובות נתונים תקינים. למדו למה דוגמאות עשירות יותר מפיקות schemas גרועים יותר, ואז את התהליך: דגימה מרובת רשומות, השלמה סמנטית ואיתור שגיאות.

שורה תחתונה: כווננו קודם את גבול החובה/רשות ביד — הזינו 5–20 רשומות בצורות שונות עם "הכל חובה" כבוי — ואז צרו את ה-schema והוסיפו format, enum ו-additionalProperties ידנית; דוגמה אחת עם כל השדות תהיה תמיד נוקשה מדי.
המחולל יכול לראות רק את הרשומה הבודדת שהדבקתם. הוא קורא "ברשומה הזו יש אימייל" בתור "בכל רשומה חייב להיות אימייל", ולכן ככל שהדוגמאות שלכם שלמות יותר, כך הוא דורש יותר.
1. מחולל עושה מיפוי מבני, לא הבנה עסקית
תנו למחולל מחולל פיסת JSON והוא עושה בדיוק דבר אחד: עובר על מבנה הערכים ומתרגם כל צומת למילות מפתח של schema. כל טבלת המיפוי קבועה:
| ערך בדוגמה | טיפוס מוסק | הערות |
|---|---|---|
"hello" |
{"type": "string"} |
לא מבדיל בין אימייל, תאריך, 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 ריק.
עכשיו צרו schema מכל רשומה בנפרד, ואז השתמשו בכל תוצאה כדי לאמת את כל חמש הרשומות (מאמת: 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 רשומה |
אף אחד מחמשת ה-schemas לא נכון, אבל היחיד שמקבל את כל הנתונים נוצר מרשומה 2 — הרשומה שבה חסר email. הוא מנצח מסיבה פשוטה: מפתחות שלעולם לא מופיעים לעולם לא נכתבים ל-required.
רשומה 4 אפילו יותר מאלפת: הערך של email בה הוא null, ולכן המחולל מקודד {"type": "null"}, וכל ארבע הרשומות האחרות עם אימיילים כמחרוזות נדחות.
3. למה "הדביקו את הרשומה השלמה ביותר" הוא הבחירה הגרועה ביותר
האינטואיציה היא "הדביקו את הדוגמה עם הכי הרבה שדות וה-schema יהיה שלם". המדידות מראות בדיוק את ההיפך — וצד שלישי הריץ מבחן מבוקר גדול יותר.
absurdtools השתמשו ב-3000 "משפחות נתונים" אקראיות, 8 רשומות בכל משפחה, כשכל שדה רשות נוכח בערך בחצי מהמקרים, ומדדו כמה נתונים יכול schema שנוצר מדוגמאות עם מספרי מפתחות שונים לקבל:
| מספר מפתחות בדוגמה | חלק מנתוני המשפחה שהתקבלו |
|---|---|
| 2 | 100.0% |
| 3 | 56.8% |
| 4 | 34.8% |
| 5 | 23.4% |
| 6 | 17.7% |
| 7 | 14.9% |
המסקנה אינה "ירידה קלה" אלא מהפך מוחלט: הדוגמה המינימלית הפיקה את ה-schema הרופף ביותר ב-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. ובגלל שאימייל של רשומה 4 הוא null, ההסקה מרובת רשומות מחברת אותו ל-{"type": ["string", "null"]}, כך שגם מחרוזות וגם null עוברים — משהו שדגימת רשומה בודדת לא יכולה לעשות לעולם.
זה אותו סדר גודל של הממצא של absurdtools ש"required מתכנס אחרי חציון של 3 רשומות".
להסקה מרובת רשומות יש נקודות עיוורות משלה: אם ה-email ברשומות האלה במקרה הוא null בכולן (נניח שכולן הגיעו מחשבונות בלי פרופיל שמולא), תוצאת ההסקה הופכת ל-{"type": "null"}, ואימיילים כמחרוזות עדיין נדחים. באופן דומה, אם כל ערך tags הוא מערך ריק, טיפוס האיבר לא יכול להיות מוסק כלל.
לכן לדגימה יש דרישה קשיחה נוספת: כל צורה של כל שדה חייבת להופיע לפחות פעם אחת. הביאו מחרוזת אחת, מספר אחד, null אחד ומערך ריק אחד; אל תתנו לאף צורה להיעדר לגמרי.
באופן קונקרטי:
- שלפו 5–20 תגובות מלוגים, mocks או בקשות אמיתיות, והקפידו לכלול את השורות החריגות (זו עם שדות ריקים, זו עם null, זו עם מערך ריק);
- הרכיבו אותן למערך JSON והדביקו אותו ב-מחולל;
- כבו את "הכל חובה" ובחרו draft 2020-12;
- התוצאה היא
type: arrayועודitems; קחו את חלק ה-itemsכחוזה של רשומה בודדת; - אמתו מיד את הדוגמאות המקוריות עם ה-schema הזה — 100% חייבות לעבור. אם אפילו אחת נכשלת, הדגימה שלכם לא מספיקה; חזרו והוסיפו רשומות.
שלב 5 הוא הבדיקה העצמית הזולה ביותר. אם המחולל אפילו לא יכול להכיל את הנתונים שהזנתם, שיטת הדגימה שלכם שגויה, ואין טעם להמשיך.
5. ארבעה דברים שמחולל לא יכול למלא — חייבים לכתוב אותם ביד
גם אחרי הזנת רשומות מרובות, הדברים הבאים עדיין לא ניתנים להסקה וחייבים להיתוסף ידנית:
| חסר | התנהגות נמדדת | תוצאה | איך לתקן |
|---|---|---|---|
| פורמט מחרוזת | "a@b.com" מפיק רק {"type": "string"} |
אימות "not an email" עובר |
להוסיף "format": "email" ולהפעיל בדיקות פורמט |
| טווחים ו-enums | לא רואה ש-score מוגבל ל-0–100, ולא ש-status יש רק שלושה ערכים |
מספרים שליליים, ערכים עצומים וערכי enum זבל עוברים | להוסיף minimum / maximum / enum |
| מדיניות שדות לא מוכרים | additionalProperties לא נוצר |
מפתח עם שגיאת הקלדה מתקבל בשקט, וזה מסוכן יותר משגיאה | להוסיף "additionalProperties": false לקבצי תצורה |
| יכולת null וסמנטיקת אוסף ריק | דוגמה בודדת שמכילה null ננעלת ל-{"type": "null"}; מערך ריק מפיק "items": {} |
ערכים תקינים נדחים, או שאיברי המערך לא מוגבלים כלל | לשנות ל-["string", "null"], ולתת לאוספים ריקים איברים אמיתיים |
השורה השלישית ראויה לאזכור נפרד: ערך ברירת המחדל של additionalProperties הוא "התר". כלומר קובץ תצורה שכותב timeout כ-timeuot עובר אימות ואז לא עושה כלום בשקט בזמן ריצה. עבור schemas בסגנון תצורה, זה פריט שחייבים לכבות במפורש.
גם ל-format יש מלכודת: מאז draft 2019-09, format שייך לאוצר המילים של ההערות, וברירת מחדל הוא רק מעיר ולא אוכף. אם הוספתם format: "email" וערכים לא חוקיים עדיין עוברים, בשמונה מתוך עשרה מקרים המאמת לא הפעיל validateFormats או את תוסף הפורמט המתאים — לא שה-schema שגוי.
הערה על בחירת draft כל עוד אנחנו כאן: אם ה-schema הולך ל-OpenAPI 3.1, הוא חייב להיות draft 2020-12, כי 3.1 מטמיע את הגרסה הזו ישירות; כלי שרשרת ותיקים יותר (האקוסיסטם של Swagger 2.0 וחלק ממימושי $ref) עדיין ממרכזים draft-07. השניים מתנהגים אחרת לגבי מילות מפתח אחים ל-$ref ולגבי מילות מפתח שקשורות למערכים, ולכן אל תערבבו אותם בתוך מערך schemas אחד.
6. לממשקי צד שלישי ללא תיעוד, הנדסו לאחור את החוזה מדוגמאות תגובה
שילוב callback של צד שלישי, עבודה עם שותף שלא כותב תיעוד, או תחזוקת מערכת legacy בת עשר שנים — כל אלה אומרים "הנתונים ביד, המפרט לא". כאן מחולל הוא לא רק נוחות; הוא הדרך המהירה ביותר לחוזה.
השיטה זהה לזו שלמעלה, עם שתי תוספות חובה:
אחת: דגמו לאורך זמן וברחבי מצבים. שדות של צד שלישי משתנים לעיתים קרובות עם מצב עסקי — מבצע מוסיף discount, הזמנה מוחזרת מוסיפה refundAt, ותגובת שגיאה בכלל לא כוללת data. תפיסת צילום בודד פירושה לראות רק מצב אחד.
הזינו יחד כמה הצלחות, כמה כשלים וכמה תוצאות ריקות: error ו-data לעיתים קרובות מופיעים באופן בלעדי הדדי, והסקה מרובת רשומות מכניסה את שניהם ל-properties ומשאירה אותם מחוץ ל-required — בדיוק כמו המציאות.
שתיים: אחסנו את ה-schema במאגר וצרפו בדיקה יומית. אמתו את תגובות ההווה האמיתיות מולו, ותלמדו על שדות שנעלמים בשקט או משנים טיפוס לפני שהמשתמשים שלכם ילמדו.
// 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,
);
}
הערך של העיסוק הזה אינו "לחסוך את המאמץ של כתיבה ידנית", אלא להפוך הסכמה מילולית להתראה ניתנת לביצוע. צד שלישי לעולם לא יגיד לכם מראש שהוא שינה שדה, אבל השינוי יופיע בוודאות בלוגים שלכם.
גבול אחד לתחום בבירור: ה-schema שהונדס לאחור מתאר מה הצד השני מחזיר עכשיו, לא מה שהוא מבטיח להחזיר. הראשון מיועד לניטור; רק השני שייך לחוזה או לתיעוד API.
7. אחרי ההשלמה, עשו ששגיאות אימות יצביעו חזרה על שדות ספציפיים
ברגע שה-schema שמיש, הדאגה הבאה היא שגיאות קריאות. לאובייקט שגיאה של ajv יש חמישה שדות, וארבעת הראשונים הם השימושיים:
| שדה | משמעות | דוגמה (id: 0 שמפר minimum: 1) |
|---|---|---|
instancePath |
JSON Pointer לערך הפוגע בנתונים | "/id" |
schemaPath |
המיקום של הכלל המפעיל ב-schema | "#/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 חדש:
- דגימה: שלפו 5–20 תגובות אמיתיות, ובחרו במכוון את אלה עם שדות ריקים, nulls, מערכים ריקים ושדות מיותרים;
- הסקה: הדביקו אותן יחד כמערך ב-מחולל, כבו את "הכל חובה", וקחו את
items; - השלמת הסמנטיקה: הוסיפו
format,enum,minimum/maximum,minItemsו-additionalPropertiesפריט אחר פריט; - בדיקה עצמית: הדוגמאות המקוריות חייבות לעבור 100%, אחרת חזרו לשלב 1 והוסיפו דוגמאות;
- הוכחה שלילית: הכינו קלט "שאמור להיכשל" לכל אילוץ ואשרו שהוא אכן נכשל — בדיקה של רק המסלול העובר שקולה לאי בדיקה;
- קומיט: שימו את ה-schema בבקרת גרסאות, שמרו את הדוגמאות הנכשלות כמקרי מבחן, והריצו את המבחנים קודם בכל פעם שה-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 בלבד יכולה להישאר ירוקה גם אחרי שה-schema נשבר — הכשל אולי נובע עד אז מסיבה אחרת לגמרי.
9. שאלות נפוצות
אפשר להעביר schema שמחולל מייצר ישר לפרודקשן
לא. הוא רק צילום של המבנה. חייבים להוסיף לכל הפחות פורמטים, טווחים, enums ומדיניות שדות לא מוכרים, ואז להריץ דרכו גם את הדוגמאות המקוריות וגם את הדוגמאות השליליות. המחולל פותר את "להקליד properties ביד זה מייגע", לא את "הוא מחליט את הכללים העסקיים".
למערכת מילות המפתח של כתיבת schema, פיצול $ref ומילות מפתח של הרכבה, ראו JSON Schema: מהיסודות לפרודקשן.
למה הרשומה עם הכי הרבה שדות דוחה יותר נתונים
כי required נאסף מתוך "מה שהופיע בדוגמה". דוגמה עם כל השדות הופכת כל מפתח רשות לחובה, בעוד שרוב המפתחות האלה נעדרים בנתונים אמיתיים. הזנת רשומות מרובות, או רשומה שמדלגת בכוונה על שדות, מקרבת אתכם לאמת יותר מהדוגמה השלמה ביותר.
מספר שלם יוסק כ-integer
המחולל באתר הזה לא עושה זאת: גם מספרים שלמים וגם מספרי נקודה צפה מופקים כ-number, כך ש-schema שנוצר מ-{"price": 5} מקבל 5.99. זה מונע תאונות כמו "הדוגמה במקרה הייתה מספר שלם, ולכן הפרודקשן דוחה מחירים עם עשרוניים". כלים של צד שלישי מסוימים כן מסיקים integer מהערך, ולכן שימו לב במיוחד לשדות כספיים.
לפרטים על דיוק מספרים וגבולות טיפוסים, ראו ששת טיפוסי הנתונים של JSON ומלכודות דיוק מספרים.
הוספתי format אבל אין לו השפעה
מאז draft 2019-09, format הוא הערה בלבד כברירת מחדל. או הפעילו במפורש בדיקות פורמט במאמת (ajv צריך ajv-formats), או השתמשו ב-pattern כאילוץ קשיח. אל תניחו שהוספה שלו תחסום משהו.
required מדווח על שגיאה, אבל השדה בבירור קיים בנתונים
הסתכלו קודם על instancePath. שגיאת required מצביעה על אובייקט האב, כלומר המפתח החסר אינו ברמה הזו — סיבות נפוצות הן רמת קינון שגויה (/profile/name אינו זהה ל-name בשורש), רישיות שונה (userId מול userID), או שהסריאליזציה במעלה הזרם מפילה undefined לגמרי.
לפני שאתם חוקרים, אשרו שהמסמך עצמו הוא JSON תקין: הערות, פסיקים עוקבים ובעיות דומות נכשלות בשלב הפרסור, לפני שה-schema בכלל מקבל הזדמנות. JSON Parse & Format מצביע ישירות על השורה הפוגעת.
כמה רשומות כדאי לדגום, ואיזה draft להשתמש?
דגימת 5–10 רשומות ביום-יום נותנת את הערך הטוב ביותר; required מתכנס בחציון של 3 רשומות, ו-12 רשומות מכסות כל משפחה. כל רשומה חייבת לבוא ממצב שונה באמת, וכל צורה של כל שדה חייבת להופיע לפחות פעם אחת. נעלו את ה-draft ל-2020-12, כי OpenAPI 3.1 מקבל רק את הגרסה הזו.
למה ברירת מחדל מתירנית של additionalProperties מסוכנת יותר?
ברירת המחדל לא מפיקה מילת מפתח כלל, כך שמפתח עם שגיאת הקלדה מתקבל בשקט, עובר אימות, ואז נכשל בזמן ריצה — קשה יותר לאבחון משגיאה ישירה. schemas בסגנון תצורה חייבים להגדיר "additionalProperties": false במפורש, בעוד ש-schemas של API צריכים לשקול קודם את מחיר התאימות.
כדאי להשתמש ב-schema שהונדס לאחור כשער קשיח?
לא. schema שהונדס לאחור מתאר מה הצד השני מחזיר עכשיו, לא מה שהוא מבטיח להחזיר, ולכן הוא שייך לניטור ולהתראות סחיפה. מה שנכנס לחוזה או לתיעוד API חייב להיות החוזה המובטח.