swagger-marshmallow-codegenでカスタマイズ出来るようにした

swagger-marshmallow-codegenで簡単なカスタマイズ出来るようにした。

例えば以下の様なことができるようになった

  • defaultで使うschema classをMySchemaに変える
  • 特定の条件を満たした値のときには自分で作った独自のfieldを使うように変える

ただこれらはすごくwork-aroundっぽい方針で作っているのであんまり綺麗ではないかもしれない。自分でDriverというクラスを作りそのクラスを --driver に渡す感じで使う。例えば以下の様な形。

$ swagger-marshmallow-codegen --driver=_custom.py:MyDriver --logging=DEBUG person.yaml > person.py

defaultで使うschema classをMySchemaに変える

defaultで使うschema classを変えるには codegen_factory を変える。myschema モジュールのMySchemaが使いたい場合には以下の様にする。

from swagger_marshmallow_codegen.driver import Driver

class MyDriver(Driver):
    codegen_factory = Driver.codegen_factory.override(schema_class_path="myschema:MySchema")

特定の条件を満たした値のときには自分で作った独自のfieldを使うように変える

こちらも同様に dispatcher_factory を変える。

例えば format=objectId のものは自分で定義した myschema の ObjectIdを使うように変えるときには以下の様にする。default値を気にせずmappingを変更する場合には、以下だけで良い。

type_map = {
    Pair(type="string", format="objectId"): "myschema:ObjectId",
    **TYPE_MAP,
}


class MyDriver(Driver):
    codegen_factory = Driver.codegen_factory.override(schema_class_path="myschema:MySchema")
    dispatcher_factory = Driver.dispatcher_factory.override(type_map=type_map)

とは言えdefault値の扱いを考えるとこちらは少し頑張らないとだめ。

from swagger_marshmallow_codegen.driver import Driver
from swagger_marshmallow_codegen.dispatcher import TYPE_MAP, Pair, FormatDispatcher, ReprWrapString


class MyDispatcher(FormatDispatcher):
    type_map = {
        Pair(type="string", format="objectId"): "myschema:ObjectId",
        **TYPE_MAP,
    }

    def dispatch_default(self, c, value, field):
        if isinstance(value, bson.ObjectId) or field.get("format") == "objectId":
            c.import_("bson")
            return ReprWrapString("bson.{!r}".format(bson.ObjectId(value)))
        return super().dispatch_default(c, value, field)


class MyDriver(Driver):
    codegen_factory = Driver.codegen_factory.override(schema_class_path="myschema:MySchema")
    dispatcher_factory = MyDispatcher

実行結果

例えば上で定義したものを使うと。以下のようなyaml

definitions:
  person:
    type: object
    properties:
      id:
        type: string
        format: objectId
        default: 5872bad4c54d2d4e78b34c9d
      name:
        type: string
      age:
        type: integer
    required:
      - name

このようなpythonのコードになる。

# -*- coding:utf-8 -*-
from myschema import (
    MySchema,
    ObjectId
)
from marshmallow import fields
import bson


class Person(MySchema):
    id = ObjectId(missing=lambda: bson.ObjectId('5872bad4c54d2d4e78b34c9d'))
    name = fields.String(required=True)
    age = fields.Integer()

参考

一応、参考にするための example も作った。

補足

ちなみにmyschemaのコードは例えば以下のようなもの

import bson
from marshmallow import Schema, fields


class MySchema(Schema):
    class Meta:
        ordered = True
        strict = True


class ObjectId(fields.String):
    default_error_messages = {
        'invalid_object_id': 'Not a valid bson.ObjectId.',
    }

    def _validated(self, value):
        """Format the value or raise a :exc:`ValidationError` if an error occurs."""
        if value is None:
            return None
        if isinstance(value, bson.ObjectId):
            return value
        try:
            return bson.ObjectId(value)
        except (ValueError, AttributeError):
            self.fail('invalid_object_id')

    def _deserialize(self, value, attr, data):
        return self._validated(value)

    def _serialize(self, value, attr, data):
        if not value:
            return value
        return str(value)

こういうちょっとしたデータの受け渡しどうするんだという話

はじめに

今自分で作っている dictknife というリポジトリについにコマンドを追加してしまった。 色々あるのだけれど。今回は dictknife transform の話。

transform

何かしらの形状の変換をしたいことがある。

例えば、こういう入力を受け取って、

properties:
  name:
    type: string
    description: name of something
  age:
    type: integer
    minimum: 0

こういう出力を返したい。

definitions:
  person:
    properties:
      name:
        type: string
        description: name of something
      age:
        type: integer
        minimum: 0

結局、load,dumpを無視するとコード自体は以下だけなのだけれど。

def transform(d):
    return {"definisions": {"person": d}}

これを省力な形で提供するのがちょっとだけ面倒。

面倒くさい点

面倒くさい点は2つある

  • transform 関数の取得
  • transform 関数へ引数を渡したい場合の方法

transform 関数の取得

上の方法で考えた変換(definisions.personでwrapするもの)がもし仮にどこかのpackageで提供されているとする。 すると以下の様に書ける気がする。

package pathを指定する場合

例えば、 foo.bar.transform:lifting で提供されている場合は以下の様に書ける。

$ dictknife transform --function foo.bar.transform:lifting ...

でも、これはちょっと使いづらい。そもそも何度も使って便利だと分かっているものでなければpackageになっていることが少ない。テキトウにファイルを置いてPYTHONPATHを追加するという方法でできなくもないけれど。やっぱり面倒。

$ PYTHONPATH=../myscript dictknife transform --function transform:lifinting ...

package path or 物理的な pathで指定する場合

直接ファイルを指定出来るようになれば十分か?一応、昔作ったmagicalimport というpackageを使うとそれは出来る。例えば上の例は以下のように書けるように出来る。

$ dictknife transform --function ../myscript/transform.py:lifting ...

eval的な何か

しかし、それでも使いにくい。何かしらのちょっとした処理を行いたいときには、一時的なファイルすら作りたくない場合がある。(というよりも、temporaryなscriptや関数群の置き場を決めるという意思決定がしたくないというような状況)。仕方が無いので禁断の果実であるevalを使うことにする。

$ dictknife transform --code 'lambda d: {"definitions": {"person": d}}' ...

transform 関数へ引数を渡したい場合の方法

trasnform 関数が取得できれば万事OKという訳でもない。冒頭の変換について考えてみても、常に "person" という固定の名前で変換したいという状況はあんまりない。どうにかしてtransform 関数へ情報を受け渡したい(そもそもtransformは関数だけで十分なのかという話もあるけれど。あんまり複雑なことを考えたくはないので今回は関数で良いということにしてみる)。

コマンドライン引数で渡す方法

コマンドライン引数で渡す方法はすごく分かりやすい。個別にコマンドを作るということを念頭に置くならこの形が最適かもしれない。とは言え、これを汎用的に提供する機能を作ろうとすると、もはやtransform コマンドのジェネレーターのようなものを作る事になってしまう。

$ dictknife transform --name person --code 'lambda d, name="NAME": {"definisions": {name: d}}' ...

configファイルで渡す方法

汎用的なtransformということを考えると以下の様な関数を作る事にならざる負えない。

def lifting(data, **kwargs):
    ...

pythonに限って言えばキーワード引数になっている方がべんりかもしれない。

def lifting(data, name="NAME"):
    ...

幸い functools.partial に辞書を渡してあげるとキーワード引数を埋める事ができる。

from functools import partial


fn = partial(lifting, **{"name": "person"})
fn(data)  # transform!!

dictを受け取って**で展開してあげれば良いかもしれない。dictを取得する方法を考えてみる。 幸い元々JSONYAMLを入出力するライブラリ上のコマンドなのでconfig用の情報をこれらのフォーマットで受け取るという形で考えても良いかもしれない。

$ dictknife transform --function "./myscript.py:lifting" --config-file ./config.json ...

とは言え、これは transform 関数を作ったときと同じ状況に陥る。本当に単純な処理に関してはファイルなんて作りたくない。

JSONで受け取れる引数を追加する

基本的にはシェル上のコマンドとJSONを直接扱う方法と言うのはあまり良い方法とは思えないのだけれど。jo やその類型のものを使えば幾分かマシになるだろうということで。JOSNを直接受け取れるようにする。

$ dictknife transform --code 'lambda d,name="foo": {"definisions": {name: d}}' --config '{"name": "person"}' ...

一応ワンライナーで済ませる事が出来るようになった。

その他細々としたこと

パイプで繋げられるようにしたい。パイプで繋げられるようなインターフェイスと言うのは以下のようなもの

$ cat src.yaml | dictknife transform <> > dst.yaml 

とは言え、明示的に入出力を指定したい場合もある(go generateで使うときなど)。

$ dictknife transform --src src.yaml --dst dst.yaml <>

現在の状態

現在の状態は以下のようなもの。真面目にdescriptionは書いていないですね。。

$ dictknife transform
Usage: dictknife transform [OPTIONS]

  transform dict

Options:
  --src PATH
  --dst PATH
  --config TEXT
  --config-file PATH
  --code TEXT
  --function TEXT
  --help              Show this message and exit.
  • --src 入力ファイル
  • --dst 出力ファイル
  • --config transform関数に渡すdictのリテラル的な文字列を受け取る
  • --config-file transform関数に渡すdictのファイル(configのファイルversion)
  • --code transform関数のワンラインナーを書きたいときに使う(eval)
  • --function transform関数

追記:

環境変数で設定するみたいな方法もあるのかもしれない?とは言えネストした構造がつらそう。

追記:

JSONを文字列で受け取れるような構造は jq で取り出す形にすると相性が良いかもしれない。

pyramid-swagger-routerというパッケージを作りました

pyramid-swagger-router というパッケージを作りました。swaggerの定義ファイル(swagger.yaml)からpyramidのviewの定義のコードを生成するパッケージです。

特徴

特徴は、pyramid-swagger-routerという名前でありながら router に値するものが何もないということです。routingのための定義を自動生成する感じのツールです。 標語の様な形にするなら以下の様な感じです。

Code generation is better than meta-programming, and onetime scaffold is simply bad.

code generation is better than meta-programming

コード生成がメタプログラミングより良いと言うのは以下の点です。

そうです。止めたいときにはただただ使うのを止めれば良いのです。何にも依存していないし。何の汚染もしません。素晴らしい。

onetime scaffold is simply bad

後の句の一度きりのscaffoldは単純に悪いというのはどういうことでしょうか?

これは、変更の時のことを考えてみてください。ある設定ファイル(e.g. swagger.yaml)から何らかのコードを生成したいとします(e.g. views.py)。 この時に、ありがちなscaffoldの機能では、単にコードを書く上での雛形となるようなskeleton的なコードを生成します。もちろん最初に作るタイミングでは便利なものではあるのですが。ここで生成されたコードに変更が加えられた時のことなどを考えてみてください(scaffoldで生成されたコードの結果が変わったということ(version X -> version Y))。

この時例えば以下の様な状態になっていると思います。

- foo/views.py  # version Xの段階でscaffold
- bar/views.py  # version Yの段階でscaffold

この時 bar/views.py で使われている生成結果のコードに追従するために foo/views.py の変更が必要になります。ここで単純なscaffoldの機能は無力な存在になります。人間がコードの変更を行わなくてはいけません。かなしい。

一方で、今回作った pyramid-swagger-router に関してはこのようなことが発生しません。発生しないように注意深く作ったので。具体的には、コード生成により変更する箇所を特定の部分に絞るということと、古いコードに対して変更適用をする際に一度元のコードのFST(full syntax tree)を取り出してからscaffoldにより生成されるであろうコードでmergeするみたいなことをしています。具体例をあげなければ分かりづらいかと思いますが。すごく雑に言うなら、修正を反映したかったらもう一度何度でもコマンドを実行すれば良いということです。

使い方

普通にインストールします。

$ pip install pyramid-swagger-router  # まだできないあとで出来るようにする

基本的な使い方は以下だけです。swaggerの定義ファイルを渡してあげると良い感じにコードを生成してくれます。簡単ですね。

$ pyramid-swagger-router <swagger.yaml> <dst>

connexion-example

swagger定義ファイルを見てあれこれやってくれるflask上のフレームワークconnexion というものもあったりします。これはメタプログラミングベースのパッケージです。これ用のexampleを公開してくれている人がいたのでこのexampleを使ったコードを書いてみようと思います。

以下のようなswagger.yamlを使います。

さすがに全部引用すると長すぎるので、定義されているAPIだけを以下に書いておきます。

GET    /pets
GET    /pets/{pet_id}
PUT    /pets/{pet_id}
DELETE /pets/{pet_id}

pet という以下の様な構造のオブジェクトに対するsimpleなCRUDAPIです。

definitions:
  Pet:
    type: object
    required:
      - name
      - animal_type
    properties:
      id:
        type: string
        description: Unique identifier
        example: "123"
        readOnly: true
      name:
        type: string
        description: Pet's name
        example: "Susie"
        minLength: 1
        maxLength: 100
      animal_type:
        type: string
        description: Kind of animal
        example: "cat"
        minLength: 1
      tags:
        type: object
        description: Custom tags
      created:
        type: string
        format: date-time
        description: Creation time
        example: "2015-07-07T15:49:51.230+02:00"
        readOnly: true

exampleの実装

scaffold

exampleと似たようの挙動をするものを実装してみます。色々な都合上1つのファイルでは無理なので幾つかのファイル分けて実装します。

$ mkdir app
$ wget https://raw.githubusercontent.com/hjacobs/connexion-example/master/swagger.yaml
$ gsed -i 's@app.@app.views.@' swagger.yaml
$ pyramid-swagger-router swagger.yaml
 INFO:                    prestring.output:touch directory path=./app
 INFO:                    prestring.output:touch file path=./app/routes.py
 INFO:                    prestring.output:touch file path=./app/views.py

以下の様な形で初回のscaffoldはOK。views.pyに定義を書いていきます。

app/views.pyには以下のような形のscaffoldにより生成されたコードが出力されてます。

from pyramid.view import(
    view_config
)


@view_config(renderer='json', request_method='GET', route_name='app_views')
def get_pets(context, request):
    """
    Get all pets

    request.GET:

        * 'animal_type'  -  `{"type": "string", "pattern": "^[a-zA-Z0-9]*$"}`
        * 'limit'  -  `{"type": "integer", "minimum": 0, "default": 100}`
    """
    return {}

同様にapp/routes.pyにも以下のようなコードが生成されます。

def includeme_swagger_router(config):
    config.add_route('app_views', '/pets')
    config.add_route('app_views1', '/pets/{pet_id}')
    config.scan('.views')


def includeme(config):
    config.include(includeme_swagger_router)

viewの実装

ここは特に何か特殊なことをしません。とりあえず元のexampleのコードを適宜コピーして実装してしまいます。

diff --git a/app/views.py b/app/views.py
index 789df11..1cc75d4 100644
--- a/app/views.py
+++ b/app/views.py
@@ -1,6 +1,14 @@
+import logging
+import datetime
+from pyramid import httpexceptions
 from pyramid.view import(
     view_config
 )
+logger = logging.getLogger(__name__)
+
+
+# our memory-only pet storage
+PETS = {}
 
 
 @view_config(renderer='json', request_method='GET', route_name='app_views')
@@ -13,7 +21,9 @@ def get_pets(context, request):
         * 'animal_type'  -  `{"type": "string", "pattern": "^[a-zA-Z0-9]*$"}`
         * 'limit'  -  `{"type": "integer", "minimum": 0, "default": 100}`
     """
-    return {}
+    animal_type = request.GET.get("animal_type")
+    limit = request.GET.get("limit") or 100
+    return [pet for pet in PETS.values() if not animal_type or pet['animal_type'] == animal_type][:limit]
 
 
 @view_config(renderer='json', request_method='GET', route_name='app_views1')
@@ -25,7 +35,10 @@ def get_pet(context, request):
 
         * 'pet_id'  Pet's Unique identifier  `{"type": "string", "required": true, "pattern": "^[a-zA-Z0-9-]+$"}`
     """
-    return {}
+    pet_id = request.matchdict["pet_id"]
+    if pet_id not in PETS:
+        raise httpexceptions.HTTPNotFound()
+    return PETS[pet_id]
 
 
 @view_config(renderer='json', request_method='PUT', route_name='app_views1')
@@ -81,7 +94,19 @@ def put_pet(context, request):
         }
     ```
     """
-    return {}
+    pet_id = request.matchdict["pet_id"]
+    pet = request.json_body
+    exists = pet_id in PETS
+    pet['id'] = pet_id
+    if exists:
+        logger.info('Updating pet %s..', pet_id)
+        PETS[pet_id].update(pet)
+        return httpexceptions.HTTPOk()
+    else:
+        logger.info('Creating pet %s..', pet_id)
+        pet['created'] = datetime.datetime.utcnow()
+        PETS[pet_id] = pet
+        return httpexceptions.HTTPCreated()
 
 
 @view_config(renderer='json', request_method='DELETE', route_name='app_views1')
@@ -93,4 +118,10 @@ def delete_pet(context, request):
 
         * 'pet_id'  Pet's Unique identifier  `{"type": "string", "required": true, "pattern": "^[a-zA-Z0-9-]+$"}`
     """
-    return {}
+    pet_id = request.matchdict["pet_id"]
+    if pet_id in PETS:
+        logger.info('Deleting pet %s..', pet_id)
+        del PETS[pet_id]
+        raise httpexceptions.HTTPNoContent()
+    else:
+        raise httpexceptions.HTTPNotFound()

ついでに以下のような app/__init__.py も作ります。(defaultの状態ではdatetime.datetimeをJSONにserializeするところで失敗するのでadapterを追加してます)。

import logging
import os.path
from pyramid.config import Configurator
from pyramid.renderers import JSON
import datetime


def make_app(settings):
    config = Configurator(settings=settings)
    config.include("app.routes")

    # override: json renderer
    json_renderer = JSON()

    def datetime_adapter(obj, request):
        return obj.isoformat()
    json_renderer.add_adapter(datetime.datetime, datetime_adapter)
    config.add_renderer('json', json_renderer)

    return config.make_wsgi_app()


def main():
    from wsgiref.simple_server import make_server
    here = os.path.dirname(os.path.abspath(__file__))
    settings = {
        "here": here,
        "pyramid.reload_all": True,
    }
    app = make_app(settings)
    server = make_server('0.0.0.0', 8080, app)
    logging.basicConfig(level=logging.DEBUG)  # xxx
    server.serve_forever()


if __name__ == "__main__":
    main()

元のflaskのコードよりコード自体は増えていますが完成しました。実行してみます。

wget https://raw.githubusercontent.com/hjacobs/connexion-example/master/test.sh
--2017-01-03 20:09:28--  https://raw.githubusercontent.com/hjacobs/connexion-example/master/test.sh
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 151.101.100.133
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|151.101.100.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 388 [text/plain]
Saving to: ‘test.sh.2’

     0K                                                       100% 6.38M=0s

2017-01-03 20:09:28 (6.38 MB/s) - ‘test.sh’ saved [388/388]
$ bash test.sh
+ http PUT :8080/pets/1 name=foo animal_type=test
{"message": "\n\n\n\n\n", "title": "Created", "code": "201 Created"}+ http :8080/pets/1
{"created": "2017-01-03T11:10:14.379035", "name": "foo", "id": "1", "animal_type": "test"}+ http PUT :8080/pets/1 name=foo animal_type=test 'tags:={"color": "brown"}'
{"message": "\n\n\n\n\n", "title": "OK", "code": "200 OK"}+ http :8080/pets/1
{"tags": {"color": "brown"}, "name": "foo", "created": "2017-01-03T11:10:14.379035", "id": "1", "animal_type": "test"}+ http :8080/pets animal_type==test
[{"tags": {"color": "brown"}, "name": "foo", "created": "2017-01-03T11:10:14.379035", "id": "1", "animal_type": "test"}]+ http DELETE :8080/pets/1

変更後のコードにscaffoldを適用する

ところで route_nameがおかしいかんじです。もう少しまともな名前にしたいですね。

def includeme_swagger_router(config):
    config.add_route('app_views', '/pets')
    config.add_route('app_views1', '/pets/{pet_id}')
    config.scan('.views')

app_viewsをpetsにapp_views1をpetにしてみましょう。swagger.yamlに以下を追加します。 (x-pyramid-route-name に与えた文字列がrouteの定義に使われます。)

diff --git a/swagger.yaml b/swagger.yaml
index 3e0b59d..473d483 100644
--- a/swagger.yaml
+++ b/swagger.yaml
@@ -12,6 +12,7 @@ security:
   - oauth2: [uid]
 paths:
   /pets:
+    x-pyramid-route-name: pets
     get:
       tags: [Pets]
       operationId: app.views.get_pets
@@ -34,6 +35,7 @@ paths:
             items:
               $ref: '#/definitions/Pet'
   /pets/{pet_id}:
+    x-pyramid-route-name: pet
     get:
       tags: [Pets]
       operationId: app.views.get_pet

もう一度viewを再生成します。もちろん、先程手で書いたviewの本体のコードは壊れていません。

$ pyramid-swagger-router swagger.yaml .
 INFO:      pyramid_swagger_router.codegen:merge file: app/routes.py
 INFO:      pyramid_swagger_router.codegen:merge file: app/views.py
 INFO:                    prestring.output:touch directory path=./app
 INFO:                    prestring.output:touch file path=./app/routes.py
 INFO:                    prestring.output:touch file path=./app/views.py
diff --git a/app/routes.py b/app/routes.py
index 6fb6bc0..acb1e0c 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -1,6 +1,6 @@
 def includeme_swagger_router(config):
-    config.add_route('app_views', '/pets')
-    config.add_route('app_views1', '/pets/{pet_id}')
+    config.add_route('pets', '/pets')
+    config.add_route('pet', '/pets/{pet_id}')
     config.scan('.views')
 
 
diff --git a/app/views.py b/app/views.py
index 1cc75d4..7b502e5 100644
--- a/app/views.py
+++ b/app/views.py
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
 PETS = {}
 
 
-@view_config(renderer='json', request_method='GET', route_name='app_views')
+@view_config(renderer='json', request_method='GET', route_name='pets')
 def get_pets(context, request):
     """
     Get all pets
@@ -26,7 +26,7 @@ def get_pets(context, request):
     return [pet for pet in PETS.values() if not animal_type or pet['animal_type'] == animal_type][:limit]
 
 
-@view_config(renderer='json', request_method='GET', route_name='app_views1')
+@view_config(renderer='json', request_method='GET', route_name='pet')
 def get_pet(context, request):
     """
     Get a single pet
@@ -41,7 +41,7 @@ def get_pet(context, request):
     return PETS[pet_id]
 
 
-@view_config(renderer='json', request_method='PUT', route_name='app_views1')
+@view_config(renderer='json', request_method='PUT', route_name='pet')
 def put_pet(context, request):
     """
     Create or update a pet
@@ -109,7 +109,7 @@ def put_pet(context, request):
         return httpexceptions.HTTPCreated()
 
 
-@view_config(renderer='json', request_method='DELETE', route_name='app_views1')
+@view_config(renderer='json', request_method='DELETE', route_name='pet')
 def delete_pet(context, request):
     """
     Remove a pet

gist

先程のexampleをやった結果のgist です。

appendix:

datetime.datetimeの対応には、rendererの修正をしましたが。以前紹介した swagger-marshmallow-codegen を使うとserialize/deserializeが簡単になります。

2017年

方針とか目標とか思いを巡らしてみる気になった。

方針

できればこういう風に行動したいという感じのやつ。これは以下の様な感じのものにすることにした。

  • なるべくupstreamに還元する

いままではコードを書いていてちょっとした問題があった時に、面倒くさがって黒魔術だったりmonkey patchだったりで解決して済ましてしまうことが多かったのだけれど。まじめにissueを立てたりPRを出すというような普通に社会に存在している人の振る舞いと同様のことをして行きたいかもという気持ちがようやく芽生えてきた。いわゆるそーしゃる活動とか言うやつです。そういう活動をやっていけたら良いなという感じ。

(ところで、この新年で、既に、redbaronというライブラリで、バグに遭遇したあとmonkey patchで済ませてしまってissueも作っていないみたいなステータスのものがある。)

方針だけでは後でどうしようかということを考えるのが辛そうなので目標みたいなものも立てたほうが良いらしい。そんなわけで目標みたいな形でも出力してみる。

目標

あんまり具体的になる気がしないのでふわっとしたままの状態。

  • 普通の人と接するインターフェイスを整える
  • 何かのプライベートな何かに所属する
  • 月1位の平均でPR出す位はしたい(方針のやつ)
  • 盆栽的なプロジェクトを持つ

普通の人と接するインターフェイスを整える

普通の人と接するインターフェイスを整えると言うのは、時勢とのズレを認識位はしておいた方が良いかもと思ったりしたし。せめてvmみたいなもので仮想的に普通の人をエミューレーション出来る程度にはなっておいた方が良いかなみたいな気持ちになった。もちろん、エミューレーションは普通の人のように振る舞える様になるだけなので、素で普通の人と同じ土俵で競い合うと言うのは不可能だし。暫くすると消耗して継続不能になるという状態にはなると思う。

何かのプライベートな何かに所属する

気持ち的に一人でやるとやっぱり気力の消耗みたいなものが多い感じはしている。あと、何かしらのプライベートな進捗があった時に発表できるような場があるとやっぱり便利な感じはしている(主にプライベートでの開発の話し)。先天的な欠陥なのか何なのか、残念ながら、物理的な座標を指定されて時間通りに目的地に辿り着くみたいなタスクが異様に苦手で消耗してしまうので、できればオンラインの活動が主の場所に所属したい感じ。

あと、意外と、自分では進んで触らなかったことなどが時折社会との接続により必要になることがあったりして、その経験が積めると便利みたいなことはあったりするのでやっぱり社会との接続的な物がなくなると良くないんだな〜みたいな気持ちになったりしたりしている(苦労は買ってでもしたくないけれど)。例えば、何らかのサービスの管理みたいなことはどこかでやってみたい感じがしている。基本的にはサービスを作ったり運用したりという何かしらの依存が増えるような作業を避けていたのだけれど。ちょっとしたチューニングのベンチマークや思いつきを試す実験場として自分で好き勝手触れるサービス的なものが1つあると便利かなーみたいな思いが出てき始めた。

月1位の平均でPR出す位はしたい(方針のやつ)

方針のやつ。upstreamに還元するので一番分かりやすいのはPRなような気がする。その過程で何か所属できる場所も見つかるんじゃないかな〜みたいな気持ちも少しだけあったりする。世のボランティア的な感じで無償で色々頑張っている人達すごいな~と思いながら見てきたけれど。そういう人々のモチベーションの源泉などを観察して取り入れられそうなものがあったら取り入れるみたいなことができたら継続できるのかもしれないというところ(継続できなくても自分には不適みたいなことが分かるので良さそう)。

盆栽的なプロジェクトを持つ

最近気づいたのは、絵を描く人のらくがきみたいな作業が精神の健康を保つのにとても有効ということで。何らかの老廃物を外部に出力するために行う作業を持つということが、人々の一人として生きて行くには必要なことらしい。一般には創作活動と呼ばれているものがそれにあたる事が多いような気がしているけれど。個人的にはコードを書くことがそれに当たるらしい。日々仕事でコードを書いているから家でもコードを書かなくても大丈夫という感じは全くないっぽい。

直近の刹那的な楽しさはらくがきでも良いのだけれど。少しだけ労力をかけて変更を重ねていき、ある時ふと振り返って後ろを見てみた時に、何らかの蓄積が存在していると良いことが多い気がしている。進捗とか呼ばれている気がするけれど。この進捗があると無いのとでは精神的な余裕が違うような気がしている。つらい気持ちになりたくないので余裕を持ちたい。そんなわけで進捗を出したい。

あと、知らない人と出会った時に「何の人?(どんな人?)」という質問にも答えやすい感じがある。そんなわけで今まで色々なところに目移りをしていきながらすぐに飽きるみたいなことを繰り返してきた感じがあるけれど。余暇時間の内の幾分かを特定のものに注力してみるということをしてみたいと思った。

2016年の振り返り

はじめに

2016年の振り返りということをやってみる気になりました。今まで振り返りのような何かしらの人間味のある活動を避けたいという気持ちがあり。ただただ事実や試行の断片だけを出力していきたいという気持ちがあったのですが。それでは社会との接続が上手く行かないという感じがしてきはじめつつあり。人間味のある活動をやってみる気になり始めています。人間味のある人々は振り返りという名目で1年の間にどのようなことを行なっていたのか列挙してみてその時々に応じたコメントをするらしいので真似することにしました。

年初に書いていたことについて

今年の最初はバックエンドよりフロントエンドの方に力を入れるつもりだったようです。だいたいこのあたりの方針は4月位で転換してしまいました。この頃には陰も形なかったgoをやるみたいな感じになっています。数学関連云々は気力があればというようなオプショナルな形ですし。気力がなかったのでできませんでした。盆栽プロジェクト的なものは上手くできていないのですが。ここ最近はコードの生成にはまっているようでした。

自分用のメモを充実させることについて

今年の最初にあげたものの中では、自分用のメモを充実させるということに関してはうまくいっているような気がします。以前までは、gistにその時々の思いつきのコードを出力するだけで終わりにしていたのですが。文章を書くという行為や何かを説明するという行為が必要らしいということに気づき始めたのでした。

ところで文章というものは生産のコストが大きい。年の始めのころは真面目に文章を書くという意識でいたようなのですが、これを止めて自分用のメモの断片をgithub特定のリポジトリにあげるというような行為に変えたところこれがすこぶる良い感じで今に至っても続いています(githubの草が不当に生えるという不具合が発生していますが)。これはreadmeが勝手にそこそこ良い感じでレンダリングされるというのと、gistのフラットな構造から脱却できたところが大きい気がします。加えて、自分自身の試行の整理の備忘録に文章はあまり必要なかったようでした。

以下の事を念頭におくことを忘れないという部分について

念頭に置くことがどうとか色々言っていますが。まとめると好き勝手やるということで。一方で好き勝手やった結果のデメリットをなるべく受け持たないようにしようという行動指針っぽいなにかです。これはこれで良いなと思ったのですが。最近は社会的な何かとの関わりがそれなりに必要なのではないかということを思いはじめて、github上でissueを書くだったり、pull requestを送るというようなそーしゃる活動のようなことをしていったほうが良いみたいな感覚になり始めています。

今年作ったリポジトリ

今年作ったリポジトリは、adventカレンダーのものと先述したメモ用のリポジトリを除くと、17個位みたいです。その内作っただけに近いものが5個位なのでおそらく10個程増えた感じになりそうです。そう言えば、以前よりはforkだったりが増えてますね。先の17個からforkしたものなどは除いています。相も変わらず作っては放置みたいなやつが多そうです。あんまり何処かでリリースのアナウンス的なものもしていませんでした。

swagger-marshmallow-codegen

これは最近作ったやつですね。コード生成楽しいみたいなやつです。昨日眠れないついでに真面目にどういう状況なのか記事を書いています。

dictknife

これはdictの操作用のライブラリの寄せ集めです。意外と色々なところの内部で使っていて個人的には便利です。元々の発端みたいな記事をdict遊びという名前で書いていたようです。個人的にはこのdict遊びみたいな試みが好きですし。こういうことが好きな人と日常的に交流できそうな何処かに所属したいみたいな気持ちがあったりします。

例えば、json-referenceで分割されたJSONのファイル群を1つにまとめる処理などがこういう感じで書けます。あとdictの再帰的なmergeやdeepequalなどが地味に便利でした。 pip install dictknife[load] という感じでインストールすると、yamljsonのload,dumpの便利機能が使えるようになったりします。

swagger-bundler

分割されたswaggerのファイルをシュッと1つにまとめるやつです。これは最近新しい仕様で再実装したいので今はあんまり説明したくない感じです。次のバージョンでは、ファイルベースでの結合を止めて、真面目に名前空間をつける予定です。現在の挙動では何をimportしたのかわかんなくなるのが辛いという状況が発生しています。

goconvert

これは、goのコードを生成するためのpython用のライブラリ群です。主にgoの変換処理を生成しようと思って作業をしていた感じでした。作り途中です。qiitaに記事を書いていましたが全く興味は持たれなそうな感じでした。後述するgo-structjsonを使って、goのstruct定義をJSONファイルとして出力してあれこれやるみたいなやつです。

go-structjson

importのpathを見て再帰的にパッケージを見ながらAST使ってstruct定義をJSONとして抽出するみたいなやつです。goの情報を使ったコードをgo以外で書きたいと思ったので作りました。まじめに抽出する条件の絞込などができていなのでdefaultのexcludeの条件を外すとすぐに40000行とかのjsonを吐いたりしてしまいます。ツールとして作成した感じなのであんまりコードを綺麗に整える気にはなっていないので汚いです。

kanagata

これはpythonからmongodbのコードを触る時に、mongoengine位しか無さそうで。一方でmongoengineのdjangoのORMの方言みたいなインターフェイスがそもそもpython第二言語でしかない環境では無駄だろうという気持ちで作ったライブラリです。基本的には値の範囲を制限したdictとlistを作るライブラリです。ゆくゆくはswaggerの定義から自動生成をしようみたいなことを思っていましたが。今は止めてmarshmallowを使ってdictのvalidationをするだけで留めておこうみたいな気持ちになっています。もしかしたらmypyのtypeddictを使うというのが良いのかもしれません。

これを作った過程で、pythonのmongodbのclientのpymongoではcollections.UserDictは辞書として取り扱ってdeserialize/serializeできるのに対し、collecsions.UserListではエラーを吐くみたいな挙動になることがわかりました。jiraでチケットを作って(そういえばはじめてのjiraでした)、userlistに対応したpatchのようなものを投げてみましたが、adhocに個別に対応するのも馬鹿馬鹿しいしもっと汎用的な仕組みを後々用意するからそれで対応してみたいなことを言われたのであまり使い勝手が良くないです。(patchをあてないと、pymongoに値を受け渡すタイミングで、一度dictに変換する必要があります。)

kumonote

これは完全に途中で飽きて止めたリポジトリです。asyncioを使ってクローラーを作ろうと思ったらしいです。蜘蛛の手。

magicalimport

これは物理的なファイルパスをpythonで手軽にimportできるようにしようというライブラリです。plugin的なアーキテクチャを作る際に、わざわざパッケージを作らなくても済む様になるので意外と便利です。swagger-bundlerだったりswagger-marshmallow-codegenだったりの内部で使っています。

selfish

これはgoの練習用に作ったリポジトリです。gistにファイルをuploadするツールです。個人的にはこれでgistyを置き換えられたことでQoLが格段に向上しました。 具体的には-aliasというオプションを指定することで同一のgistに対する更新が手軽になったという辺りが大きいです。

# 最初のupload
$ selfish -alias head *.go
# 色々更新した後にgistに更新を反映
$ selfish -alias head *.go
# 新しいものを作るときには `-alias` を外すか別の名前で実行
$ selfish *.py readme.md

django-aggressivequery

これはdjangoのqueryのoptimizerです。後に触れるdjango-returnfielldsで生成するqueryをそこそこ良い感じにするために使っています。具体的にはN+1をjoinやprefetchに置き換えて除去してくれます。とは言え、職が変わったので、もう仕事でdjangoを使う事は無さそうです。なのであんまり積極的にメンテする気もない感じではあります。

django-returnfields

これはdjango restframework用のライブラリです。fat apiを定義したときのN+1クエリー的なものを除去するのに便利というやつです。そう言えばこれを作る過程で異様にdjangoのorm関連の記事を書いた記憶をがあります。

djangoオワコンなのか何なのか入門用の記事や特定のライブラリの紹介みたいな記事しか見かけないような気がします。

console-angular

これはangular1.xの挙動を確かめるためにhtmlやcssを書くのが馬鹿らしいと思って作ったパッケージだった記憶があります。内部的にはほとんど数行のラッパー。 元になった記事はこれっぽいです。

最近はangular1.x触っていないですし。今のおしごとのフロントエンドはangular(angular2)だったりですし。隔世の感があります。最近はフロントエンド触っていないので追いついていけてないですけれど。今だったらangularに限らずとりあえずbootcamp的にxxx-cliの内部を把握して、あとはテキトウに少しずつ新しい知識を集めていけば良いみたいなイメージでいます。

postcss-restructure

これはごみです。ほとんどpostcssのhello worldみたいなpluginな記憶。

cssconflict

これもごみです。これはcssのconflictした定義を見つけようと書き始めたものの、よく考えたらHTML部分も見なければレンダリングに使われる記述が決定できないみたいなことに気づいたみたいな経緯で放置されてます。

cssdiff

これはすごく巨大なcss同士の意味的なdiffを取ろうみたいなやつです。css parserでparseしたあとテキトウにdictでdiffを取るというだけなので数時間位で作った記憶があります。こういう便利ツールがstarを集めやすい(とは言え0件ではなく色がつくという程度)。

最後に

飽きました。(後で何か書く)

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

最近のコマンドラインからのJSONのAPIのPOSTなどの仕方

手元の環境でweb apiを叩く処理をしたい時にどうしているかというメモ。

利用しているもの

本当はpipで新たにインストールしなければ行けないコマンドに頼らない形が良いけれど。curlが辛くなったというのと。curlに飽きたというのでhttpieを使っている。

pip install httpie

apiの指定

例えば以下のようなAPIを呼びたいとする。

POST /pets
{
  "id": <number>,
  "name": <string>,
  "tag": <string>
}

JSONの文字列をコマンドライン上で作るのは辛い*1。なので以下のように別のファイルに書くことにしている

src/pet0.json

{
    "id": 1,
    "name": "foo",
    "tag": "base"
}

httpieを使う時のコマンドとrequest内容は以下の様な感じ。

$ cat src/pet0.json | http --json --verbose post http://localhost:4444/pets
POST /pets HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 48
Content-Type: application/json
Host: localhost:4444
User-Agent: HTTPie/0.9.6

{
    "id": 1,
    "name": "foo",
    "tag": "base"
}

引数の設定

ところどころ引数を変えたい場合がある。今のところはpythonの標準ライブラリの範囲で問題ない。format関数はjsonと相性が良くないので % を使っている。外部ライブラリに依存しちゃって良いのならmakoやjinja2を使ったほうが便利。

$ python ./bin/getjson.py --config config.json src/pet1.json | http --json --verbose post http://localhost:4444/pets
POST /pets HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 48
Content-Type: application/json
Host: localhost:4444
User-Agent: HTTPie/0.9.6

{
    "id": 1,
    "name": "foo",
    "tag": "base"
}

src/pet1.json

{
    "id": "%(petId)s",
    "name": "%(petName)s",
    "tag": "base"
}

config.json

{
    "petId": 1,
    "petName": "foo"
}

bin/getjson.py

import sys
import json


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("-c", "--config", required=True)
    parser.add_argument("file")
    args = parser.parse_args()
    dump(args.config, args.file)


def dump(config, file):
    with open(config) as rf:
        conf = json.load(rf)

    with open(file) as rf:
        template = rf.read()
        data = template % conf
        print(json.dumps(json.loads(data), indent=2, sort_keys=True, ensure_ascii=False))

if __name__ == "__main__":
    main()

引数のオプションなどを覚えたくない

細かい引数をいちいち覚えておくのが面倒なのでMakefileを書く。何か他のものを使ったほうが楽な気がしないこともない感じ。

URL = http://localhost:4444
GETJSON = python ./bin/getjson.py --config config.json

api_create_pet:
   @${GETJSON} src/pet1.json | http --json --verbose post ${URL}/pets
$ make api_create_pet
POST /pets HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 48
Content-Type: application/json
Host: localhost:4444
User-Agent: HTTPie/0.9.6

{
    "id": 1,
    "name": "foo",
    "tag": "base"
}

ちなみにresponseをそのままjsonとして受け取りたい場合には --verboseを外した方が良い。

Authorizatioin ヘッダーなどが必要な場合

jwtなどを使っていてAuthorization ヘッダーが必要な場合にはテキトウに生成した結果を先述のconfig.jsonに埋め込んで cat config.json | jq <path> -r を使って取り出している。

get_auth_header:
    @<generate header> --clietId `cat config.json | jq .clientId -r` --salt `cat config.json | jq .clientSalt -r`
make get_auth_header|pbcopy  # config.jsonに保存

authorization headerを付ける場合

AUTH_HEADER = Authorization:"Bearer `cat config.json | jq .token -r`"

api_create_pet:
   @${GETJSON} src/pet1.json | http --json --verbose post ${URL}/pets ${AUTH_HEADER}

おわりに

本当は最初から入っているコマンドだけで完結しているだとか。他の人とも共有しやすい(e.g. postman使う)ほうが良いのかもしれないけれど。今はとりあえずこういう感じ。

細かい補足

format関数はjsonと相性が悪い理由などの補足

補足1-1 format関数がjsonと相性が悪い理由

例えばjsonのファイルを以下の様に書かなくてはいけない。

{{
    "id": "{petId}",
    "name": "{petName}",
    "tag": "base"
}}

補足1-2 %の問題

ネストした辞書をconfigに持てない。

# formatはok
"@{foo[bar]}@".format(**{"foo": {"bar": "boo"}})  # => "@boo@"

# % はng
"@(foo[bar])@" % ({"foo": {"bar": "boo"}})  # => error

なので現状は諦めてフラットなjsonにデータを持っている。

補足1-3 jinja2やmakoの場合

これはどちらも大丈夫。

mako

from mako.template import Template
Template("@${foo['bar']}@").render(**{"foo": {"bar": "boo"}})  # => "@boo@"

Template("""{
    "id": "${petId}",
    "name": "${petName}",
    "tag": "base"
}""").render(petId=1, petName="foo")

jinja2

from jinja2 import Template
Template("@{{foo['bar']}}@").render(**{"foo": {"bar": "boo"}})  # => "@boo@"

Template("""{
    "id": "{{petId}}",
    "name": "{{petName}}",
    "tag": "base"
}""").render(petId=1, petName="foo")

補足2 apiのswagger

こういうapi setを想定していた

swagger: '2.0'
info:
  version: '1.0.0'
  title: Swagger Petstore (Simple)
  description: A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification
  termsOfService: http://helloreverb.com/terms/
  contact:
    name: Swagger API team
    email: foo@example.com
    url: http://swagger.io
  license:
    name: MIT
    url: http://opensource.org/licenses/MIT
host: localhost:4444
basePath: /
schemes:
  - http
consumes:
  - application/json
produces:
  - application/json
paths:
  /pets:
    get:
      description: Returns all pets from the system that the user has access to
      operationId: findPets
      produces:
        - application/json
        - application/xml
        - text/xml
        - text/html
      parameters:
        - name: tags
          in: query
          description: tags to filter by
          required: false
          type: array
          items:
            type: string
          collectionFormat: csv
        - name: limit
          in: query
          description: maximum number of results to return
          required: false
          type: integer
          format: int32
      responses:
        '200':
          description: pet response
          schema:
            type: array
            items:
              $ref: '#/definitions/pet'
        default:
          description: unexpected error
          schema:
            $ref: '#/definitions/errorModel'
    post:
      description: Creates a new pet in the store.  Duplicates are allowed
      operationId: addPet
      produces:
        - application/json
      parameters:
        - name: pet
          in: body
          description: Pet to add to the store
          required: true
          schema:
            $ref: '#/definitions/newPet'
      responses:
        '200':
          description: pet response
          schema:
            $ref: '#/definitions/pet'
        default:
          description: unexpected error
          schema:
            $ref: '#/definitions/errorModel'
definitions:
  pet:
    type: object
    required:
      - id
      - name
    properties:
      id:
        type: integer
        format: int64
      name:
        type: string
      tag:
        type: string
  newPet:
    type: object
    required:
      - name
    properties:
      id:
        type: integer
        format: int64
      name:
        type: string
      tag:
        type: string
  errorModel:
    type: object
    required:
      - code
      - message
    properties:
      code:
        type: integer
        format: int32
      message:
        type: string

*1:joを使っても良いという話はあるかもしれない