jsonschema/OpenAPI Specでのstrictなschema定義の大変さとPolicy as Codeについて

なんとなく自分の中でのopenAPIについての総括的な行いをしたくなってきた。ちなみに雑感的なものは最近tweetした。ただし今回はその手前でschemaに関する事柄についてまとめてみようと思った。

jsonschemaとOpenAPI Spec

ここで言及しているjsonschemaとOpenAPI Specはこれらのこと。

ただしOAS(OpenAPI Spec)の利用経験的にはOAS3.0.xではなくOAS2.0を利用してのことが多くなるかもしれない。そしてjsonschemaに関してはフルの仕様の話ではなくあくまでOASに関連するものだけ。

strictなschemaとは?

strictなschemaとは?というところから端を発して、なぜjsonschemaが辛くなるのかというところまで話を進められたら良い。そして最終的にPolicy as Codeで導入されるpolicyやruleとの関係性について言及できたらなーと思っている。

正しいデータでエラーを返さないというのは手軽に達成できる

まず最初に正しいデータでOKを返すところから考えてみる。例えば以下のような値を取るPersonというデータ型を例に考えていく。

person.json

{
  "name": "foo",
  "age": 20
}

nameはstringでageはinteger。これをjsonschemaとして表すと以下の様になる(ただしめんどくさいのでYAMLで表す)。

00schema.yaml

type: object
properties:
  name:
    type: string
  age:
    type: integer

このschema(00schema.yaml)は先程のデータ(person.json)に対してvalidな定義。手元で確かめるためにpython用のコード(validate.py)を追加して試してみる (実際のコードなどは記事の最後にある付録部分を参照)。

$ python validate.py --schema 00schema.yaml --data data/person.json

エラーはなし。

値の型がおかしいことの検証はほぼ無料で付いてくる

先程のschema(00schema.yaml)で、型の指定は書いたので型が意図とは異なる値が渡された場合のvalidationは無料で付いてくる。なので以下の様なデータはvalidation errorとして報告される。

person-ng.json

{
  "name": "foo",
  "age": "20"
}

ここまではOK。想定通り。

$ python validate.py --schema 00schema.yaml --data data/person-ng.json
'20' is not of type 'integer'

Failed validating 'type' in schema['properties']['age']:
    OrderedDict([('type', 'integer')])

On instance['age']:
    '20'

requiredな値を忘れた場合のエラーには不足

通常期待するデータの中には必須なもの(requiredなもの)が存在する。例えば先程のデータ(person.json)について、nameは必須(required)であるとする。先程のschema定義ではnameが不足している場合であってもvalidationは素通りしてOKになる。

person-missing-name.json

{"age": 20}

nameは必須ではないのでこれもOK。requiredの指定が必要。

$ python validate.py --schema 00schema.yaml --data data/person-missing-name.json

とはいえここまでは誰しもが気づく話で、requiredの指定を忘れずにというだけの話。requiredな部分を追加する。

01schema.yaml

type: object
properties:
  name:
    type: string
  age:
    type: integer

# 追加
required:
  - name

これでrequiredなfield(name)の不足には気付ける様になる。

$ python validate.py --schema 01schema.yaml --data data/person-missing-name.json
'name' is a required property

Failed validating 'required' in schema:
    OrderedDict([('type', 'object'),
                 ('properties',
                  OrderedDict([('name', OrderedDict([('type', 'string')])),
                               ('age',
                                OrderedDict([('type', 'integer')]))])),
                 ('required', ['name'])])

On instance:
    OrderedDict([('age', 20)])

もちろんrequiredなfieldのtypoにも気付ける。

optionalな値の不足について

ところでrequiredな値だけではなく、optionalな値(必須ではない値)に関してはどうだろう?optionalな値が意図せず不足というのは特に名前のtypoという形で起きる。

たとえばageのかわりにxageというfield名になっていた場合など。

person-age-is-typo.json

{
  "name": "foo",
  "xage": 20
}

このような場合に先程のrequiredを追加したschema(01schema)ではエラーとして判定されない。

$ python validate.py --schema 01schema.yaml --data data/person-age-is-typo.json

これはjsonschemaの仕様が可能な限りデータに対して柔軟で値の欠損が起きないように扱おうという方針のため。

ここで存在できる値の範囲を狭めるために additionalProperties という属性にfalseを指定する (swagger/OpenAPIを利用したコード生成ツールによってはこのadditionalPropertiesの取扱いがまちまち。特にdefault値がなんであるかは実装依存の近い。余分な値の取扱い方は、jsonschemaに従いdefaultをtrueとして扱うものと単に無視するものとdefaultをfalseとして扱うものの3種類が存在する。ちなみにOAS3.0ではdefaultはtrueということになっている)

02schema.yaml

type: object
properties:
  name:
    type: string
  age:
    type: integer
required:
  - name

# 追加
additionalProperties: false

additionalProperties=false で余分な値を許さないという制約が加わることになる。これによってoptionalな値のtypo(たとえば先程のデータでageをxageとtypo)に対してもエラーとして扱うことができる。この指定が無い場合には静かにオプション指定が無視されるという挙動になり、動いているのか動いていないのかわからない利用する側としてはストレスの溜まるふるまいをすることになる。

$ python validate.py --schema 02schema.yaml --data data/person-age-is-typo.json
Additional properties are not allowed ('xage' was unexpected)

Failed validating 'additionalProperties' in schema:
    OrderedDict([('type', 'object'),
                 ('properties',
                  OrderedDict([('name', OrderedDict([('type', 'string')])),
                               ('age',
                                OrderedDict([('type', 'integer')]))])),
                 ('required', ['name']),
                 ('additionalProperties', False)])

On instance:
    OrderedDict([('name', 'foo'), ('xage', 20)])

schemaの定義外であるものの付与したまま残したいメタデータもある

APIに対する入力データという意味ではstrictであればあるほど間違いが少ない。一方で例えばmiddlewareで管理したくなるような副次的なデータはこのschemaの検証を通過した後も保持されて欲しいという場合がある。このような場合はadditionalProperties=falseを指定したくない。

例えばquota-userdeprecated,nullableのような指定。

person-with-quota-user.json

{
  "name": "foo",
  "age": 20,
  "x-quota-user": "me"
}

additionalPropertiesによりoptionalな値の取りうるkeyの範囲を制限した結果、すべてのkeyの取りうる範囲を制限したということになるので、当然付加的なデータの装飾も全部エラー扱いになる。

$ python validate.py --schema 02schema.yaml --data data/person-with-quota-user.json
Additional properties are not allowed ('x-quota-user' was unexpected)

Failed validating 'additionalProperties' in schema:
    OrderedDict([('type', 'object'),
                 ('properties',
                  OrderedDict([('name', OrderedDict([('type', 'string')])),
                               ('age',
                                OrderedDict([('type', 'integer')]))])),
                 ('required', ['name']),
                 ('additionalProperties', False)])

On instance:
    OrderedDict([('name', 'foo'), ('age', 20), ('x-quota-user', 'me')])

この付加的なデータの取扱いと同様の関係がOASとOpenAPI Documentの間にも存在している(OASで記述したAPI仕様ドキュメントをOpenAPI Documentという。OASとの違いを混同すると分かりづらくなるので注意)。

OpenAPI Specそれ自体が(一応は)jsonschemaによって定義されていて、その定義の中でx-を付与したオプションによる指定は全て許可するという形になっている。というわけで今作っているschemaに関しても同様のふるまいを追加してみる。

03schema.yaml

type: object
properties:
  name:
    type: string
  age:
    type: integer
required:
  - name
additionalProperties: false

# 追加
patternProperties:
  "^x-":
    type: string

今度はOK。

$ python validate.py --schema 03schema.yaml --data data/person-with-quota-user.json

(ちなみに厳密にはpatternPropertiesの取扱いについてはOASに直接的は書かれていない。一方でJSON Schema Specification Wright Draft 00をベースにするという形で間接的に言及はされているので、サポートしているのなら同様の仕様で動くということになる(はず))。

(schemaのformat error, dataのformat errorがあるのが地味に面倒)

そしてこの種のstrictさの話は、OASそれ自体の指定にも掛かっている。invalidなschema定義に対するlint的なもののサポートで完全なものが少ない(例えばOpenAPIの公式にあるjsonschemaの定義は対応漏れがポロポロとあったりした。いまだに仕様だけで言及されているものも多くある(はず))。

OAS2からOAS3でのこの種の明らかな改善は以下のようなものがある。

  • nullableに対する取扱い(nullableフィールドの追加が3.0で追加された)
  • typeに対する取扱い(jsonschemaはフルのオブジェクトが指定できる。arrayなど。一方OASは文字列のみということを3.0では明言)

もちろんこれらの定義がjsonschemaで記述されているからラッキーということで安易にlintとして利用しても良いのだけれど。その場合には不整合に苦しんだり、あるいはOAS自体の定義が長いのでエラーの箇所が特定し辛いということになったりもする。

(例えばvalidationErrorではなくschemaError。はたしてtypoなのかインデントがずれているのか)

jsonschema.exceptions.SchemaError: OrderedDict(
[('$allOf', [OrderedDict([('$ref', '#/definitions/person')]), OrderedDict([('type', 'object'), ('properties', OrderedDict([('father', OrderedDict([('$ref', '#/definitions/person')])), ('mother', OrderedDict([('$ref', '#/definitions/person')]))]))])])
]) is not valid under any of the given schemas

以下の切り分けが難しかったりする。

  • jsonschemaとしてinvalid
  • openAPIとしてinvalid
  • 作成しているプロジェクトの仕様としてinvalid

中にはjsonschema関連のツールにしれっと丸投げしていて挙動が違うものもあったりでツライ(利用できるのは最大公約数的なものだけになる)。

そして最終的には複雑な定義が複雑なエラーを

元の定義からだいぶ複雑な定義になってしまっていった。そして最終的にはこれによりエラーメッセージがひどくわかりにくくなってしまう。

現実には先程定義したようなschemaを参照したschemaを至る所で定義することになる。例えばpersonが両親(father,mother)を持つようにしてみる(最初の1人は親を持たない?ので両親はそれぞれoptionalということにする)。

04schema.yaml

definitions:
  person:
    type: object
    properties:
      name:
        type: string
      age:
        type: integer
    required:
      - name
    additionalProperties: false
    patternProperties:
      "^x-":
        type: string

# 追加
allOf:
  - $ref: "#/definitions/person"
  - type: object
    properties:
      father:
        $ref: "#/definitions/person"
      mother:
        $ref: "#/definitions/person"

additionalPropertiesを全体に波及させるためにはどうすれば良いだろう?全部にallOfを付けるのは現実的ではないかもしれない(allOf,anyOf,oneOf,notの取扱いは特にコード生成系のツールで怪しい挙動を持つ箇所の1つ。ちなみにOAS2.0でサポートされるのはallOfだけ)。

皆さんそもそもallOfで上手く行くと思いますか?

person-with-parents.json

{
  "name": "foo",
  "age": 20,
  "father": {
    "name": "boo"
  },
  "mother": {
    "name": "bar",
    "age": 40
  }
}

実行してみるとエラー。エラーです。

実はそれ以前の問題でallOfとadditionalProperties=falseの相性がものすごく悪い。たとえば先程渡したような一見まともにvalidになるように見えるデータとschemaがinvalidとして扱われてしまう。

$ python validate.py --schema 04schema.yaml --data data/person-with-parents.json
'father', 'mother' do not match any of the regexes: '^x-'

Failed validating 'additionalProperties' in schema['allOf'][0]:
    OrderedDict([('type', 'object'),
                 ('properties',
                  OrderedDict([('name', OrderedDict([('type', 'string')])),
                               ('age',
                                OrderedDict([('type', 'integer')]))])),
                 ('required', ['name']),
                 ('additionalProperties', False),
                 ('patternProperties',
                  OrderedDict([('^x-',
                                OrderedDict([('type', 'string')]))]))])

On instance:
    OrderedDict([('name', 'foo'),
                 ('age', 20),
                 ('father', OrderedDict([('name', 'boo')])),
                 ('mother', OrderedDict([('name', 'bar'), ('age', 40)]))])

エラーが長い。まぁそれは置いておいてエラーになった原因を説明すると、allOfの本来の意味は渡されたschemaの全てを満たすという意味(ちなみにoneOfは候補の中から厳密に1つだけに合致するという意味。anyOfは候補中のいずれかに合致するという意味)。

したがってperson schemaの外にあるfather,motherの両親を追加するschemaはadditionalProperties=falseで制限される範囲に含まれてしまいエラーになる。fatherもmotherもx-で始まってはいないので。

このような形で使う場合に実際に欲しいものはpreprocess的な$allOf あるいは extendという名前の何かかもしれない。つまるところallOfを使った瞬間にadditionalPropertiesは使えないと考えた方が良い。

そしてエラーが長い。エラーが長いということは、そもそもどこでエラーが起きたのか、原因はなんなのか?が曖昧になり全体を理解しなければ部分を理解できないという構造になってしまっている。

そしてPolicy as Code

jsonschemaベースのvalidationを良しとした場合のエラーメッセージの表現では原因の把握が困難になるという事はPolicy as Codeの記事を読んで感じたことだったりする(実はこの記事を読んでて環境中にDSLを増やしたくないなーという気持ちとjsonschema/OAS的な形で吸収できないかなーと考えたのがこの記事を書くきっかけになっていた)。

リンク先の記事からの検証の出力の抜粋。こちらではPolicy,Ruleなどの指定があることで検証に文脈を付加できる。

$ sentinel test -verbose
PASS - staging.sentinel
  PASS - test/staging/pass_not_aws.json
    trace:
      TRUE - staging.sentinel:15:1 - Rule "main"
        TRUE - staging.sentinel:17:3 - is_not_aws(k)
  PASS - test/staging/pass.json
    trace:
      TRUE - staging.sentinel:15:1 - Rule "main"
        FALSE - staging.sentinel:17:3 - is_not_aws(k)
        TRUE - staging.sentinel:17:20 - validate_region(v)
  PASS - test/staging/fail_aws_invalid_region.json
    trace:
      FALSE - staging.sentinel:15:1 - Rule "main"
 FALSE - staging.sentinel:17:3 - is_not_aws(k)
        FALSE - staging.sentinel:17:20 - validate_region(v)

仮にvalidationにモジュール的なものが存在し、関数のようなものが存在し、mixin的な形で合成可能できるという形になっていた場合には、もしかしたらエラーメッセージを文脈という形で切り分けることができる。かもしれない。一方でこれをjsonschemaにcompileされた表現で扱うということにしてしまった場合にははエラーが全て一度にやって来てしまう。ここがけっこうネックなのではという話。

つまるところjsonschema/openAPI Specは構造についてしか言及できず、runtimeでしか扱えず(preprocessは独自仕様となりあまりオススメはされず)という形で辛い。

また仮に最終的にjsonschemaに落ちるからポータブルという言葉があったとして、ポータブルというのは各環境に実装が存在するということが前提としてある必要がある(ちなみにpythonに関して言えばjsonschemaのv4までしかサポートしていない(現在はv7について審議中))。

ただ今までの経験上DSLを要求するということで成功したものをあまり見なかったりはするので、Sentinel(HasiCorpのPaCの処理系のこと)などが本当に上手く行くかどうかはわからない。

一応はallOfを使って似たようなことができなくもない

一応は先程のallOfの挙動を逆手に取って以下の様なコードを書くことはできたりはする。schema定義はallOfの先頭のschemaに寄せて細かなvalidation(rule)を2つ目以降に書いていく形式。

05schema.yaml(の一部)

allOf:
  - $ref: "#/definitions/person"
  - description: "名前の範囲を制限(in test)"
    properties:
      name:
        enum:
          - bar
          - boo

とはいえ先程言った通りにallOf,anyOf,oneOfは各種ツールで最も対応できていない仕様のうちの1つ。加えて通常のjsonschemaのvalidationではdescriptionをエラーメッセージに含めないのでエラーレポートの部分も自分自身の手でカスタマイズする必要がでてきそう。

python validate.py --schema 05schema.yaml --data data/person.json
'foo' is not one of ['bar', 'boo']

Failed validating 'enum' in schema['allOf'][1]['properties']['name']:
    OrderedDict([('enum', ['bar', 'boo'])])

On instance['name']:
    'foo'

そして冒頭のリンク先のエコシステムに関するtweetに戻る(雑感の話)。

付録 validation用のコード

ライブラリなどのインストール。

$ python -m pip install dictknife[load] jsonschema

validate.py

from dictknife import loading
import jsonschema
import argparse


parser = argparse.ArgumentParser()
parser.add_argument("--schema", required=True)
parser.add_argument("--data", required=True)

args = parser.parse_args()

schema = loading.loadfile(args.schema)
jsonschema.Draft4Validator.check_schema(schema)

data = loading.loadfile(args.data)
for err in jsonschema.Draft3Validator(schema).iter_errors(data):
    print(err)

以下の様にして使う。

$ python validate.py --schema 00schema.yaml --data data/person.json