marshmallowのコード生成でadditionalPropertiesをサポートするのがだるかった話し
最近作っている swagger-marshmallow-codegen というライブラリでswaggerの additionalProperties
に対応するのがだるかったという話。
additionalProperties?
additionalProperties
というのはjsonschemaの方にもある。このあたりに書いてあるので読めばどういうものかわかる。
additionalPropeties 取りうる値がbooleanだけだと思っていたので普通に読み飛ばしていたのだけれど。実際にはobjectも受け取る。
booleanの例は例えば以下の様な形。
definitions: Named: properties: name: type: string additionalPropeties: false
これは、schemaに定義されていない余分な(additional)propetyがやってきたときの挙動を決めるもの。falseだと余分なpropetyを許さない。trueの場合は許す(通常のjsonschemaはdefaultではtrue)。
ところで、このような理解で、additionalPropeties
にはboolean以外受け取れないと思っていた。ところがオブジェクトが取れる。オブジェクトを渡すとどのように解釈されるのかというと、余分なpropetyは全てここで指定されたschemaでvalidationされる。例えば以下の様にすると余分なpropetyは必ずintegerであることを期待する。
definitions: Box: properties: name: type: string additionalPropeties: type: integer
実際、swaggerの仕様にも使われるjsonschemaの方にもしっかりとbollean以外も取ると書いてあった。気づかなかった。
marshmallowでadditionalPropetiesをシミュレートする
この additionalPropeties
を marshmallow でシミュレートしたい。marshmallowというのはpython製のschemaライブラリ。pythonの世界には数多くのschemaライブラリがあるがなんとなくこのライブラリを使っている。
ところが1つ問題があって、marshmallowはこの additionalPropeties
のような機能と相性が悪い。通常のfieldが固定のfieldなら何の問題も無くswagger specからmarshmallowのschemaのコードに変換出来る。
固定のfieldを持つschemaの場合
例えば以下の様な固定のfieldを持つschemaの定義であれば簡単。
definitions: person: propeties: name: type: string age: type: integer required: - name
このようなswagger specは以下のようなmarshmallowのコードに変換できる。
from marshmallow import Schema, fields class Person(Schema): name = fields.String(required=True) age = fields.Integer()
additionalPropetiesが指定されているschemaの場合
一方で additionalPropeties
が指定されているschemaの場合は状況が変わる。というのも、marshmallowの実装が、クラスに指定されたfieldsの指定をkeyにiterateしてdeserialization(JSONの表現からparseしてpythonのdictを作ること。このタイミングでvalidationも行われる)する。従ってfieldsを見てiterateの範囲を決めるのではなく、渡された値を見てiterateの範囲を決める必要がある挙動に関しては一筋縄ではいかない。
marshmallowにも additional
という余分な値を指定する機能はあるが、こちらも結局明示的に指定する必要がある。
from marshmallow import Schema, fields class Person(Schema): name = fields.String(required=True) age = fields.Integer() class Meta: additional = ("nickname",) print(Person().load({"name": "foo", "age": "20", "nickname": "foo"})) # UnmarshalResult(data={'age': 20, 'name': 'foo', 'nickname': 'foo'}, errors={})
(ところで、実は上のPersonのSchemaも完全にjsonschema(swagger spec)のそれと等価ではない。jsonschemaはadditionalPropetiesがdefaultでtrueという仕様ではあるけれど。marshmallowの場合はadditionalProptiesがfalseとまでは言わないものの。schemaのfieldの定義中に存在しないpropetyは消されてしまう)
# 上のPerson schemaのMeta.additioinalを取り除いたもの print(Person2().load({"name": "foo", "nickname": "foo"}) # UnmarshalResult(data={'name': 'foo'}, errors={})
なんとかadditionalProptiesをサポートする
pre_load
や pre_dump
などのフックを使ってなんとかadditionalProptiesをサポートするようなSchemaを書く事はできて以下の様になる。
from marshmallow import Schema, SchemaOpts, fields from marshmallow import pre_load, post_load, pre_dump, post_dump class AdditionalPropertiesOpts(SchemaOpts): def __init__(self, meta, **kwargs): super().__init__(meta, **kwargs) self.additional_field = getattr(meta, "additional_field", fields.Field) class AdditionalPropertiesSchema(Schema): """ support addtionalProperties class MySchema(AdditionalPropertiesSchema): class Meta: additional """ OPTIONS_CLASS = AdditionalPropertiesOpts @pre_load @pre_dump def wrap_dynamic_additionals(self, data): diff = set(data.keys()).difference(self.fields.keys()) for name in diff: f = self.opts.additional_field self.fields[name] = f() if callable(f) else f return data def dumps(self, obj, many=None, update_fields=False, *args, **kwargs): return super().dumps(obj, many=many, update_fields=update_fields, *args, **kwargs) def dump(self, obj, many=None, update_fields=False, *args, **kwargs): return super().dump(obj, many=many, update_fields=update_fields, *args, **kwargs)
deserialization/serializationのときのiterateの範囲は、fieldsの設定を見るので、余分なfieldsの分fieldを追加してあげる(update_fields=Falseはself.fieldsをミニマムな定義に保つための機構が働いてしまうので無効にするためのオーバーライド)。
例えばこういうswagger specは
definitions: Box: properties: name: type: string additionalProperties: $ref: "#/definitions/value" value: type: integer
以下の様な形に翻訳できるようになった。
class Box(AdditionalPropertiesSchema): name = fields.String() class Meta: additional_field = fields.Integer()
実際動く。
print(Box().load({"name": "foo", "x": "100", "y": "200"})) # UnmarshalResult(data={'x': 100, 'name': 'foo', 'y': 200}, errors={}) print(Box().load({"name": "foo", "x": "ababa", "y": "200"})) # UnmarshalResult(data={'name': 'foo', 'y': 200}, errors={'x': ['Not a valid integer.']})
ところで、元々の始まりはコード生成だったのでこれで終わりというわけにはならない。swagger specをparseしてこのschemaのコードを生成してあげる必要がある。
swagger specからschemaのコードの生成
幸い動かせる様になったので生成出来るようになっている。例えばこういう感じのことができなくちゃいけない。
$ cat <<-EOS > box2.yaml definitions: Box: properties: name: type: string additionalProperties: \$ref: "#/definitions/value" value: type: integer EOS # pip install swagger-marshmallow-codegen $ swagger-marshmallow-codegen box2.yaml INFO: swagger_marshmallow_codegen.codegen:write schema: write Box INFO: swagger_marshmallow_codegen.codegen: write field: write name, field=fields.String INFO: swagger_marshmallow_codegen.codegen: write field: write additional_field, field=fields.Integer INFO: swagger_marshmallow_codegen.codegen:write schema: skip value # -*- coding:utf-8 -*- # this is auto-generated by swagger-marshmallow-codegen from marshmallow import ( Schema, fields ) from swagger_marshmallow_codegen.schema import AdditionalPropertiesSchema class Box(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.Integer()
先程作られたコードと同じようなものが生成できている。これを作るのが意外と大変でここでも何箇所かハマってしまった。
$ref を見る必要がある
上の例で既に書いてしまったが、$ref
を見る必要がある。当然 $ref
による参照が動くになる必要があるし。それは再帰的に指定されているかもしれない。
definitions: Box: properties: name: type: string additionalProperties: $ref: "#/definitions/x" x: $ref: "#/definitions/y" y: $ref: "#/definitions/value" value: type: integer
class Box(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.Integer()
$ref の先はprimitiveな値とは限らない
$ref の先はintegerやstringのようなprimitiveな値とは限らないかもしれない。例えばこういうような。
definitions: Box: properties: name: type: string additionalProperties: $ref: "#/definitions/value" Box2: properties: name: type: string additionalProperties: $ref: "#/definitions/Box" value: type: integer
Box2は additionalProperties
に Boxを取り、そのBoxは additionalProperties
に valueを取る。
class Box(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.Integer() class Box2(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.Nested('Box')
ところでこれも $ref がネストしているかもしれない(さらにallOfと組み合わせられているかもしれない)。
definitions: Box: properties: name: type: string additionalProperties: $ref: "#/definitions/value" Box2: properties: name: type: string additionalProperties: $ref: "#/definitions/x" x: $ref: "#/definitions/y" y: allOf: - $ref: "#/definitions/Box" properties: date: type: string format: date value: type: integer
class Box(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.Integer() class Box2(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.Nested('X') class Y(Box): date = Date() class X(Y): pass
$ref ができたらそれで終わりとはならないし即時定義にも対応する必要がある
$ref ができたらそれで終わりとはならないし。即時定義にも対応する必要がある。即時定義と言うのは自分で勝手に呼んでいる呼び方で適切な名前ではないかもしれない。以下の様なものを指している。propertiesの中に直接リテラル?のような形でschemaの定義が書かれているようなもののこと。
definitions: Box4: properties: box: type: object properties: name: type: string additionalProperties: type: string
これについては一度 $ref を使ったフラットな形式に変換してからコード生成に使っている。以下の様な中間表現に変換している。
$ swagger-marshmallow-codegen --driver Flatten box4.yaml definitions: Box4: properties: box: $ref: '#/definitions/Box4Box' Box4Box: type: object properties: name: type: string additionalProperties: type: string
上手く動いたのでもちろん変換できる。
class Box4(Schema): box = fields.Nested('Box4Box') class Box4Box(AdditionalPropertiesSchema): name = fields.String() class Meta(object): additional_field = fields.String()
と、ここで大丈夫そうと思って安心していたらまだダメだった。こういうschemaに対応できなかった。
definitions: Box5: type: object properties: box: additionalProperties: type: string
こうなってしまう。
class Box4(Schema): box = fields.Field()
これは何故かと言うと、objectの即時定義だと判定する処理がpropetiesが存在すること依存してしまっていた。このflatにする処理はdictknifeという別のライブラリに依存していたのでそちらも治す必要があった。
実際フラットにしたつもりの処理で以下のようなyamlになってしまっていた(まったくフラットになっていない)。
definitions: Box4: properties: box: type: object additionalProperties: type: string
もちろん直したので今は動く。
class Box4(Schema): box = fields.Nested('Box4Box') class Box4Box(AdditionalPropertiesSchema): class Meta(object): additional_field = fields.String()
あと実はこの時以下の様なコードが生成されてしまっていたので直したりもした。
class Box4(Schema): box = fields.Nested('Box4Box') class Box4Box(AdditionalPropertiesSchema): pass # 定義が空だと思ったっぽい class Meta(object): additional_field = fields.String()
だるい感じ。