WerkzeugでJSON等のいろいろなレスポンスを作ってみた

前回、Werkzeugでいろいろと試してみました。
Werkzeugでリクエスト・レスポンス・Cookieを試してみた - メモ的な思考的な

 
今回は、WerkzeugでいろいろなHTTPレスポンスを作ってみました。

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 
なお、アプリの構成は前回作成した BaseStructure クラスをベースにしています。

 

特定のHTTPメソッドのみレスポンスを許可

Rule オブジェクトを生成する時の引数 methods にHTTPメソッドを指定することで、そのHTTPメソッドのみレスポンスを返せます。
http://werkzeug.pocoo.org/docs/0.14/routing/#werkzeug.routing.Rule

 

class Application:
    def __init__(self):
        self.url_map = Map([
            ...
            Rule('/get-only', endpoint='get_only', methods=['GET']),
            Rule('/post-only', endpoint='post_only', methods=['POST']),
        ])

    def get_only_handler(self, request):
        return Response('GET Only!\n')

    def post_only_handler(self, request):
        return Response(f'POST Only: {request.form.get("foo")}\n')

 
curlで動作確認します。

GETでアクセスした場合です。

# GETだけレスポンス可能なURL
$ curl --include localhost:5000/get-only
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
...
GET Only!

# POSTだけレスポンス可能なURL
$ curl --include 'localhost:5000/post-only'
HTTP/1.0 405 METHOD NOT ALLOWED
Content-Type: text/html
Allow: POST

 
POSTでアクセスした場合です。

# GETだけレスポンス可能なURL
$ curl -w '\n' --include -X POST 'localhost:5000/get-only' --data 'foo=1'
HTTP/1.0 405 METHOD NOT ALLOWED
Content-Type: text/html
Allow: HEAD, GET

# POSTだけレスポンス可能なURL
$ curl -w '\n' --include -X POST 'localhost:5000/post-only' --data 'foo=1'
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
...
POST Only: 1

 

JSONレスポンス

レスポンスオブジェクトの content_type引数に application/json をセットすることで、JSONをレスポンスできます。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/json', endpoint='json'),
        ])

    def json_handler(self, request):
        input_data = request.form.get('input')
        result = {
            'foo': 'abc',
            'bar': ['ham', 'spam', 'egg'],
            'result': input_data,
        }
        return Response(json.dumps(result), content_type='application/json')

 
では実際に、データをPOSTしてJSONAjaxで取得・表示するHTMLを用意して試してみます。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JSON response</title>
</head>
<body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<p>
    <label for="id_input">POST data: </label>
    <input id="id_input" type="text">
</p>
<button id="id_get_json">Go</button>
<p>foo: <span id="id_foo"></span></p>
bar:
<ul id="id_bar">
</ul>
<p>input data: <span id="id_input_result"></span></p>
<script>
    $(function () {
        $('#id_get_json').on('click', function () {
            $.ajax({
                url: '/json',
                type: 'POST',
                dataType: 'json',
                data: {'input': $('#id_input').val()}
            }).done((data) => {
                $('#id_foo').html(data['foo']);
                data['bar'].forEach((currentValue, index) => {
                    $('#id_bar').append(`<li>No. ${index}: ${currentValue} </li>`)
                });
                $('#id_input_result').html(data['result']);
            }).fail((data) => { console.log('fail!') })
        })
    })
</script>
</body>
</html>

 
ブラウザで見るとこのような感じです。

f:id:thinkAmi:20181002215447p:plain:w200

データを入力し、GoボタンでAjaxのPOSTを実行すると、以下のように表示が切り替わります。

f:id:thinkAmi:20181002215457p:plain:w200

 

ファイルをアップロード

ファイルをアップロードするHTMLフォームがあるとします。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>File Upload</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
    <p><input type="file" name="upload_file"></p>
    <p><input type="submit"></p>
</form>
</body>
</html>

 
Werkzeugでは、Requestオブジェクトの files を使うことで、アップロードされたファイルを取得できます。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.files

以下のコードでは、アップロードされたファイルを uploads ディレクトリに保存します。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/upload', endpoint='upload'),
        ])

    def upload_handler(self, request):
        f = request.files.get('upload_file')
        f.save(str(pathlib.Path(f'./uploads/{f.filename}')))
        return Response('hello upload')

 

ファイルをダウンロード

ファイルをダウンロードするには、 Content-Disposition ヘッダを追加します。

以下ではURLにアクセスすると foo.csv がダウンロードされます。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/download', endpoint='download'),
        ])

    def download_handler(self, request):
        field_names = ['No', 'Name']
        contents = [
            {'No': 1, 'Name': 'Apple'},
            {'No': 2, 'Name': 'Mandarin'},
            {'No': 3, 'Name': 'Grape'},
        ]
        stream = StringIO()
        writer = csv.DictWriter(stream, fieldnames=field_names)

        # CSVヘッダの書込
        writer.writeheader()
        # CSVデータの書込
        writer.writerows(contents)

        # ストリームからデータを取得し、レスポンスとする
        data = stream.getvalue()

        headers = Headers()
        headers.add('Content-Disposition', 'attachment', filename='foo.csv')

        return Response(
            data,
            headers=headers,
        )

参考

 
ただし、上記の方法では日本語名のファイルはダウンロードできません。

日本語ファイルをダウンロードする場合は、

  • Content-Dispositionヘッダで、 filename*=アスタリスク付きでファイル名を指定する
  • ファイル名はパーセントエンコードする
    • RFC5987に基づく方法

が必要になります。

参考

 
なお、以前、Cookieを調べた時に、いろいろなパーセントエンコードについて知りました。
Pythonで、日本語をCookie値へ設定する方法を調べてみた - メモ的な思考的な

RFC5987でのパーセントエンコードを見たところ、

pct-encoded   = "%" HEXDIG HEXDIG
              ; see [RFC3986], Section 2.1

# https://tools.ietf.org/html/rfc5987#page-3

との記述がありました。

RFC3986についてはCookieの時に調べました。RFC3986でのパーセントエンコードPythonで実現するには、 urllib.parse.quote(character, safe='~') が良さそうでした。

 
そこで、

encoded_filename = quote(request.form['filename'], safe='~')
headers.add('Content-Disposition', f"attachment; filename*=UTF-8''{encoded_filename}")

として 一~二.csv というファイル名でダウンロードしてみたところ、

f:id:thinkAmi:20181002222338p:plain:w200

というファイル名でダウンロードされました。

~ という文字がファイル名に使えなかったため、 _ へと変換されたのでしょう。
ファイルをダウンロードさせたいときのいろんな方法(Servlet, Apacheなど) - Qiita

 

静的ファイルっぽく見せかけた時のレスポンス

これはどちらかというとルーティングについてです。

以下は拡張子 html 付きでアクセスした場合でも、動的なレスポンスを返すようなルーティングとなっています。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/extension.html', endpoint='extension'),
        ])

    def extension_handler(self, request):
        return Response('extension request')

 
curlで調べてみます。

$ curl localhost:5000/extension.html
extension request

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/werkzeug-sample

various_responses ディレクトリ以下が今回のものであり、Werkzeugアプリは various_response_app.py になります。