JSON Schema - 検証が機能するかを決める部分
壊れたJSON Schemaの設定は、たいてい例外を投げません。誤ったデータに対してvalidを返すのです。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
構文チェックが教えてくれるのは、その文書がパースできるということだけです。{"user_id": null, "email": 4}をあなたのサービスが受け入れるべきかどうかについては何も言いません。その隙間を埋めるためにスキーマがあり、両者の違いは正確に述べる価値があります。バリデーターは「これは整形式のJSONか」に答え、スキーマは「これは約束された文書か」に答えます。
人の時間を実際に奪う失敗の形は、正しいデータを拒むスキーマではありません。そちらは数分で気づかれます。奪うのは、誤ったデータを受け入れ、文句なくコンパイルされ、1年にわたってvalidを報告し続けるスキーマです。
draftを選ぶ
答えは2つあり、どちらも擁護できます。
新規の仕事には2020-12。OpenAPI 3.1が足並みをそろえている識別子なので、API記述を書いているならすでにこの方言の中にいます。名前には注意を。2020-12はdraftが切られた時期で、公表されたのは2021年初頭です。そのURIにある文書が最後に再公開されたのは2022年6月で、それは仕様本文への修正であって新しい版ではありません。
対応ツールの広さを取るならdraft-07。言語、エディタ、コード生成器を通じて対応の裾野がもっとも長く、JavaScript圏の外にあるバリデーターの多くは今もこれを既定として扱います。自分の制御下にないツールにスキーマを消費させる必要があるなら、draft-07が実務上の下限です。
やってはいけないのは、$schemaを書かずに祈ることです。方言を宣言していないスキーマは、そのバリデーターがたまたま既定に選んだものに従って解釈されます。それこそ、あなたが取り除こうとしていた曖昧さです。
実際に触れることになる2020-12の変更点
3つ。噛みつく頻度の高い順に。
prefixItemsがitemsの配列形式を置き換えました。draft-07では、配列の値を持つitemsは位置ごとのタプル検証を意味し、残りはadditionalItemsが制約していました。2020-12では位置はprefixItemsに入り、itemsは常に単一のスキーマで、prefixItemsが覆っていないすべてに適用されます。
unevaluatedPropertiesとunevaluatedItemsは、additionalPropertiesとadditionalItemsの合成を理解する版です。理由は後述します。この2つこそ人が間違える組だからです。
$dynamicRefと$dynamicAnchorは2019-09の$recursiveRefと$recursiveAnchorを置き換えます。呼び出し側が特殊化できる木構造の型のような、拡張可能な再帰スキーマを書くのでなければ、これらに触れることは一生ありません。$recursiveRefを使う2019-09のスキーマを引き継いだなら、2020-12のバリデーターの下では動きません。
静かに失敗するAjvのエントリポイント
Ajvの既定エクスポートが実装しているのはdraft-07だけです。2020-12と2019-09の方言は別のエントリポイントにあります。素直に配線してみましょう。
const Ajv = require("ajv"); // $schemaに何と書いてあってもdraft-07
const ajv = new Ajv({ strict: false });
const schema = {
type: "object",
properties: {
point: {
type: "array",
prefixItems: [{ type: "number" }, { type: "number" }],
minItems: 2,
maxItems: 2
}
}
};
ajv.validate(schema, { point: ["north", "west"] }); // true
prefixItemsはこのバリデーターが知らないキーワードです。未知のキーワードは無視されるので、確認されるのは値が2要素の配列であることだけ。宣言した要素の型は一度も見られません。スキーマが明白に禁じているデータに対してtrueが返ります。
Ajvにも防御はあります。既定で有効なstrictモードは未知のキーワードに異議を唱え、読み込んでいないメタスキーマを指す$schemaがあれば例外を投げます。そしてその両方が日常的に切られます。スキーマがAjvの知らない語彙を使っていると人がまず加えるのがstrict: falseですし、生成されたスキーマの多くは$schemaを持たないまま出荷されます。どちらかの防御を外せば、待っているのは静かな通過です。
正しい配線はこうです。
const Ajv2020 = require("ajv/dist/2020");
const addFormats = require("ajv-formats");
const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
validate({ point: ["north", "west"] }); // false
console.log(validate.errors);
2019-09のエントリポイントはajv/dist/2019です。既知の不正な文書が失敗することを主張するテストを1本加える価値があります。否定テストのない検証設定は、誰も動作を証明していない設定です。
formatは既定では何も検証しない
2019-09と2020-12において、formatはアノテーションであって表明ではありません。仕様に明記されています。{"type": "string", "format": "email"}を見た適合バリデーターは、「この値はemailとして注釈された」と記録したうえで"not an email"を文句なく受け入れてよいのです。表明を有効にしない限り、ほとんどのバリデーターがまさにそうします。
ですからこのスキーマは、
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"created_at":{ "type": "string", "format": "date-time" }
},
"required": ["email"]
}
emailの欠落を捕まえ、emailが数値である場合を捕まえます。"email": "banana"は捕まえませんし、バリデーターにformatを表明せよと指示していない限り"created_at": "きのう"も捕まえません。
Ajvではこれは1つではなく2つのことを意味します。実際の実装を供給するajv-formatsをインストールして登録すること、そして正しい語彙が働くよう自分の方言のエントリポイントを使うこと。Ajv自身はformatの実装を一切同梱していません。ajv-formatsがなければ、formatはstrictモードでは未知フォーマットのエラー、strictを切れば何もしない飾りです。どちらも検証ではありません。
値の形が本当に重要なら、formatをpatternで、あるいはminLengthとmaxLengthで裏打ちしてください。正規表現はどのdraftのどのバリデーターでも表明され、プラグインもフラグも要りません。formatは、一部のバリデーターを説得すれば強制させられるドキュメントです。
ここでの常連の犠牲者は日付です。JSONに日付型はなく、ペイロード中のあらゆるタイムスタンプは実のところただの文字列だからです。
additionalProperties対unevaluatedProperties
additionalPropertiesが知っているのは、同じスキーマオブジェクト内のpropertiesとpatternPropertiesだけです。$refやallOfの枝が持ち込んだものは見えません。この一文が、「明らかに定義されているフィールドをスキーマが拒否する」という報告のほぼすべてを説明します。
{
"allOf": [{ "$ref": "#/$defs/base" }],
"properties": { "role": { "type": "string" } },
"additionalProperties": false
}
baseで定義されたプロパティはすべて拒否されます。この階層で既知のプロパティはroleだけだからです。最後の行を"unevaluatedProperties": falseに替えれば、このキーワードはその場に適用される演算子が仕事を終えたあとに走り、baseが評価したものすべてを見て、どこからも説明されなかったものだけを拒否します。
目安: すべてのプロパティをその場で宣言している独立したオブジェクトにはadditionalProperties: false。allOf、$ref、if/then、oneOfで合成されたものにはunevaluatedProperties: false。配列に対する同じ関係がunevaluatedItemsであり、prefixItemsと自然に組みます。
requiredは「存在する」であって「中身がある」ではない
requiredは存在しなければならないキーの一覧です。それだけのものです。
{ "type": "object", "required": ["user_id"] }
{"user_id": null}はこれを満たします。{"user_id": ""}も満たします。nullが許容できないなら、プロパティのスキーマでそう言ってください。"type": "string"は単独でnullを排除し、["string", "null"]はnullを認めます。
{
"properties": { "user_id": { "type": "string", "minLength": 1 } },
"required": ["user_id"]
}
鏡写しの間違いは、nullを取りうるフィールドを必須でないと印づけることです。ときどき存在しないキーと、ときどきnullであるキーは、消費側にとって別々の契約であり、そのどちらかを意図して選ぶことはレスポンスの設計の一部です。
サンプルからスキーマを生成する
40フィールドあるペイロードのスキーマを手書きするのは、人がやらずに済ませたくなる程度には退屈です。実際のレスポンスから生成するのが実務的な出発点であり、結果が使い物になるかを決める要件がひとつあります。
オブジェクトの配列は最初の要素から標本を取るのではなく、すべての要素にわたってマージしなければなりません。要素ゼロだけを取ると2つのことが壊れます。要素3以降に現れるキーはpropertiesから丸ごと欠け、たまたま要素ゼロにあって以降にないキーはrequiredと印づけられ、妥当だと分かっているデータをスキーマが拒否するようになります。正しい振る舞いは、propertiesにはキーの和集合を、requiredには積集合を取り、型はキーごとに合併することです。
スキーマ生成ツールがやっているのがそれです。手で打った例ではなく実際のAPI出力を1ページ分与え、そのうえで結果を編集してください。既知のフォーマットを持つ文字列は締め、集合が閉じているところにはenumを加え、additionalPropertiesの問題はオブジェクトごとに決めます。生成された出力は草案であって契約ではありません。
数値にも目を向ける価値があります。生成器は9007199254740993を見て"type": "integer"と書きます。型については正直で、JavaScriptがその値を保持できないという事実については沈黙しています。
労力が報われる場所
APIの契約。スキーマは提供側がテストの拠り所とし、消費側が検証に用いるものになるので、破壊的変更は顧客のログではなくCIで落ちます。設定の検証。デプロイファイルの打ち間違いが、3つ先のサービスでのnull参照ではなく、問題のキーを指すJSON Pointer付きのエラーになります。そしてコード生成。ひとつの定義からリファレンス文書とTypeScriptの型の両方が出るので、3つが食い違いようがなくなります。
これらはスキーマを書いたから手に入るのではありません。正しく配線されたバリデーターが実際に強制するスキーマを書いたから手に入るのです。