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をシミュレートする

この additionalPropetiesmarshmallow でシミュレートしたい。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_loadpre_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()

だるい感じ。