responderで理解するWebサーバー #2 ルーティング

こんにちは、Webエンジニアのjuri-tです。

responder啓蒙活動第2回です。今回はresponderにおけるルーティングについて書きます。

READMEにも書いてある簡単な例

import responder
api = responder.API()

@api.route("/hello")
async def hello(req, resp):
   resp.text = 'Hello'

if __name__ == '__main__':
   api.run()

ここの、@api.routeというのがルーティングの設定になります。この場合、/helloにアクセスした際、ここでデコレートされているhello関数が呼ばれて、レスポンスにHelloが返ります。(手元で起動している場合は、http://localhost:5042/helloです)

このままだと全然面白くないので、もう少し実戦っぽい例にしましょう。

RESTful APIを実現するルーティング

import responder
api = responder.API()

@api.route("/user/{id}")
async def user(req, resp, *, id):
   resp.text = f'Hello, {id}'

if __name__ == '__main__':
   api.run()

RESTful APIに準拠して、特定のidのユーザーを取得するルーティングです。{id}で囲った部分が動的になります。/user/1とアクセスするとidに1になります。idに制限をかけているわけではないので、/user/juri-tとするとidにjuri-tが入ります。制限する方法は後述。

次は、リクエストメソッドに応じて処理を分岐する場合を考えましょう。

リクエストメソッドごとの処理の書き方

import responder

api = responder.API()

@api.route("/user/{id}")
async def user(req, resp, *, id):
   if req.method == 'get':
       resp.text = f'GET {id}'
   elif req.method == 'post':
       resp.text = f'POST {id}'
   else:
       resp.text = f'{req.method} {id}'

if __name__ == '__main__':
   api.run()

Requestオブジェクトには、methodという変数にリクエストメソッドが入っています。GETの場合はgetが、POSTの場合はpostが、DELETEの場合はdeleteが、という風に取得できるので条件文で分岐すれば良いです。

さて、responderは関数ではなくクラスによるルーティングも可能です。

クラスを使ったルーティング

import responder

api = responder.API()

@api.route("/user/{id}")
class User:
   async def on_get(self, req, resp, *, id):
       resp.text = f'GET {id}'

   async def on_post(self, req, resp, *, id):
       resp.text = f'POST {id}'

   async def on_request(self, req, resp, *, id):
       resp.text = f'{req.method} {id}'

if __name__ == '__main__':
   api.run()

この書き方でも上記と同様の処理になります。on_getでGETメソッド時の処理、on_postでPOSTメソッド時の処理です。すべてのメソッドを受け取るのはon_requestメソッドです。(ここに書いてないですが、他のメソッドもあります。たとえば、on_deleteはDELETEメソッド時の処理になります。)

さて、これでも良いですが、いろんなルーティングを用意していくと、このファイル肥大化してちょっと嫌ですね。ルーティング処理と、コントローラーの処理が混ざっているので、複雑になるとこのままだと辛いです。

コントローラーの実装を別ファイルに切り出す

controllersディレクトリを作成し、その中にusers.pyファイルを以下のように作ります。(クラスの実装は先ほどと同じです)

class User:
   async def on_get(self, req, resp, *, id):
       resp.text = f'GET {id}'

   async def on_post(self, req, resp, *, id):
       resp.text = f'POST {id}'

   async def on_request(self, req, resp, *, id):
       resp.text = f'{req.method} {id}'

ルーティングをするファイルはこうなります。

import responder
from controllers.users import User

api = responder.API()

api.add_route("/user/{id}", User)

if __name__ == '__main__':
   api.run()

デコレータじゃなく、add_routeというメソッドでルーティングをすることもできます。これなら、ルーティングが増えても役割分担できているので大丈夫そうですね。ちなみにadd_routeにはクラスの代わりにメソッドを渡しても大丈夫です。(この記事の最初にあるhelloメソッド)

パスパラメータに型をつける

import responder
from controllers.users import User

api = responder.API()

api.add_route("/user/{id:int}", User)
if __name__ == '__main__':
   api.run()

 さて、上記のルーティングの処理を上のように変えてみましょう。変更点は{id:int}です。これはPythonのtype hintと呼ばれるもので、型情報を与えるものです。このとき、idに数字を与えれば正常に処理されますが、文字列を与えると404 Not Foundとなります。

404 Not Foundのときをカスタマイズしたい

ルートが見つからなかったときの404エラーのハンドリング方法ですが、現在はすでに言及されている方もいらっしゃいますが、良い方法が提供されていないようです。

ただ、エラーになるわけではなく、デフォルトのレスポンスとしてNot Foundが返ります(ちょっと味気ないので、カスタマイズできると良いんですけどね)。500も同様です。

定義しているメソッド以外を処理するルートとか、リクエストを処理する前に自前でルーティングしちゃえば良いんじゃ?とか思ったんですが、404のときはそもそもbefore_requestの処理に入らないので、それも厳しいです。

というわけで、イレギュラーなハックを考えました。自己責任でお願いします

import responder
from controllers.users import User

api = responder.API()

api.add_route("/", None, static=True)
api.add_route("/user/{id:int}", User)

if __name__ == '__main__':
   api.run()

api.add_route("/", None, static=True) この一行を加えます。このとき、staticフォルダにあるindex.htmlがデフォルトのhtmlとしてルーティングされます。

2つ目の引数はendpointとなるクラスやメソッドですが、ここはNoneである必要があります。

ただ、この方法はContent-Typeをapplication/jsonとかにしてもindex.htmlがルーティングされるし、なんといってもレスポンスのステータスが200になります。

もしかしたら2つ目の引数でルーティングするクラスをうまく作ればいけるかも?(引数が合わないとかでエラーになったのですぐにはできず)

追記(2019.7.17)

import responder
from controllers.users import User

api = responder.API()

def not_found_error(req, resp):
   resp.status_code = 404
   if req.headers['Content-Type'] == 'application/json':
       resp.media = {"status": 404, "message": "Not Found"}
   else:
       resp.content = api.template('404_not_found.html')

api.add_route("/", not_found_error, default=True)
api.add_route("/user/{id:int}", User)
if __name__ == '__main__':
   api.run()

こんな感じでルーティングすれば、404もカスタマイズできますね。500のサーバーエラーはすべてのルーティングに書く必要あるのかな?

そして、Content-Typeはリクエストボディの形式ですね。。

というわけで、今日はこのへんで。

サポートありがとうございます。頂いたご支援は美味しいものを食べに行きます。