swagger-marshmallow-codegen というライブラリを作りました
swagger-marshmallow-codegen というライブラリを作りました。swaggerの定義ファイルからmarshmallow のschemaを生成するライブラリです。
ライブラリ?
正確にはライブラリでは無くコマンドです。marshmallowのschema定義のコードを生成するコマンドです。
(この後、どうして作ったのかみたいな文章が続きます。 使い方が知りたいだけの人は 使い方 の部分まで読み飛ばしてください。)
何で作ったの?
元々、connexion という swaggerの定義ファイルを見てflaskのrouting設定をやってくれるライブラリを使っていました。このconnexionがrequestとresponseをjsonschemaレベルではvalidationしてくれるのですが。値の変換が関わるような処理を行おうと思った場合に別途schemaの定義(例えば、marshmallowのschema)が必要になってしまうためです。
jsonschemaを使ったvalidationは基本的にはJSONの値のチェックまでしか行いません。例えば日付を意味する"yyyy-mm-dd"というフォーマットの文字列があったとして、渡された値が適切なフォーマットに基づいたものであるかのチェックしか行いません。一方で、内部のコード上では、渡された値を、その言語の内部表現の値として扱いたい場合が結構あります(例えば、文字列ではなく datetime.date
のオブジェクトとして扱う)。この値の変換部分を含んだschemaの定義を作ってあげようというのが理由の1つです。
何でライブラリ(メタプログラミング)ではなくコマンド(コード生成)として作ったの?
コード生成とメタプログラミングとの違いを検証してみたいというのが事の発端でした。正直なところこれがやりたかったことの1つで手段と目的が逆だったりもします。通常、pythonで何らかの定義ファイルから良い感じの便利な挙動を行うコードを書く時にはメタプログラミングを使います。大抵の場合宣言的な記述が書けるという触れ込みの機構は内部にメタクラスを抱えて、設定から条件に合致したオブジェクトを生成するという仕組みになっている物が多いです。
これはこれで便利なのですが。以下の点が時折不満に感じます。
- そもそも内部でどのような処理が行われているか追いづらい
- 特定のオブジェクトの処理だけに暫定的に特別な処理を追加しづらい
メタプログラミングのつらい話
最初の頃は、ユースケースも単純で、メタプログラミングを使ったライブラリを便利便利と使っているのですが。時間の経過とともに細かな状況に対応しなければいけなくなっていくことが多く。この細かな修正の対応のためには結局隠蔽されて自動で上手くやってくれていたハズの内部の処理について詳しく把握する必要があったりすることがあります。そしてそれはおそらく必ずやってきます。
また、内部の詳細を把握したとしてもそれで終わりではありません。望みの挙動を得るために、ライブラリの作成者が想定している作法に則った形で修正する必要が出てくる時にこの作法とやりたい挙動の整合性を取ることに時間が割かれてしまったりします。その上、結局、望みの挙動を実現するためのフックポイントにあたる箇所が見つからず、最悪、再実装かforkするという対応になってしまいます。
コード生成への期待の話
これがコード生成だと変わります。コード生成はメタプログラミングを完全に置き換えることはできませんが。実行時に行っている処理の一部を実際のpythonコードに直接置き換えることができます(N個の対象があったらN個の記述が生成されるということ)。この処理自体は静的なコマンドの実行に過ぎないものですし。
なによりメタプログラミングのコードとは異なり、一般的な処理として1つにまとまっていたコードに対して、個別のコードの記述に分けてくれます。このため、何か困ったことがあったら直接コードにデバッガーを仕掛けることは簡単ですし。ピンポイントで行える様になります。
処理自体を追うことに関しても基本的には温かみのある手書きのコードを機械によって生成しているにすぎないので処理内容の記述は素直で単調です。おそらく読みやすいはずです。メタプログラミングを使ったコードを読む場合とは異なり、抽象度の高いよく分からない海の中を変数やメソッドの名前だけを頼りに泳ぐみたいなことはする必要はありません(特殊な最適化などをおこなったりしていないのであれば)。
また、すごく差し迫った状況で、特定のオブジェクトに対する処理だけに今だけに限り処理内容を書き換えたいというときには、テキトウなTODOコメントとともに最悪直接該当部分のコードを書き換えて急場をしのぐということができます(あんまりオススメしないですが)。
加えて、mypyなどの静的な解析を行うツールとの相性もおそらくコード生成の方が良いです。メタプログラミングの入力と出力のような複雑な1つのコードを対象にするのではなく、生成された個別のコードを対象にできるので。ただしこれに関しては推測の域を出ていないのであまり詳しくは話しません。
既に実用できるレベルなの?
基本的な機能は揃っているので、swaggerの定義ファイルからのvalidationというレベルではおそらく使えるものになっています。ただ以下の点であんまり積極的には勧めません。
- 利用者が手軽に対応するマッピングの設定を追加できるようになっていない
- そもそもswaggerのdefinisionsを解析するだけではあまり便利にならない
利用者が手軽に対応するマッピングの設定を追加できるようになっていない
これはそのままの話です。swaggerでデフォルトで用意されているtype,formatの範囲で作業する分には問題ないのですが。自分で独自の型のマッピングを追加したいという場合があります。例えばMongoDBを使っている時には、type="string" format="objectID"
が指定されたフィールドの値は、内部では bson.ObjectId
として扱いたいと言うような場合や。正規表現やswaggerの組み込みの機能の範囲を越えたvalidationを新たに追加したい場合や。swaggerではメタデータはx-
を先頭に付与して指定するという仕様があるのですが、自分達で独自に定義したメタデータを使った自分たち独自の処理を追加したいといった場合には、おそらく現状では内部の実装を把握していないと対応できなそうです。
そもそもswaggerのdefinisionsを解析するだけではあまり便利にならない
これはわりと悲しいことですが事実で。swaggerの定義ファイルを書くということは単にAPIの仕様のドキュメントを作っているに過ぎません。そしてvalidationを追加するというのは、単に望まない入力をエラーとして弾くということしかしてません。なので元々正常な入力以外やってくることがないという前提に立つならば(例えば本当に初期段階のプロトタイピングのときなど)、全く何も価値を生み出していません。実質きれいなドキュメントができましたおしまいというレベルです。
swagger-marshmallow-codegenでschemaの定義は自動で生成することができるようになったのですが。まだオーバーヘッドの割にできることが少ないような気がします。schema定義だけで済む要件ならswaggerの定義ファイルを書くかわりに、schemaの定義(例えばmarshmallowのschema)を直接書けば良いだけだったりしますし。
使い方
ここからは使い方の話です。
インストール
まだ pypi にあげていないので各自インストールする必要があります(あとであげる)。
$ pip install swagger-marshmallow-codegen # まだできない
使い方
swagger-marshmallow-codegenにswaggerの定義ファイルを渡すだけです。簡単ですね。
$ swagger-marshmallow-codegen swagger.yaml > schema.py
実行例
簡単なもの
例えば、以下のようなyamlから以下の様なschemaの定義が生成されます。
swagger.yaml
definitions: person: type: object properties: name: type: string age: type: integer required: - name
schema.py
# -*- coding:utf-8 -*- from marshmallow import( Schema, fields ) class Person(Schema): name = fields.String(required=True) age = fields.Integer()
requiredの部分もみてくれます。
$ref
$ref
もみてくれます。stringやintegerなどのprimitiveな型の場合には新しいSchemaは定義されません。
swagger.yaml
# type array definitions: name: type: string description: "name of something" age: type: integer description: "age" person: type: object properties: name: $ref: "#/definitions/name" age: $ref: "#/definitions/age" skills: type: array items: $ref: "#/definitions/skill" required: - name skill: type: object properties: name: type: string required: - name
schema.py
# -*- coding:utf-8 -*- from marshmallow import( Schema, fields ) class Person(Schema): name = fields.String(required=True, description='name of something') age = fields.Integer(description='age') skills = fields.List(fields.Nested('Skill', )) class Skill(Schema): name = fields.String(required=True)
もう少し複雑な$ref
自分自身と同じ型を参照するrefなども問題ないです。これは主にmarshmallowのおかげですが。
swagger.yaml
definitions: name: type: string description: "name of something" age: type: integer description: "age" person: type: object properties: name: $ref: "#/definitions/name" age: $ref: "#/definitions/age" father: $ref: "#/definitions/person" mother: $ref: "#/definitions/person" skills: type: array items: $ref: "#/definitions/skill" required: - name skill: type: object properties: name: type: string required: - name
schema.py
class Person(Schema): name = fields.String(required=True, description='name of something') age = fields.Integer(description='age') father = fields.Nested('self') mother = fields.Nested('self') skills = fields.List(fields.Nested('Skill', )) class Skill(Schema): name = fields.String(required=True)
nestedに self
が渡されているのは自己参照するfieldの定義です。
ちなみに以下のように書き換えてあげると。自己参照ではなくなります。
--- 04person.yaml 2016-12-25 00:36:53.000000000 +0900 +++ 05person.yaml 2016-12-25 00:55:11.000000000 +0900 @@ -1,4 +1,4 @@ definitions: name: type: string @@ -6,6 +6,10 @@ age: type: integer description: "age" + father: + $ref: "#/definitions/person" + mother: + $ref: "#/definitions/person" person: type: object properties: @@ -14,9 +18,9 @@ age: $ref: "#/definitions/age" father: - $ref: "#/definitions/person" + $ref: "#/definitions/father" mother: - $ref: "#/definitions/person" + $ref: "#/definitions/mother" skills: type: array items:
schemaは増えます。
--- 04person.py 2016-12-27 08:50:33.000000000 +0900 +++ 05person.py 2016-12-27 08:50:33.000000000 +0900 @@ -1,10 +1,18 @@ class Person(Schema): name = fields.String(required=True, description='name of something') age = fields.Integer(description='age') - father = fields.Nested('self') - mother = fields.Nested('self') + father = fields.Nested('Father') + mother = fields.Nested('Mother') skills = fields.List(fields.Nested('Skill', )) +class Father(Person): + pass + + +class Mother(Person): + pass + + class Skill(Schema): name = fields.String(required=True)
即時定義の展開
swaggerの定義ファイルもjsonschemaと同様にobjectやarrayの定義中に直接リテラル的に定義を書くことができます。一応これにも対応しています。
swagger.yaml
definitions: person: type: object required: - id - name properties: id: type: string name: type: string age: type: integer skills: type: array items: type: object properties: name: type: string relations: type: array items: type: object properties: direction: type: string enum: - following - followed - bidirectional personId: type: string
schema.py
# -*- coding:utf-8 -*- from marshmallow import( Schema, fields ) from marshmallow.validate import OneOf class Person(Schema): id = fields.String(required=True) name = fields.String(required=True) age = fields.Integer() skills = fields.List(fields.Nested('PersonSkillsItem', )) relations = fields.List(fields.Nested('PersonRelationsItem', )) class PersonRelationsItem(Schema): direction = fields.String(validate=[OneOf(choices=['following', 'followed', 'bidirectional'], labels=[])]) personId = fields.String() class PersonSkillsItem(Schema): name = fields.String()
PersonalRelationsItemなど末尾にItemが付いた謎のschemaなどが現れました。実は、内部的には、以下のようなフラットな構造に変換してから処理を行なっています。
definitions: person: type: object required: - id - name properties: id: type: string name: type: string age: type: integer skills: $ref: '#/definitions/personSkills' relations: $ref: '#/definitions/personRelations' personRelations: type: array items: $ref: '#/definitions/personRelationsItem' personRelationsItem: type: object properties: direction: type: string enum: - following - followed - bidirectional personId: type: string personSkills: type: array items: $ref: '#/definitions/personSkillsItem' personSkillsItem: type: object properties: name: type: string
実は隠し機能として、--driver Flatten
のオプションをつけるとこのフラットな構造のyamlを出力することができます(とは言えdriverオプションは開発者用の隠し機能です)。
swagger-marshmallow-codegen --driver Flatten swagger.yaml > flatten.yaml
default値やvalidation
schema定義にdefaultの指定が含まれていた場合にはその設定も見ます。validationも少なくともswaggerで使えるもの(OpenAPI 2.0)に関しては一通り揃っているはずです。
swagger.yaml
definitions: default: properties: string: type: string default: "default" integer: type: integer default: 10 boolean: type: boolean default: true date: type: string format: date default: 2000-01-01 datetime: type: string format: date-time default: 2000-01-01T01:01:01Z object: type: object properties: name: type: string default: foo age: type: integer default: 20 default: name: foo age: 20 array: type: array items: type: integer default: - 1 - 2 - 3 length-validation: type: object properties: s0: type: string s1: type: string maxLength: 10 s2: type: string minLength: 5 s3: type: string maxLength: 10 minLength: 5 maximum-validation: type: object properties: n0: type: number maximum: 100 n1: type: number maximum: 100 exclusiveMaximum: true n2: type: number maximum: 100 exclusiveMaximum: false m0: type: number minimum: 100 m1: type: number minimum: 100 exclusiveMinimum: true m2: type: number minimum: 100 exclusiveMinimum: false regex-validation: type: object properties: team: type: string pattern: team[1-9][0-9]+ team2: type: string pattern: team[1-9][0-9]+ maxLength: 10 array-validation: type: object properties: nums: type: array items: type: integer maxItems: 10 minItems: 1 uniqueItems: true color: type: string enum: - R - G - B yen: type: integer enum: - 1 - 5 - 10 - 50 - 100 - 500 - 1000 - 5000 - 10000 huge-yen: type: integer multipleOf: 10000 enum-validation: type: object required: - name - color properties: name: type: string money: $ref: "#/definitions/yen" deposit: $ref: "#/definitions/huge-yen" color: $ref: "#/definitions/color"
schema.py
# -*- coding:utf-8 -*- from marshmallow import( Schema, fields ) import datetime from swagger_marshmallow_codegen.fields import( Date, DateTime ) from collections import OrderedDict from marshmallow.validate import( Length, OneOf, Regexp ) from swagger_marshmallow_codegen.validate import( ItemsRange, MultipleOf, Range, Unique ) import re class Default(Schema): string = fields.String(missing=lambda: 'default') integer = fields.Integer(missing=lambda: 10) boolean = fields.Boolean(missing=lambda: True) date = Date(missing=lambda: datetime.date(2000, 1, 1)) datetime = DateTime(missing=lambda: datetime.datetime(2000, 1, 1, 1, 1, 1)) object = fields.Nested('DefaultObject', missing=lambda: OrderedDict([('name', 'foo'), ('age', 20)])) array = fields.List(fields.Integer(missing=lambda: [1, 2, 3])) class DefaultObject(Schema): name = fields.String(missing=lambda: 'foo') age = fields.Integer(missing=lambda: 20) class Length_validation(Schema): s0 = fields.String() s1 = fields.String(validate=[Length(min=None, max=10, equal=None)]) s2 = fields.String(validate=[Length(min=5, max=None, equal=None)]) s3 = fields.String(validate=[Length(min=5, max=10, equal=None)]) class Maximum_validation(Schema): n0 = fields.Number(validate=[Range(min=None, max=100, exclusive_min=False, exclusive_max=False)]) n1 = fields.Number(validate=[Range(min=None, max=100, exclusive_min=False, exclusive_max=True)]) n2 = fields.Number(validate=[Range(min=None, max=100, exclusive_min=False, exclusive_max=False)]) m0 = fields.Number(validate=[Range(min=100, max=None, exclusive_min=False, exclusive_max=False)]) m1 = fields.Number(validate=[Range(min=100, max=None, exclusive_min=True, exclusive_max=False)]) m2 = fields.Number(validate=[Range(min=100, max=None, exclusive_min=False, exclusive_max=False)]) class Regex_validation(Schema): team = fields.String(validate=[Regexp(regex=re.compile('team[1-9][0-9]+'))]) team2 = fields.String(validate=[Length(min=None, max=10, equal=None), Regexp(regex=re.compile('team[1-9][0-9]+'))]) class Array_validation(Schema): nums = fields.List(fields.Integer(validate=[ItemsRange(min=1, max=10), Unique()])) class Enum_validation(Schema): name = fields.String(required=True) money = fields.Integer(validate=[OneOf(choices=[1, 5, 10, 50, 100, 500, 1000, 5000, 10000], labels=[])]) deposit = fields.Integer(validate=[MultipleOf(n=10000)]) color = fields.String(required=True, validate=[OneOf(choices=['R', 'G', 'B'], labels=[])])
追記
あと細かい話でいうと。pythonでは変数名としてinvalidな名前のフィールドにも対応しています(例えば、githubのemojisのAPIなどのような)。
swagger.yaml
definitions: emojis: type: object properties: "100": type: string "1234": type: string "+1": # 実は"+1"ではなく+1だと1として扱われてしまうというバグ?がある type: string "-1": type: string 8ball: type: string
schema.py
# -*- coding:utf-8 -*- from marshmallow import( Schema, fields ) class Emojis(Schema): n100 = fields.String(dump_to='100', load_from='100') n1234 = fields.String(dump_to='1234', load_from='1234') x1 = fields.String(dump_to='+1', load_from='+1') x_1 = fields.String(dump_to='-1', load_from='-1') n8ball = fields.String(dump_to='8ball', load_from='8ball')
こういう感じで。名前がちょっと良くないけれど。
from schema import Emojis print(Emojis().load({"+1": "hai", "-1": "hoi", "8ball": "o_0"})) # UnmarshalResult(data={'x1': 'hai', 'n8ball': 'o_0', 'x_1': 'hoi'}, errors={}) data, errs = Emojis().load({"+1": "hai", "-1": "hoi", "8ball": "o_0"}) print(Emojis().dump(data)) # MarshalResult(data={'8ball': 'o_0', '-1': 'hoi', '+1': 'hai'}, errors={})