背景
JSON Schema を手で書くのが好きではありません。
型を書き写すだけの作業なのに、キーが20個あると必ずどこかで打ち間違える。
かといって生成ツールを使うと、今度は出てきたスキーマが信用できなくて、結局手で読み直すことになる…。
それで今日、自分用に JSON から JSON Schema を起こすツールを作りました。
作る過程で「生成ツールが信用できない理由」がはっきりしたので、そこを書いておきます。
JSON Schema は、JSON がどんな形をしているべきかを JSON 自身で記述する仕様です。
API のリクエスト検証や設定ファイルの検査に使います。
今回作ったものは draft 2020-12(2020年12月版の仕様)を出力します。
生成ツールが嘘をつく2つの方向
推論ツールが間違える方向は、きれいに2つに分かれます。
ひとつは「見た形を消す」。
サンプルに出ていた型を、出力スキーマから落としてしまうパターンです。
もうひとつは「見ていない形を足す」。
サンプルに無い制約を、当てずっぽうで付けてしまうパターン。
前者が起きると、本番で通るはずのデータが検証で弾かれます。
後者が起きると、本番で弾くべきデータが素通りします。
どちらも実害が出るのですが、後者のほうが気づくのが遅れるぶん厄介だと思っています。
見た形を消す例: 配列の先頭だけを見る
いちばんよくあるのがこれです。
配列のスキーマを作るとき、先頭の要素だけを見て items を決める実装。
下記の入力で何が起きるか。
[1, "x"]
先頭だけ見る実装だと items: {type: "integer"} になります。
2件目の文字列が黙って消える。
このスキーマで元のデータを検証すると、元データ自身が不合格になります。
なので、配列は全要素を合流させます。
下記は実装の中心部分で、v が配列の値、inferSchema が値1つからスキーマ節を作る再帰関数です。
mergeSchemas が2つのスキーマ節を1つにまとめます。
if (Array.isArray(v)) {
var items = null;
for (var i = 0; i < v.length; i++) {
items = mergeSchemas(items, inferSchema(v[i], opts));
}
var sa = { type: 'array' };
if (items) sa.items = items; // 空配列には items を付けない
return sa;
}
items を null から始めて、要素を1つずつ合流させていくだけです。
これで [1, "x"] は items: {type: ["integer","string"]} になります。
合流のとき、整数と小数が混ざったら number にまとめます。
[1, 2.5] は ["integer","number"] ではなく number。
整数は小数の一種なので、並べるより畳んだほうが正確です。
最後の if (items) は地味に効きます。
[](空配列)から items を推測しない、という意思表示です。
中身を見ていないのだから、何も言わないのが正しい。
見ていない形を足す例: required を全部付ける
こちらが本題です。
オブジェクトの配列からスキーマを起こすとき、required(必須キーの一覧)をどう決めるか。
下記のような入力を考えます。
[{"a": 1, "b": 2}, {"a": 3}]
1件目には b があり、2件目には無い。
素朴に実装すると、1件目を見た時点で required: ["a","b"] が確定してしまいます。
すると2件目のような「b が無いオブジェクト」が不合格になる。
サンプルとして与えたデータ自身が、生成したスキーマで弾かれるわけです。
正しくは、required は全オブジェクトの共通キーだけにします。
つまり和集合ではなく積集合。
下記が合流部分です。
var ra = a.required || [], rb = b.required || [];
var req = ra.filter(function (k) { return rb.indexOf(k) >= 0; });
var o = { type: 'object', properties: props };
if (req.length) o.required = req;
return o;
a と b は合流させたい2つのオブジェクトのスキーマ節です。
properties(キーごとの型)は和集合で、両方にあるキーは再帰的に合流させます。
required だけが積集合になる。
入力 [{"a":1,"b":2},{"a":3}] に「キーを必須にする」を有効にして通すと、出力はこうなります。
手元で実際に生成させた結果をそのまま貼ります。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"items": {
"type": "object",
"properties": { "a": { "type": "integer" }, "b": { "type": "integer" } },
"required": ["a"]
}
}
properties には a と b の両方が載り、required は a だけ。
これなら元データの2件ともが合格します。
properties は和集合、required は積集合。
方向が逆なのは、片方が「どんな形を許すか」でもう片方が「何を必ず要求するか」だからです。
許容は広く、要求は狭く取る。
サンプルという不完全な観測から出発している以上、これが安全側の倒し方だと思っています。
format も同じ考えで絞った
文字列に format(date や email といった書式の注釈)を付ける機能も入れましたが、
判定するのは誤検出しにくい4つだけにしました。
function detectStringFormat(s) {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return 'date';
if (/^\d{4}-\d{2}-\d{2}[Tt][^\s]*(Z|z|[+-]\d{2}:\d{2})$/.test(s)) return 'date-time';
if (/^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(s)) return 'email';
if (/^https?:\/\/\S+$/.test(s)) return 'uri';
return null;
}
s は文字列の値そのもので、当てはまらなければ null を返して format を付けません。
電話番号や UUID(重複しないよう生成された36文字の識別子)も判定できそうなのですが、外したときに「本番データが弾かれる」側へ倒れるので入れませんでした。
制約は足すほど嘘になりやすい。
ツールは下記に置いてあります。
https://hashitosystem.com/tools/jsonschema/
ブラウザの中だけで動くので、貼った JSON はどこにも送られません。
仕事の JSON を貼れる、というのが個人的にはこのツールを作った最大の理由です。
推論ツールを評価するときは、[{"a":1,"b":2},{"a":3}] を貼ってみるのが手っ取り早い。
required に b が入るツールは、そのサンプル自身を弾くスキーマを吐いている。
本記事はAI補助で執筆した、個人開発の紹介記事です。