1ファイルでapi serverを作る用の環境を整えていた

個人用のメモです。

はじめに

手元で色々弄る用に1ファイルでweb serverを作る用の環境を整えていた。1ファイルが良い理由はいろいろな試行錯誤をするための実験をしたいからです。

pythonで使うwebフレームワークとしてはpyramidが好きなのですが、ところどころ1ファイルだけでアプリを作るにはあんまりうれしくない感じの状態で少しだけ調整が必要になったりします(恣意的な評価)。あと、用途としてはAPIサーバーを作る事が多いのですが、どちらかと言うとpyramidのデフォルトはサーバー側でHTMLを出力するアプリ向けの構成になっています。

そして色々な用途用のscaffoldが用意されてはいるのですが。そのまま使うということもなかったり。一方で、結局フルのpyramidの機能を使う分には1ファイルでのあぷりでは限界があります。もちろん真面目に開発するときには1ファイルで作るということなどまずないので、不要といえば不要なのですが。詳しい話をすると色々な機能のあれこれが昔存在したPasteDeployというパッケージの機能から作られる事が前提となっており、それ用の設定ファイル(.iniファイル)が必要とする形になっています。

そんな感じで自分用のsnipetを集めるよりは、ちょっとしたフレームワーク地味た何かにまとめておこうと思い始めたのでした。

つくりたいもの

つくりたいものはざっくりいうと以下のような感じです。

  • 1ファイルで作られたアプリケーションに注力
  • 主にjson responseを返すAPIサーバー用の機能をデフォルトにする
  • pyramidの機能は潰さずに使えるようにする(後で真面目に作る時には移行が手軽にできるようにする)

作っている最中のものは toybox というリポジトリにおいてあります。

hello world

hello worldはbottleやflask並みに短いです。つまり色々な部分を覆い隠したショートカットが存在するということです。

from toybox.simpleapi import simple_view, run


@simple_view("/hello/{name}")
def hello(request):
    return {"message": "hello {}".format(request.matchdict["name"])}

if __name__ == "__main__":
    run(port=8080)

大変短い。

サーバーの実行はそのままpythonで実行するだけです。defaultではwsgirefのサーバーが立ち上がります(python3ならそれなりに早い(本番で使えるとは言っていない))。

$ python app.py
scanning __main__
running host='0.0.0.0', port=8080

以下の様な結果を返します。

$ http GET :8080/hello/world
{
    "message": "hello world"
}
$ http GET :8080/404
{
    "code": "404 Not Found",
    "message": "The resource could not be found.\n\n\ndebug_notfound of url http://localhost:8080/; path_info: '/', context: <pyramid.traversal.DefaultRootFactory object at 0x109e3b8d0>, view_name: '', subpath: (), traversed: (), root: <pyramid.traversal.DefaultRootFactory object at 0x109e3b8d0>, vroot: <pyramid.traversal.DefaultRootFactory object at 0x109e3b8d0>, vroot_path: ()\n\n",
    "title": "Not Found"
}

デフォルトの404エラーが text/html ではなく application/json なのが嬉しいところです。

これは元々、pyramidでもdefaultの設定で、Accept: application/json のヘッダーがついているrequestに関しては、 application/json のresponseを返す様になっていました。これを常に有効にしています。

もう1つ、500のInternel Server Errorの時にもJSONでかえってきます。例えば以下の様なview callableをテキトウに書いてあげると。

@simple_view("/500")
def error(request):
    return 10 / 0

/500 GETAPIを定義しました。これはruntime errorになるはずのviewです。実際にrequestしてみると以下の様なresponseが返ってきます。

$ http GET :8080/500
{
    "code": "500 Internal Server Error",
    "message": "division by zero",
    "title": "Internal Server Error",
    "traceback": [
        "Traceback (most recent call last):",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/tweens.py\", line 22, in excview_tween",
        "    response = handler(request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/router.py\", line 155, in handle_request",
        "    view_name",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/view.py\", line 612, in _call_view",
        "    response = view_callable(context, request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/viewderivers.py\", line 351, in authdebug_view",
        "    return view(context, request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/viewderivers.py\", line 438, in rendered_view",
        "    result = view(context, request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/viewderivers.py\", line 147, in _requestonly_view",
        "    response = view(request)",
        "  File \"app.py\", line 11, in error",
        "    return 10 / 0",
        "ZeroDivisionError: division by zero"
    ]
}

tracebackなどは debug=True のときだけ返すようにしたいですが。今のところ本番環境で使うようなことは考えていないので常にでてきます。

simple_view

pyramidにはsimple_viewというデコレータが存在しません。その代わりに、config.add_route(), config.add_view() というディレクティブと view_config() というデコレータがあります。すごく雑に言えば、simple_view というのはrouteとviewの定義を同時にやってしまっているものです。

さて、pyramidの開発で結構ハマってしまうのはviewの定義が上手く行っているかどうかなのですが。それを確認するために proutes というコマンドが使われます。 例えば以下の様な形で使います。

$ proutes development.ini

同様に、pcreate, pserve, pshell, proutes, pviews, ptweens, prequest, pdistrepor などがありますがたいていのコマンドはPasteDeploy由来の設定ファイルが必要になります。

routeの定義だけでも確認したいということで以下の様な形で確認できるようにしました。ただし実際のpyramidの proutes と全く同じものではありません。あくまで簡易版です。

if __name__ == "__main__":
    # run(port=8080)
    run.proutes()

今現在定義されているrouteは以下の様な感じです。

$ python app.py 
scanning __main__
500 /500 __main__.error *
helloname /hello/{name} __main__.hello *

pyramidの機能の利用

ところで現状のコードの場合には、datetimeをresponseとして出力しようとするとエラーになります。

@simple_view("/now")
def now(request):
    from datetime import datetime
    return {"now": datetime.now()}

エラーになります。

$ http GET :8080/now

{
    "code": "500 Internal Server Error",
    "message": "datetime.datetime(2017, 2, 19, 21, 59, 59, 309713) is not JSON serializable",
    "title": "Internal Server Error",
    "traceback": [
        "Traceback (most recent call last):",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/tweens.py\", line 22, in excview_tween",
        "    response = handler(request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/router.py\", line 155, in handle_request",
        "    view_name",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/view.py\", line 612, in _call_view",
        "    response = view_callable(context, request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/viewderivers.py\", line 351, in authdebug_view",
        "    return view(context, request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/viewderivers.py\", line 461, in rendered_view",
        "    request, result, view_inst, context)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/renderers.py\", line 432, in render_view",
        "    return self.render_to_response(response, system, request=request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/renderers.py\", line 455, in render_to_response",
        "    result = self.render(value, system_values, request=request)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/renderers.py\", line 451, in render",
        "    result = renderer(value, system_values)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/renderers.py\", line 275, in _render",
        "    return self.serializer(value, default=default, **self.kw)",
        "  File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/json/__init__.py\", line 237, in dumps",
        "    **kw).encode(obj)",
        "  File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/json/encoder.py\", line 198, in encode",
        "    chunks = self.iterencode(o, _one_shot=True)",
        "  File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/json/encoder.py\", line 256, in iterencode",
        "    return _iterencode(o, 0)",
        "  File \"/me/venvs/my3/lib/python3.5/site-packages/pyramid/renderers.py\", line 288, in default",
        "    raise TypeError('%r is not JSON serializable' % (obj,))",
        "TypeError: datetime.datetime(2017, 2, 19, 21, 59, 59, 309713) is not JSON serializable"
    ]
}

これはpyramidでもそうで。jsonのrendererの設定を追加してあげる必要があります。

def support_datetime_response(config):
    from pyramid.renderers import JSON
    from datetime import datetime

    # override: json renderer
    json_renderer = JSON()

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


if __name__ == "__main__":
    run.add_modify(support_datetime_response)
    run(port=8080)

今度は大丈夫。

$bash http GET :8080/
{
    "now": "2017-02-19T22:04:15.534517"
}

add_modifyという名前はやめて、pyramidと同様にinclude()という名前にして文字列も受け取れるようにするかどうかは考え中。