RFC 8927: JSON Type Definition を読んだ
RFC Editor を眺めていたら JTD と呼ばれるものを見つけたので読んでみました。CDDL を調べながら原著を読めばわかるので自分なりに理解したことをまとめる感じの記事です。
RFC 8927: JSON Type Definition
全体の構成
トップレベルの構造は root-schema
といい、 任意のメンバ definitions
と必須のメンバ schema
から成ります (schema
の定義については後述します)。
CDDL の代わりに無理矢理 TypeScript っぽく書くと(?)次のような形です。
RootSchema: {
definitions?: { [key: string]?: Schema, [key: string]?: Schema, ... },
schema: Schema,
}
Schema
schema
は JTD の主要な部分を表現する型として頻繁に登場します。 8つの form
と呼ばれる塊をグループ化して名前をつけたもので、実態としては以下のように ref
, type
, enum
, ... のいずれかです(//
は定義を展開しつつ候補からの選択を表現する CDDL の演算子)。
schema = (
ref //
type //
enum //
elements //
properties //
values //
discriminator //
empty //
)
以降の節では、それぞれの form
の定義について書き下していきます。
Empty
empty
は TypeScript で言うところの any
にあたるものだと思います。原著の 3.3.1. Empty では The "empty" form is meant to describe instances whose values are unknown, unpredictable, or otherwise unconstrained by the schema.
と説明されています。
名前は empty
ですが、 shared
と呼ばれるオプション用のオブジェクトを渡すことができるため、完全な空オブジェクトではないパターンも許容します。
// schema
// OK
{}
{ "nullable": true }
{ "nullable": false, "metadata": { "some": "metadata" } }
// NG
{ "nullable": "apple" }
Ref
ref
はルートのスキーマの定義を参照することを表します。前提として root-schema 上に definitions
が存在することが必要です。
定義的には次のようなものも通ってしまうのですが、基本的には properties
など高級な form の中で使うものだと思います。
2021/3 追記: 作り上 schema を validate した時に初めて検証されるので、上記は誤りです。 様々なパターンについては jsontypedef/json-typedef-spec を見た方が良さそうです。
# used a ruby implementation:
# https://github.com/jsontypedef/json-typedef-ruby
schema = JTD::Schema.from_hash({ "definitions" => { foo: {} }, "ref" => "foo" })
# => #<JTD::Schema:0x00...>
// schema
{
"definitions": {
"coordinates": {
"properties": {
"lat": { "type": "float32" },
"lng": { "type": "float32" }
}
}
},
"properties": {
"user_location": { "ref": "coordinates" },
"server_location": { "ref": "coordinates" }
}
}
Type
type
は一般的な静的型付け言語にもあるような値の型で、それぞれの型に対する具体的な説明は原著にあります。
型そのものに対しては説明することはないのですが、float
に対応する説明には a JSON number
とあり、これに関しては知らなかったことがたくさんありました。
Enum
enum
は列挙型です。要素数1以上の文字列のリストとして値の候補を与えることができます。
// schema
// OK
{ "enum": ["apple", "banana"] }
// NG
{ "enum": [] }
Elements
elements
は与えた schema
を要素とする配列であることを定義します。
あるオブジェクトの中の v
の値に、 enum
に含まれる文字列の配列を期待する場合の例はこんな感じです。
schema = JTD::Schema.from_hash({
'properties' => {
'v' => {
'elements' => { 'enum' => ['foo', 'bar', 'baz'] }
}
}
})
# エラーがなければ空配列が返る
JTD::validate(schema, {
'v' => ['foo', 'bar']
})
# => []
Properties
properties
は任意の名前のキーに対して任意の schema
が対応するメンバを複数持つ構造体ライクなオブジェクトを表現します。
メンバは必須または任意として定義でき、さらに追加でメンバを持たせることを許容できます。 必須と任意のメンバに重複するキーを存在させることはできません。
// schema
// OK
{
"properties": {
"users": {
"elements": {
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"created_at": { "type": "timestamp" }
},
"optionalProperties": {
"updated_at": { "type": "timestamp" }
}
}
}
}
}
// NG
{
"properties": { "conflict_key": {} },
"optionalProperties": { "conflict_key": {} }
}
Values
values
は任意の名前のキーに対して単一の schema
が対応する要素を格納する連想配列ライクなオブジェクトを表現します。 値の型が同一になる Hash(Ruby) か Object(JavaScript) みたいなイメージで捉えています。
// schema
{
"values": {
"type": "float32"
}
}
// instance
// OK
{}
{ "a": 1, "b": 2 }
// NG
null
{ "a": 1, "b": 2, "c": "foo" }
Discriminator
discriminator
は Discriminated Union を記述するためのもので、 Polymorphism を表現できるのがいいみたいです。この記事がわかりやすかったです。
スキーマとインスタンスに登場するメンバを、原著中の D, M, I, S に対応させると、次のようになります。
- D : schema において discriminator として定義された値、 "何をオブジェクトの識別に使うのか" に対しての名前
- M : schema における mapping に対応する値、オブジェクトの候補
- I : instance における D と同名のメンバ
- S : I と同名の M のメンバ
// schema
{
"discriminator": "version", // D
"mapping": { // M
"v1": {
"properties": {
"a": { "type": "float32" }
}
},
"v2": { // S
"properties": {
"a": { "type": "string" }
}
}
}
}
// instance
{
"version": "v2", // I
"a": "foo"
}
まとめ
JSON Type Definition の RFC を読んで、自分なりにまとめました。
より細かいルールや実装系のことを考えるには必須のエラーの構造などには触れませんでしたが、再帰的な表現を多用することで言語仕様としてコンパクトにまとまっているなという印象でした。
個人的には RFC ちゃんと読むの初めてだったので勉強になりました。 JTD はまだ Experimental というカテゴリにあるものでしたが、すでに標準になっている定番にもチャレンジしたいです。