自動生成した JSON Schema が実データを拒否し続ける理由
要点:生成したスキーマは正しいデータをしばしば妨げます。サンプルが豊かになるほどスキーマが悪くなる理由と、複数レコードのサンプリング、意味論の補完、エラー位置の特定という手順を学びます。

要点:まず required と optional の境界を手で収束させましょう。「すべて必須」を切った状態で、形の異なる 5〜20 件のレコードを投入し、その後でスキーマを生成し、format、enum、additionalProperties を手で足します。全フィールドを持つサンプル一つでは常に厳しすぎます。
生成器はあなたが貼り付けた 1 レコードしか見えません。「このレコードに email がある」を「すべてのレコードに email が必要」と読みます。サンプルが完全になるほど、要求が厳しくなるのです。
1. 生成器は構造の対応付けをするだけで、業務を理解しない
JSON の塊を生成器に渡すと、それはただ一つのことをします。値の構造をたどり、各ノードをスキーマのキーワードに翻訳するのです。対応表は固定です。
| サンプル内の値 | 推論される型 | 備考 |
|---|---|---|
"hello" |
{"type": "string"} |
email、日付、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 は型から導かれるのではなく、「このサンプルにこのキーが存在する」ことから導かれます。サンプルが 1 レコードなら、その命題は「すべてのキーが必須」と等価です。
そして実際の 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 は 5 件中 3 件に現れ、admin は 1 回だけ現れ、レコード 2 は tags が空配列です。
次に各レコードから個別にスキーマを生成し、その結果で 5 件すべてを検証します(バリデータ: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 を持つ他の 4 件はすべて拒否されます。
3. 「最も完全なレコードを貼る」が最悪の選択である理由
直感は「フィールドが最も多いサンプルを貼ればスキーマが完全になる」です。測定はまったく逆を示します。第三者がより大規模な統制実験も行いました。
absurdtools は 3000 の無作為な「データファミリー」を使い、1 ファミリー 8 レコード、各任意フィールドはおよそ半分の確率で存在させ、キー数が異なるサンプルから生成したスキーマがどれだけのデータを受け入れられるかを測りました。
| サンプルのキー数 | ファミリーのデータの合格割合 |
|---|---|
| 2 | 100.0% |
| 3 | 56.8% |
| 4 | 34.8% |
| 5 | 23.4% |
| 6 | 17.7% |
| 7 | 14.9% |
結論は「わずかな低下」ではなく、完全な逆転です。最小のサンプルは 95% のファミリーで最も緩いスキーマを生み、最も完全なサンプルはわずか 0.1% でしかそれを達成できませんでした。
理由は required の意味論にあります。それは「すべて」を意味し、任意性は欠落によってしか観察できません。全フィールドを持つレコードを貼ると、生成器に「これらのキーは一つも省略できない」と伝えます。フィールドが欠けたレコードを貼ると、「このキーは存在しないことがある」という証拠を渡すことになります。
一文で覚えましょう。サンプルの価値は完全さではなく、どの不在をカバーするかです。
何件あれば足りるか
同じ実験群は収束速度も測りました。「これまでのすべてのレコードがキーを持つ場合にのみ required に残す」という規則でレコードを一つずつ蓄積し、required が真の必須集合に収束するまでのレコード数の中央値は 3 でした。6 件でおよそ九割のファミリーをカバーし、12 件ですべてをカバーします。
ですから日常的には 5〜10 件のサンプリングが最も費用対効果が高く、20 件以上を強制しても追加の利得はごくわずかです。
ただし前提があります。これらのレコードは本当に異なる状況から来なければなりません。同じテンプレートからコピーした 5 件は、2 件目以降は新しい情報を提供しません。フィールド集合が同一で、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 は 4 件目を加えた時点で収束し、その後変化しません。email と admin は正しく properties に置かれ、required には入りません。さらにレコード 4 の email が null なので、複数レコード推論はそれを {"type": ["string", "null"]} に合成し、文字列も null も通ります。単一レコードのサンプリングでは決してできないことです。
これは absurdtools の「required は中央値 3 レコードで収束する」という発見と同じ桁です。
ただし複数レコード推論にも盲点があります。これらのレコードの email がたまたますべて null なら(たとえばすべてプロフィール未入力のアカウント由来なら)、推論結果は {"type": "null"} になり、文字列の email は依然として拒否されます。同様に tags がすべて空配列なら、要素の型はまったく推論できません。
ですからサンプリングにはもう一つの厳格な要件があります。すべてのフィールドのすべての形が少なくとも一度は現れること。文字列を一つ、数値を一つ、null を一つ、空配列を一つ持ち込み、どの形も完全に欠けさせないでください。
具体的には:
- ログ、モック、実際のリクエストから 5〜20 件のレスポンスを引き、異常な行(空フィールドのもの、null のもの、空配列のもの)を必ず含める。
- それらを JSON 配列にまとめ、生成器に貼る。
- 「すべて必須」を切り、draft 2020-12 を選ぶ。
- 結果は
type: arrayとitemsになる。itemsの部分を 1 レコードの契約として取り出す。 - 元のサンプルをこのスキーマですぐ検証する。100% 通らなければならない。一つでも失敗すればサンプリングが足りないので、戻ってレコードを追加する。
ステップ 5 が最も安い自己チェックです。生成器が自分に投入したデータさえ受け入れられないなら、サンプリング方法が間違っており、先に進む意味はありません。
5. 生成器が埋められない四つ。手で書くしかない
複数レコードを投入しても、以下はなお推論できず、手で追加する必要があります。
| 欠けている部分 | 測定された振る舞い | 結果 | 修正方法 |
|---|---|---|---|
| 文字列の形式 | "a@b.com" は {"type": "string"} しか生まない |
"not an email" の検証が通る |
"format": "email" を追加し、format 検証を有効にする |
| 範囲と enum | score が 0〜100 に限られるとも、status が三値しかないとも見えない |
負数、巨大値、ゴミの enum 値がすべて通る | minimum / maximum / enum を追加 |
| 未知フィールドの方針 | additionalProperties は生成されない |
綴り違いのキーが静かに受理され、エラーより危険 | 設定系ファイルには "additionalProperties": false を追加 |
| null 許容と空コレクションの意味論 | null を含む単一サンプルは {"type": "null"} に固定され、空配列は "items": {} を生む |
正しい値が拒否されるか、配列要素が完全に無制約になる | ["string", "null"] に変え、空配列には実際の要素を与える |
三行目は特筆に値します。additionalProperties の既定値は「許可」です。つまり timeout を timeuot と綴った設定ファイルが検証を通り、実行時に静かに何もしません。設定系スキーマでは、これは明示的に閉じなければならない項目です。
format にも罠があります。draft 2019-09 以降、format は注釈語彙に属し、既定では注釈するだけで強制しません。format: "email" を足しても不正値が通るなら、八割はバリデータが validateFormats や対応する形式プラグインを有効にしていないだけです。スキーマが間違っているわけではありません。
ドラフト選択についても触れておきます。スキーマを OpenAPI 3.1 に入れるなら draft 2020-12 でなければなりません。3.1 はそのバージョンを直接埋め込むからです。古いツールチェーン(Swagger 2.0 エコシステムや一部の $ref 実装)は依然 draft-07 を中心にしています。二つは $ref の兄弟キーワードと配列関連キーワードで振る舞いが異なるので、一つのスキーマ群で混在させないでください。
6. ドキュメントのないサードパーティ API では、レスポンスサンプルから契約を逆算する
サードパーティのコールバックを統合する、ドキュメントを書かないパートナーと仕事をする、10 年前のレガシーシステムを保守する。いずれも「データは手元にあり、仕様はない」状態です。ここでは生成器は単なる便利道具ではなく、契約への最短経路です。
方法は上と同じで、二つの必須事項が加わります。
一つ:時間と状態をまたいでサンプリングする。 サードパーティのフィールドは業務状態で変わることも多いです。プロモーションで discount が加わり、返金注文で refundAt が加わり、エラー応答には data がまったくありません。単一のスナップショットを取ると、一つの状態しか見えません。
成功を数件、失敗を数件、空の結果を数件まとめて投入します。error と data はしばしば相互排他的に現れ、複数レコード推論は両方を properties に入れつつ required から外します。現実と正確に一致します。
二つ:スキーマをリポジトリに保存し、日次チェックを付ける。 今日の実際のレスポンスをそれで検証すれば、フィールドが静かに消えたり型が変わったりするのを、ユーザーより先に知れます。
// 1 日 1 回実行:契約ドリフトのアラート
const drifted = records.filter((record) => !validate(record));
if (drifted.length > 0) {
console.warn(
`${drifted.length} 件のレスポンスが契約と一致しません。最初の差分:`,
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);
/** ajv のエラー配列を { fieldPath: [message] } に変換し、フォームの強調表示に使う */
function groupByField(errors) {
const grouped = {};
for (const error of errors) {
// required と additionalProperties では、問題のフィールドは instancePath ではなく params にある
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 件取得し、空フィールド、null、空配列、余分なフィールドを持つものを意図的に選ぶ。
- 推論:それらを配列としてまとめて生成器に貼り、「すべて必須」を切り、
itemsを取る。 - 意味論を補完:
format、enum、minimum/maximum、minItems、additionalPropertiesを一つずつ追加する。 - 自己チェック:元のサンプルが 100% 通らなければ、ステップ 1 に戻ってサンプルを追加する。
- 反証:すべての制約に対して「失敗すべき」入力を用意し、実際に失敗することを確認する。合格経路だけをテストするのはテストしないのと同じです。
- コミット:スキーマをバージョン管理に入れ、失敗サンプルをテストケースとして保ち、API がフィールドを変えたらまずテストを走らせる。
ステップ 5 の反証テストは最も省略されやすいです。required にキーを足しても、欠落キーがエラーになることを一度も確認しなければ、バリデータが自分のメンタルモデルと一致するかに賭けていることになります。
import { describe, expect, it } from "vitest";
describe("user schema", () => {
it("完全なレコードを受け入れる", () => {
expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
});
it("name のないレコードを拒否する", () => {
expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
// 理由も検証し、別のフィールドの失敗でテストが通らないようにする
expect(
validate.errors?.some((e) => e.params?.missingProperty === "name"),
).toBe(true);
});
});
最後の検証が重要です。false だけを検証すると、スキーマが壊れた後でも失敗理由がまったく別のものになっているのに緑のままになりえます。
9. FAQ
生成器が出力したスキーマをそのまま本番に出せますか
いいえ。それは構造のスナップショットにすぎません。最低でも形式、範囲、enum、未知フィールドの方針を追加し、元のサンプルと反証サンプルの両方を通す必要があります。生成器が解決するのは「properties を手で打つのが面倒」であり、「業務ルールを決めてくれる」ことではありません。
スキーマ作成のキーワード体系、$ref の分割、合成キーワードについては JSON Schema:基礎から本番までを参照してください。
フィールドが最も多いレコードがより多くのデータを拒否するのはなぜですか
required が「サンプルに現れたもの」から集められるからです。全フィールドのサンプルはすべての任意キーを必須に変えますが、実際のデータではそれらの大半が欠けています。複数レコードを投入するか、意図的にフィールドを省略したレコードの方が、最も完全なサンプルより真実に近づきます。
整数は integer と推論されますか
このサイトの生成器はそうしません。整数も浮動小数点も number として出力するので、{"price": 5} から生成したスキーマは 5.99 を受け入れます。「サンプルがたまたま整数だったので、本番が小数の価格を拒否する」といった事故を避けられます。サードパーティツールには値から integer を推論するものもあるので、金額フィールドには特に注意してください。
数値精度と型の境界の詳細は JSON の 6 つのデータ型と数値精度の落とし穴を参照してください。
format を追加したのに効きません
draft 2019-09 以降、format は既定で注釈のみです。バリデータで 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 ドキュメントに入るのは約束された契約でなければなりません。