0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSONからスキーマを起こすとき、required は積集合にしないと嘘になる

0
Posted at

背景

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;
}

itemsnull から始めて、要素を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;

ab は合流させたい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 には ab の両方が載り、requireda だけ。
これなら元データの2件ともが合格します。

properties は和集合、required は積集合。
方向が逆なのは、片方が「どんな形を許すか」でもう片方が「何を必ず要求するか」だからです。
許容は広く、要求は狭く取る。
サンプルという不完全な観測から出発している以上、これが安全側の倒し方だと思っています。

format も同じ考えで絞った

文字列に formatdateemail といった書式の注釈)を付ける機能も入れましたが、
判定するのは誤検出しにくい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}] を貼ってみるのが手っ取り早い。
requiredb が入るツールは、そのサンプル自身を弾くスキーマを吐いている。


本記事はAI補助で執筆した、個人開発の紹介記事です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?