ローリングコンバットピッチなう!

AIとか仮想化とかペーパークラフトとか

Chrome+Angularに対応したAPIサーバーをFlaskで書く時の注意点

Angularの修行

最近、Angularの修行をしています。
Angularの公式チュートリアルといえばTour of Herosですが、Angularの日本語ドキュメントは文章表現、言い回しが独特でとっつきにくいところがあります。

https://angular.jp/tutorial


ちょっと肩慣らしを思って、以下のサイトのチュートリアルをやりました。
Angular入門 - とほほのWWW入門

こちらではAngular以外にもBootstrap等も判りやすく解説されていて、結構お世話になっているサイトです。
ちなみにチュートリアル内容をそのまま写経していると、おそらくAngularのバージョン差分に引っかかり、ちょいちょいTypeScriptのトランスコードでエラーになります。そのあたりをググってStackOverflowとかにたどり着きながら解決するのも良い勉強になりますが、なかなかにシンドいですね。

で、チュートリアルを進めていくと、最後のところでAPIサーバーを立てる必要が出てきます。

チュートリアルではnodejsでAPIサーバーを立てているのですが、自分はイマイチnodejsに噛み合わず、pythonAPIサーバーを作りたいので、チュートリアルに載っているのとほぼ同等の機能をpython + flaskで実装しました。

Access-Control-Allow-Originヘッダが無い?

チュートリアルではAngularのアプリのコードを提供するAPサーバーと、アプリにデータを提供するAPIサーバーが別のポート番号で動作します。
クロスサイトスクリプティング対策に対応するために、サンプルのAPIサーバーでは「Access-Control-Allow-Origin」ヘッダに対して「*」を付けて返す用に実装されていました。

これを真似てFlask版のAPIサーバーでも、下記の様にGETやPUTのレスポンスに個別にヘッダを追加して対処していました。
(add_response_header()でレスポンスに「Access-Control-Allow-Origin」ヘッダと「Access-Control-Allow-Headers」ヘッダを追加)

# Response Headers
res_headers = [
    {'header':'Access-Control-Allow-Origin','value':'*'},
    {'header':'Access-Control-Allow-Headers','value':'Origin, X-Requested-With, Content-Type, Accept'}
]

def add_response_header(response):
    global res_headers
    for header in res_headers:
        response.headers[header['header']] = header['value']

# Routing
@app.route('/users',methods=['GET','POST'])
def f_users():
    global users
    if request.method == 'GET':
        response = jsonify(users)
        add_response_header(response)
        return response
    elif request.method == 'POST':
    # 書き込まれたjsonの内容チェックをするべきだが省略
        json = request.get_json()
        users.append(json)
        response = Response(status = 200)
        add_response_header(response)
        return response

このコードをcurlでテストして想定通りのヘッダが付与されている事を確認、その上でAngularアプリと連動させる実験をしました。

ところが...GETは良いのですが、データをAPIサーバーに書き込もうとすると正常に動作しない。
Chrome上でアプリを動かしていたのですが、Chromeデバッグ表示を有効にして見てみるとCORSエラーとして「Access-Control-Allow-Origin」が無いという表示が出てエラーになっていました。

いや、ヘッダ返しているはずだけど?と思ってAPIサーバー側のログを見ると、POSTではなく、OPTIONSメソッドが発行されています。
どうやら(少なくとも)Chrome上では、AngularのhttpモジュールからPOSTしようとすると、POSTの前でOPTIONSメソッドでヘッダチェックが走り、CORS(Cross Origin Resource Sharing: オリジン間リソース共有)のチェックをパスすると、POSTメソッドが発行される様です。

ということは、APIサーバー側でOPTIONSメソッド対応も書かなきゃいけないの?めんどせ〜と思ってしまいました。

flask_cors

リソース毎にOPTIONSメソッド対応も一個一行書くのは超面倒だと思って調べたら、flaskにはflask_corsというオプションパッケージがあることが分かりました。

flask-cors.readthedocs.io

pipでインストール出来ます。

$ pip install flask-cors

一番単純な使い方は公式ドキュメントの先頭に載っていますが、

from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

と、CORS()という関数をappを渡すだけ。これでAPIサーバーの全ての応答に「Access-Control-Allow-Origin」ヘッダが付与されます。
ちなみにデフォルトでは「*」が値に設定されますが、CORS()のオプションでオリジンのlistを渡す事が可能です。

全てのリソースにではなく、リソース毎にCORSの許可・不許可を切り替えたい場合は、各リソースのルーティングディレクティブの下に@cross_origin()というディレクティブを並べる事で、個別にヘッダを足さなくても大丈夫な様です。

@app.route("/")
@cross_origin()


なお、
Angular入門 - とほほのWWW入門
に対応したPython+Flask版のAPIサーバー全ソースを以下に掲載しておきます。
(別のソースからコピペ流用したところがあるので、結構不要なimportが混ざっています)
エラーチェック等は全然していません。例えばAngularアプリ側からPOSTされたjsonのフォーマットをチェックする等、本来はすべきですが、受信したものを信じて処理しています。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask, Response, render_template, request, redirect, url_for,jsonify,make_response
from werkzeug.serving import WSGIRequestHandler
from flask_cors import CORS
import json

# Port No.
PORT=5000


# Internal Data(Example)
users = [
    { 'id':1, 'name':'Yamada','email': 'yamada@example.com'},
    { 'id':2, 'name':'Tanaka','email': 'tanaka@example.com'},
    { 'id':3, 'name':'Suzuki','email': 'suzuki@example.com'}
]

def search_userid(id):
    global users

    idx = 0
    ret_idx = -1
    for user in users:
        if user['id'] == id:
            ret_idx = idx
            break
        idx += 1
    
    return ret_idx


# Initialize APP
app = Flask(__name__)
# JSONにASCII以外の文字列を使用可能にする。(このサンプルでは意味なし)
app.config["JSON_AS_ASCII"] = False
# CORS(Cross Origin Resource Sharing: オリジン間リソース共有)設定
CORS(app)

# Routing
@app.route('/users',methods=['GET','POST'])
def f_users():
    global users
    if request.method == 'GET':
        response = jsonify(users)
        return response
    elif request.method == 'POST':
    # 書き込まれたjsonの内容チェックをするべきだが省略
        json = request.get_json()
        users.append(json)
        response = Response(status = 200)
        return response



@app.route('/users/<int:id>',methods=['GET','POST','DELETE'])
def f_users_id(id):
    global users

    idx = search_userid(id)

    if request.method == 'GET':
        if idx != -1:
            response = jsonify(users[idx])
        else:
            response = Response(status = 404)
        return response
    elif request.method == 'POST':
    # 本来はURLで指定されたidとjson内のidを比較チェックすべきだが省略
    # 書き込まれたjsonの内容チェックをするべきだが省略
        json = request.get_json()
        response = jsonify(json)
        if idx != -1:
            users[idx] = json
        else:
            response.status = 404
        return response
    # 書き込まれたjsonの内容チェックをするべきだが省略
        json = request.get_json()
        response = jsonify(json)
        if idx != -1:
            users[idx] = json
        else:
            response.status = 404
        return response
    elif request.method == 'DELETE':
        if idx != -1:
            del users[idx]
            response = Response(status = 200)
        else:
            response = Response(status = 404)
        return response

if __name__ == '__main__':
    WSGIRequestHandler.protocol_version = "HTTP/1.1"
    app.debug = False
    app.run(host='0.0.0.0',port=PORT)