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