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={})