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が簡単になります。