SimpleSchema は JSON の検証と各データ構造への設定を行うライブラリです。
HTTP の API サーバを書いていると、よく HTTP の POST リクエストで JSON を受け取ることがありますが、この値が正しいフォーマットかどうかを検証したいことがあります。 検証だけなら、JSON Schema を使うという手があり、幸いなことに Elixir には JSON Schema を実装した ExJsonSchema というライブラリがあります。
しかし、JSON Schema は手で書くのが大変です。もう少し機能を絞った単純なスキーマを使いたいところです。
また、JSON Schema は検証しか行わないため、データにアクセスするのに手間が掛かります。
json = Poison.decode!(conn.body_param)
:ok = validate(json)
hp = json["player"]["hp"]
# → json.player.hp と書きたい
datetime = json["datetime"] # 文字列を取り出して
{:ok, datetime, _} = DateTime.from_iso8601(value) # DateTime 型に変換する
# → json.datetime した段階で DateTime 型であって欲しい
このように、特に DateTime
に変換するといった処理が必要な場合、検証と同時に変換まで済ませたいのです。
そこで、簡単にスキーマを書けるようして、検証を行い、それらのデータを変換するライブラリとして、SimpleSchema というライブラリを作りました。
以下のように使います。
# defschema/1 を使ってスキーマを定義する
defmodule Person do
import SimpleSchema, only: [defschema: 1]
defschema [
name: :string,
age: {:integer, minimum: 0},
]
end
# JSON 文字列をデコードしたデータを…
json = %{
"name" => "John Smith",
"age" => 42,
}
# Person と一緒に from_json!/2 すると、Person 構造体に値が設定される
person = SimpleSchema.from_json!(Person, json)
assert person.name == "John Smith"
assert person.age == 42
このように、defschema/1
でスキーマを定義して、SimpleSchema.from_json!/2
にそのスキーマと JSON オブジェクトを渡すと、JSON オブジェクトを検証し、指定したスキーマにデータを入れてくれます。
JSON オブジェクトが Person
スキーマを満たしていない場合、以下のようにエラーがでます。
bad_json = %{
"name" => 100, # 文字列ではない
"age" => -10, # 無効な年齢
"__additional_key__" => 0, # 余分なキー
}
# from_json/2 は失敗する
{:error, reason} = SimpleSchema.from_json(Person, bad_json)
IO.inspect reason
出力:
[{"Expected the value to be >= 0", "#/age"},
{"Type mismatch. Expected String but got Integer.", "#/name"},
{"Schema does not allow additional properties.", "#/__additional_key__"}]
この Person
を内包するスキーマを定義することもできます。
つまりスキーマはネスト可能です。
# Person を内包する Group
defmodule Group do
import SimpleSchema, only: [defschema: 1]
defschema [
group_name: :string,
persons: [Person],
]
end
json = %{
"group_name" => "A Group",
"persons" => [%{
"name" => "John Smith",
"age" => 42,
}, %{
"name" => "YAMADA Taro",
"age" => 20,
}],
}
group = SimpleSchema.from_json!(Group, json)
assert group.group_name == "A Group"
assert Enum.fetch!(group.persons, 1).age == 20
Group
スキーマを defschema/1
で定義していますが、:group_name
が文字列であり、:persons
が Person
の配列であることが、見ればすぐに分かるでしょう。
これによって、共通するスキーマに名前を付けて再利用することができます。
もう少し詳細に SimpleSchema の機能を説明します。
SimpleSchema ライブラリが定義している、SimpleSchema.from_json/2
の第1引数に渡せるスキーマのことを シンプルスキーマ と呼びます。
JSON Schema と比べると大分単純で直感的な構文になっているので「シンプル」と名付けています。
例えば、:integer
はシンプルスキーマです。
value = SimpleSchema.from_json!(:integer, 10)
assert value == 10
:integer
シンプルスキーマは、渡された値が整数であるかを確認し、整数であればその値を戻り値にします。
整数に制約を付け加えることも可能です。
value = SimpleSchema.from_json!({:integer, minimum: 10, maximum: 20}, 5)
# RuntimeError: [{"Expected the value to be >= 10", "#"}]
{:integer, opts}
という書き方もシンプルスキーマになります。
これは渡された値が整数であり、かつ10から20の範囲内であるかを確認し、正しければその値を戻り値にします。
%{}
という書き方もシンプルスキーマであり、各フィールドには、更にシンプルスキーマを渡すことができます。
schema = %{
value: {:integer, optional: true},
point: %{
x: :integer,
y: :integer,
},
}
data = %{
"point" => %{
"x" => 10,
"y" => 20,
}
}
value = SimpleSchema.from_json!(schema, data)
# value == %{point: %{x: 10, y: 20}}
assert value.point.x == 10
assert value.point.y == 20
このシンプルスキーマは、渡された値がマップであるかを確認し、渡された値の各フィールドが、指定したシンプルスキーマのフィールドと合っているかどうか確認します。 正しければ、渡されたマップのキーを atom にした上で戻り値にします。
また、:value
フィールドに optional: true
という制約を付与しました。
これはマップのフィールドに渡すシンプルスキーマのみに指定可能で「このフィールドが無くてもエラーにしない」という意味になります。
そのため data
に "value"
キーが存在していなくても SimpleSchema.from_json!/2
が成功しています。
シンプルスキーマは、以下のいずれかである必要があります。
:boolean
または{:boolean, opts}
:integer
または{:integer, opts}
:number
または{:number, opts}
:null
または{:null, opts}
:string
または{:string, opts}
:any
または{:any, opts}
%{...}
または{%{...}, opts}
[...]
または{[...], opts}
SimpleSchema
ビヘイビアを実装したモジュール、または{Module, opts}
opts
には各制約をキーワードリストで指定します。
制約の一覧は以下の通りです。
{:nullable, boolean}
: もしtrue
ならnil
を許可する。:null
以外のシンプルスキーマに指定可能。{:minimum, integer}
: 最小値。:integer
と:number
に指定可能。{:maximum, integer}
: 最大値。:integer
と:number
に指定可能。{:min_items, non_neg_integer}
: 最小の要素数。:array
に指定可能。{:max_items, non_neg_integer}
: 最大の要素数。:array
に指定可能。{:unique_items, boolean}
: もしtrue
なら配列がユニークであることを要求される。:array
に指定可能。{:min_length, non_neg_integer}
: 最小の長さ。:string
に指定可能。{:max_length, non_neg_integer}
: 最大の長さ。:string
に指定可能。{:enum, [...]}
: 要素に指定可能な値のリスト。:integer
と:string
に指定可能。{:format, :datetime | :email}
: 事前に定義されたフォーマットで検証する。:string
に指定可能。{:optional, boolean}
: もしtrue
なら、%{...}
の子要素として必須では無い。%{...}
の子要素のみ指定可能。{:tolerant, boolean}
: もしtrue
なら、生成される JSON Schema に"additionalProperties"
が設定される。つまり子要素に指定されてない要素を許可するようになる。%{...}
に指定可能。デフォルトはfalse
。{:default, any}
: フィールドのデフォルト値。渡された JSON にこのフィールドが存在しなかった場合はこの値になる。%{...}
の子要素のみ指定可能。{:field, string}
: 対応する JSON のフィールド名。%{...}
の子要素のみ指定可能。
シンプルスキーマや制約以外の情報は opts
に :meta
キーを使って追加します。
schema = %{
value: {:integer, optional: true},
point: %{
x: {:integer, meta: %{description: "x座標"}},
y: {:integer, meta: %{description: "y座標"}},
},
}
SimpleSchema
ビヘイビアを実装したモジュールは、シンプルスキーマになります。
これを使うことで、特定のスキーマに名前を付けたり、特定の構造体に変換できるようになります。
例えば、"2017-11-27T11:49:50+09:00"
といった ISO 8601 に従った日付を DateTime
型として取得するには、以下のように定義します。
defmodule DateTimeSchema do
@behaviour SimpleSchema
@impl SimpleSchema
def schema(_opts) do
{:string, format: :datetime}
end
@impl SimpleSchema
def from_json(_schema, value, _opts) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _} -> {:ok, datetime}
{:error, reason} -> {:error, reason}
end
end
@impl SimpleSchema
def to_json(_schema, value, _opts) do
{:ok, DateTime.to_iso8601(value)}
end
end
DateTimeSchema
は SimpleSchema
ビヘイビアを実装しているためシンプルスキーマになります。
そのため、以下のように SimpleSchema.from_json!/2
に渡すことができます。
datetime = SimpleSchema.from_json!(DateTimeSchema, "2017-11-27T11:49:50+09:00")
# datetime == #DateTime<2017-11-27 02:49:50Z>
このように SimpleSchema
ビヘイビアを実装することで、特定のスキーマに DateTimeSchema
というを付け、DateTime
型の構造体に変換して利用できるようになります。
なお、上記の DateTimeSchema
に相当する機能は既に SimpleSchema.Type.DateTime
に入っています。
SimpleSchema
ビヘイビアが要求する関数は、以下の通りです。
@callback schema(opts :: Keyword.t) :: simple_schema
@callback from_json(schema :: simple_schema, json :: any, opts :: Keyword.t) :: {:ok, any} | {:error, any}
@callback to_json(schema :: simple_schema, value :: any, opts :: Keyword.t) :: {:ok, any} | {:error, any}
schema/1
で、そのモジュールが要求するシンプルスキーマを定義します。
from_json/3
で、value
を任意の型に変換して返します。
value
は schema/1
で返したシンプルスキーマによる検証が済んでいて、例えば上記の DateTimeSchema.from_json/3
に渡された value
は、{:string, format: :datetime}
で検証されています。
そのため value
が文字列であり、:datetime
のフォーマットであることが保証されています。
ただし、SimpleSchema.from_json/2
に optimistic: true
が指定されていた場合、検証を行いません。この場合、正しい値を渡す責任はユーザにあります。
to_json/3
で、変換された値をシンプルスキーマの満たす文字列に変換します。
from_json/3
と逆の変換を行います。
この関数は SimpleSchema.to_json/2
の内部で利用される関数なので、不要であれば常に {:error, "not implemented"}
でも構いません。
defschema/1
は、defstruct/1
による構造体の定義と、SimpleSchema
ビヘイビアの実装を行います。
defmodule Person do
import SimpleSchema, only: [defschema: 1]
defschema [
name: :string,
age: {:integer, minimum: 0},
]
end
このコードは、以下の様に変換されます。
defmodule Person do
@enforce_keys [:name, :age]
defstruct [:name, :age]
@behaviour SimpleSchema
@impl SimpleSchema
def schema(_opts) do
%{
name: :string,
age: {:integer, minimum: 0},
}
end
@impl SimpleSchema
def from_json(schema, value, _opts) do
SimpleSchema.Type.json_to_struct(__MODULE__, schema, value)
end
@impl SimpleSchema
def to_json(schema, value, _opts) do
SimpleSchema.Type.struct_to_json(__MODULE__, schema, value)
end
end
schema/1
で、:name
と :age
を持つマップとしてシンプルスキーマを定義しています。
渡された JSON のオブジェクトがこのシンプルスキーマの構造になっているかを検証した後、Person.from_json/3
を呼び出して、value
を Person
構造体に変換しています。
JSON のオブジェクトを特定の構造体に変換するためのヘルパーとして SimpleSchema.Type.json_to_struct/3
があるので、これを使うと簡単に変換できます。