Werkzeugでリクエスト・レスポンス・Cookieを試してみた

Flaskでも使われているライブラリ Werkzeug でいろいろと試した時のメモです。

 
公式ドキュメントはこちらです。以下のサンプルでも公式ドキュメントへのリンクを記載しています。
http://werkzeug.pocoo.org/docs/0.14/

 
なお、curlでの動作検証時、以下が参考になりました。
curl コマンド 使い方メモ - Qiita

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

Werkzeugを使ったアプリの構成

今回作成するWerkzeugを使ったアプリは、Werkzeugのチュートリアル Step 2: The Base Structure をベースに、 app.py というファイルに追加していきます。
http://werkzeug.pocoo.org/docs/0.14/tutorial/#step-2-the-base-structure

import os

from werkzeug.wrappers import Request, Response
from werkzeug.wsgi import SharedDataMiddleware


class BaseStructure:
    """Werkzeugのチュートリアルにあった、Werkzeugアプリの基本的な構成
    http://werkzeug.pocoo.org/docs/0.14/tutorial/#step-2-the-base-structure
    """
    def dispatch_request(self, request):
        return Response('Hello world!')

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        """WSGIアプリを直接dispatchすることで、wsgi_app()をWSGIミドルウェアっぽく使える"""
        return self.wsgi_app(environ, start_response)


def create_app(with_static=True):
    application = BaseStructure()

    if with_static:
        application.wsgi_app = SharedDataMiddleware(
            application.wsgi_app,
            {'/static': os.path.join(os.path.dirname(__file__), 'static')}
        )
    return application


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    app = create_app()
    run_simple('127.0.0.1', 5000, app, use_debugger=True, use_reloader=True)

 
チュートリアルでは、dispatch_request() にルーティングを記載していました。

ただ、今回はルーティングについてはふれないものの、 dispatch_request() にいろいろと実装してきます。

 

HTTPリクエス

WSGIアプリケーションの引数 environwerkzeug.wrappers.Request に渡してインスタンス化することで、HTTPリクエストデータが扱いやすくなります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest

request = Request(environ)

 

HTTPメソッド

request.method で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.method

body.append(f'HTTP method: {request.method}')
# => GET

 

HTTPヘッダ

request.headers で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.headers

for k, v in request.headers.items():
    body.append(f'Request header: key:{k} / value: {v}')
    # => Request header: key:Host / value: localhost:5000 ...

 

クライアントのIPアドレスなど

request.access_routerequest.remote_addr で取得します。

body.append(f'access_route: {request.access_route}')
# => access_route: ImmutableList(['127.0.0.1'])

body.append(f'remote_addr: {request.remote_addr}')
# => remote_addr: 127.0.0.1

 

クエリストリングの取得

request.argsrequest.values で取得します。

POSTの時にもクエリストリングの値を取得できます。

# request.argsを使う場合
body.append(f'Query String: {request.args}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    ImmutableMultiDict([('foo', 'bar')])
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    ImmutableMultiDict([('ham', 'spam')])

# request.valuesを使う場合
body.append(f'request.values: {request.values}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    CombinedMultiDict([ImmutableMultiDict([('foo', 'bar')]),
#                       ImmutableMultiDict([])

 
なお、POSTの時にもクエリストリングがあるのかどうかは、RFC的な規定は見つけられませんでした。ご存知の方は教えていただけますとありがたいです。
参考:http - Is it valid to combine a form POST with a query string? - Stack Overflow

 

POSTデータの取得

request.formrequest.values で取得します。

なお、GETの時は、request.formは ImmutableMultiDict([]) になるようです。

また、POSTの場合でクエリストリング & POSTデータがある場合は、CombinedMultiDict内のImmutableMultiDictに、両方のエントリが含まれます。

順番は、クエリストリング > POSTデータ、で良さそうです。

# request.formを使う場合
body.append(f'Form: {request.form}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    ImmutableMultiDict([])
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    ImmutableMultiDict([('foo', '1'), ('bar', '2')])

# request.valuesを使う場合
body.append(f'request.values: {request.values}')
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    CombinedMultiDict([ImmutableMultiDict([('ham', 'spam')]),
#                       ImmutableMultiDict([('foo', '1'), ('bar', '2')])
#                     ])

 

WSGI/CGI環境変数の取得

request.environ で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.environ

結果は dict 型であり、この中に WSGI/CGI の両方の環境変数が含まれるようです。

body.append(f'environ: {type(request.environ)} / {request.environ}')
# => environ: <class 'dict'> / {'wsgi.version': (1, 0), 'wsgi.url_scheme': 'http', 'wsgi.input': <_io.BufferedReader name=6>, 'wsgi.errors': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='UTF-8'>, 'wsgi.multithread': False, 'wsgi.multiprocess': False, 'wsgi.run_once': False, 'werkzeug.server.shutdown': <function WSGIRequestHandler.make_environ.<locals>.shutdown_server at 0x105f13c80>, 'SERVER_SOFTWARE': 'Werkzeug/0.14.1', 'REQUEST_METHOD': 'GET', 'SCRIPT_NAME': '', 'PATH_INFO': '/', 'QUERY_STRING': 'foo=bar', 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_PORT': 55330, 'SERVER_NAME': '0.0.0.0', 'SERVER_PORT': '5000', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:5000', 'HTTP_CONNECTION': 'keep-alive', 'HTTP_PRAGMA': 'no-cache', 'HTTP_CACHE_CONTROL': 'no-cache', 'HTTP_UPGRADE_INSECURE_REQUESTS': '1', 'HTTP_USER_AGENT': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', 'HTTP_ACCEPT': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 'HTTP_ACCEPT_ENCODING': 'gzip, deflate, br', 'HTTP_ACCEPT_LANGUAGE': 'ja,en-US;q=0.9,en;q=0.8', 'HTTP_COOKIE': 'one_time=x; counter=8', 'werkzeug.request': <Request 'http://localhost:5000/?foo=bar' [GET]>}

 

HTTPレスポンス

werkzeug.wrappers.Response を使うことで、HTTPレスポンスデータとなります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse

# msgは、HTTPレスポンスボディ
response = Response(msg)

 

Content-Typeをセット

response.content_type を使います。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.CommonRequestDescriptorsMixin.content_type

response.content_type = 'application/json'

 

独自HTTPヘッダをセット

独自HTTPヘッダをセットするには

の3つが使えます。

response.headers.add('X-headers-add', 'using add')
response.headers.add_header('X-headers-add_header', 'using add_header')
response.headers['X-headers-key'] = 'using key'
# => X-headers-add: using add
#    X-headers-add_header: using add_header
#    X-headers-key: using key

 

エラーページを表示

abort()raise InternalServerError() などが使えます。

 
なお、例外クラスを継承し、エラーページを差し替えることもできます。
http://werkzeug.pocoo.org/docs/0.14/exceptions/#custom-errors

以下の例では、InternalServerErrorを継承し、HTTP500エラー時の画面の表示を差し替えています。

class MyInternalServerError(InternalServerError):
    def get_body(self, environ=None):
        # werkzeug._compat.text_type()がprotectedなので、使っていいものか...
        return text_type(
            u'<!DOCTYPE html>'
            u'<title>My Internal Server Error</title>'
            u'<h1>Oh, my internal server error!</h1>'
        )

 

リダイレクト

werkzeug.utils.redirect() が使えます。
http://werkzeug.pocoo.org/docs/0.14/utils/#werkzeug.utils.redirect

return redirect('https://www.google.co.jp')

 
なお、URLに / を追加するリダイレクトのための werkzeug.utils.append_slash_redirect も用意されています。
http://werkzeug.pocoo.org/docs/0.14/utils/#werkzeug.utils.append_slash_redirect

 

Werkzeugではリクエスト時のCookieとレスポンス時のCookieが分かれています。

 

リクエスCookieの取得

request.cookies ですべてのCookieを取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.cookies

Cookieのキーを指定して値を取得するには、以下となります。

counter = request.cookies.get('counter', 0)

 

レスポンスCookieの設定

レスポンスオブジェクトを生成した後、 response.set_cookie() にてCookieをセットします。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.set_cookie

response = Response(msg)
response.set_cookie('counter', str(int(counter) + 1))

 
なお、 response.delete_cookie()Cookieを削除できます。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.delete_cookie

delete_cookie() を呼ぶと

Set-Cookie: one_time=; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/

のようなHTTPレスポンスヘッダが追加されます。

Cookieを削除しない限り、リクエストオブジェクトからレスポンスオブジェクトにCookieを移し替えなくても、次回のリクエスト時も同じキー・値でCookieがやってきます。

 

同じCookieキー名での設定に注意

例えば、

response.set_cookie('same_cookie', '1st', httponly=True)
response.set_cookie('same_cookie', '2nd', secure=True)

のように、同じCookieキー名 (same_cookie) でCookieをセットしたとします。

 
この場合、HTTPレスポンスヘッダは

Set-Cookie: same_cookie=1st; HttpOnly; Path=/
Set-Cookie: same_cookie=2nd; Secure; Path=/

となります。

ただし、リロードするなどして再度アクセスすると、HTTPリクエスト時のCookieヘッダは

Cookie: one_time=x; same_cookie=1st; counter=2

と、片方のものしかセットされていません。

 
CookieのRFC6265を見ると、

(原文)

Servers SHOULD NOT include more than one Set-Cookie header field in the same response with the same cookie-name. (See Section 5.2 for how user agents handle this case.)

https://tools.ietf.org/html/rfc6265#section-4.1

(日本語訳)

サーバは、同じ応答内に同じ cookie-name の複数の Set-Cookie ヘッダを内包するべきでない。 (UA がこの場合をどう扱うかについては、 5.2 節を見よ。)

https://triple-underscore.github.io/http-cookie-ja.html#section-4.1

とあります。

これらより、同じCookieキー名を設定すべきではなく、挙動もおかしくなるため、注意が必要です。

 

その他

開発WSGIサーバを外部アクセス可能にする

werkzeug.serving.run_simple() で開発WSGIサーバを起動できます。
http://werkzeug.pocoo.org/docs/0.14/serving/#werkzeug.serving.run_simple

なお、Flask同様、引数hostに 0.0.0.0 を指定することで、開発WSGIサーバを外部アクセス可能にします。
Flaskのサーバーはデフォルトだと公開されてない - Qiita

 

favicon.ico のリクエストを処理する

Cookie

counter = request.cookies.get('counter', 0)
response.set_cookie('counter', str(int(counter) + 1))

と実装し、ブラウザで表示した後、リロードしたところ、HTTPレスポンスヘッダが

Set-Cookie: counter=3; Path=/

となっていました。counter=2を想定していたのに、counter=3となっていました。

 
原因は、ブラウザでアクセスした時、1回で

の2リクエストが飛んでいたせいでした。

今回のアプリはルーティングがないため、favicon.icoのリクエストでも dispatch_request() が処理されてしまったようです。

 
対応としては、開発環境の場合は werkzeug.wsgi.SharedDataMiddleware が使えます *1

create_app()関数の中で、SharedDataMiddlewareを使います。

なお、Werkzeugのチュートリアルなどではディレクトリを指定していますが、以下のようにファイル自体を指定することも可能です。

def create_app(with_static=True):
    application = Application()

    # WSGIミドルウェアの設定ポイント
    if with_static:
        application.wsgi_app = SharedDataMiddleware(
            application.wsgi_app,
            {'/favicon.ico': str(pathlib.Path('./favicon.ico'))}
        )
    return application

 
次に、 app.py と同じディレクトリの中に favicon.ico を用意します。

以上で、favicon.icoへのアクセスはSharedDataMiddlewareにより処理され、dispatch_request()では処理されなくなります。

 

ソースコード

GitHubに上げました。base_structure ディレクトリ以下が今回のファイルです。
thinkAmi-sandbox/werkzeug-sample

*1:本番環境では何らかの形で静的ファイルを配信するはず