Flask-Cors を使って Flask を Cross Origin Request に対応する

Flask で作った REST API を Cross Origin Request に対応する必要があり、Flask-Cors を使用しました。その際に調べた Flask-Cors の使い方のメモです。

やりたいこと

Flask で作った REST API と Web フロントコンテンツが Origin が異なっていたので、CORS 対応が必要でした。 通常 Production として使用するような環境では Flask の前に Nginx などの Web Server が立っており、 そちらで CORS 対応をすると思いますが、今回は簡易的に Flask のみで稼働させていたので、Flask で CORS 対応を行いました。

調べてみると Flask-Cors という Flask の Extension パッケージがありましたので、こちらを使用しました。

環境

OS
Microsoft Windows 20H2
ブラウザ
Microsoft Edge 109.0.1518.78
Python
Python 3.9.6
Flask
Flask 2.2.2
Flask-Cors
Flask-Cors 3.0.10

Cross-Origin Resource Sharing (CORS)

CORS については オリジン間リソース共有 (CORS) - HTTP | MDN で詳細に説明されています。ここでは簡単な例示に留めさせてもらいます。途中 Flask-Cors も使っていますが、Flask-Cors の使い方は次項で説明します。また HTTP ヘッダーが何度も出てきますが、長くなりますので、適宜本説明に関係のないヘッダーは省いて掲載しています。

準備

まず、Flask を http://127.0.0.1:5000 (= Origin) で Listen させます。そして Web コンテンツ (ここでのメインは JavaScript) を http://127.0.0.1:5500 (= Origin) にホストします(Web コンテンツのホストは VSCode 拡張機能の Live Server を使用)。Origin は以下の通りポート番号も含まれますので同じホスト 127.0.0.1 でもこれらは Cross-Origin となります。

ウェブコンテンツのオリジン (Origin) は、ウェブコンテンツにアクセスするために使われる URL の スキーム (プロトコル)、 ホスト (ドメイン)、 ポート番号 によって定義されます。 - Origin (オリジン) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN

CORS Request

Flask で以下のように簡単な API を作ります。

from flask import Flask, Response
import json

app = Flask(__name__)

@app.route("/cors", methods=["GET"])
def cors():
    data = {
        "result": "Cross origin request"
    }
    return Response(
        response=json.dumps(data),
        content_type="application/json"
    )

次に以下の JavaScript で Request してみます。

fetch('http://127.0.0.1:5000/cors')
    .then((res) => console.log(res));

すると Edge の開発者ツールのコンソールに以下のようなエラーメッセージが表示されます。 Access-Control-Allow-Origin ヘッダーにリクエスト元 (http://127.0.0.1:5500) がないのでブロックされたと言われています。

Access to fetch at 'http://localhost:5000/nocors' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Edge の開発者ツールのネットワークでレスポンスヘッダーを見てみると以下の通りで、Access-Control-Allow-Origin ヘッダーはないです。

Connection: close
Content-Length: 43
Content-Type: application/json

さらにリクエストヘッダーを見てみると以下の通りとなっており、Cross-Origin Request 以外では見かけない Origin ヘッダーが付与されています。 この Origin が、レスポンスヘッダーの Access-Control-Allow-Origin に含まれている必要があります。

またこのリクエストのことを CORS Request と呼びます。(以前は単純リクエストと呼ばれていたもの) WHATWG の Fetch Standard で仕様策定されています。

Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en;q=0.9,en-GB;q=0.8,en-US;q=0.7
Connection: keep-alive
Host: localhost:5000
Origin: http://127.0.0.1:5500

CORS Requestの対応

それでは Python 側のコードを以下の通り変更します。ポイントは @cross_origin デコレーターです。

from flask import Flask, Response
from flask_cors import cross_origin #追加
import json

app = Flask(__name__)

@app.route("/cors", methods=["GET"])
@cross_origin(origins=["http://127.0.0.1:5500"], methods=["GET"])   #追加
def nocors():
    data = {
        "result": "Cross origin request"
    }
    return Response(
        response=json.dumps(data),
        content_type="application/json"
    )

フロント側は手を入れずにもう一度リクエストしてみます。 すると Edge の開発者ツールのコンソールを見ると Response が返って来ていることが確認できると思います。 さらに Edge の開発者ツールのネットワークでレスポンスヘッダーを確認すると、以下の通り Access-Control-Allow-Origin ヘッダーに http://127.0.0.1:5500 が入って返ってきていることが確認できます。これにより Cross-Origin Request が許可された形となっています。

Access-Control-Allow-Origin: http://127.0.0.1:5500
Connection: close
Content-Length: 59
Content-Type: application/json

次に POST メソッドを試してみます。Python 側コードは POST を追加します。長くなるのでここではデコレーター部分だけ記載します。

@app.route("/cors", methods=["GET", "POST"])    # POST を追加
@cross_origin(origins=["http://127.0.0.1:5500"], methods=["GET"])

フロント側は以下の通り変更します。

const options = {
    method: "POST",
    body: JSON.stringify({
        value: "Cross Origin Request"
    })
}
fetch('http://localhost:5000/cors', options).then((res) => console.log(res));

こちらも特に問題なく Response が返って来ることが確認できます。

CORS-preflight request

ここからさらにフロント側に手を入れて Content-Type ヘッダーを追加します。

const headers = new Headers();  //追加
headers.append("Content-Type", "application/json"); //追加
const options = {
    method: "POST",
    headers,    //追加
    body: JSON.stringify({
        value: "Cross Origin Request"
    })
}
fetch('http://localhost:5000/cors', options).then((res) => console.log(res));

すると Edge の開発者ツールのコンソールに以下のようなエラーメッセージが表示されます。 次は Content-Type ヘッダーは許可されていないのでブロックされたと言われています。さらにプリフライトなるワードが登場しています。

Access to fetch at 'http://localhost:5000/cors' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

さてプリフライトとは何なのでしょう。少し長くなりますが MDN の説明を引用します。

CORS のプリフライトリクエストは CORS のリクエストの一つであり、サーバーが CORS プロトコルを理解していて準備がされていることを、特定のメソッドとヘッダーを使用してチェックします。

これは OPTIONS リクエストであり、 Access-Control-Request-Method,Access-Control-Request-Headers, Origin の 3 つの HTTP リクエストヘッダー使用します。

プリフライトリクエストはブラウザーが自動的に発行するものであり、通常は、フロントエンドの開発者が自分でそのようなリクエストを作成する必要はありません。これはリクエストが"to be preflighted"と修飾されている場合に現れ、単純リクエストの場合は省略されます。

Preflight request (プリフライトリクエスト) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN

つまりブラウザが勝手にメインのリクエスト (今回は POST リクエスト) の前に OPTIONS リクエストでサーバー側に確認し、メインのリクエストを行ってもよいか確認しているものになります。これを CORS-preflight request と呼びます。

Edge の開発者ツールのネットワークを見ると cors エンドポイントに 2 つのリクエストがされていることが確認できます。そのうちの 1 つは OPTIONS リクエストになっています。そのリクエストヘッダーは以下の通りで、Access-Control-Request-HeadersAccess-Control-Request-MethodOrigin が含まれています。

Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en;q=0.9,en-GB;q=0.8,en-US;q=0.7
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: POST
Connection: keep-alive
Host: localhost:5000
Origin: http://127.0.0.1:5500
Referer: http://127.0.0.1:5500/

これに対し、レスポンスヘッダーは以下の通りで、メソッドやヘッダーに対する応答はありません。

Access-Control-Allow-Origin: http://127.0.0.1:5500
Allow: POST, HEAD, OPTIONS, GET
Connection: close
Content-Length: 0
Content-Type: text/html; charset=utf-8

CORS-preflight request の対応

それでは Python 側で以下の通り修正をします。

@app.route("/cors", methods=["GET", "POST"])
@cross_origin(origins=["http://127.0.0.1:5500"], methods=["GET","POST"])

フロント側は手を入れずに、再度リクエストします。 すると Edge 開発者ツールのコンソールに Response が返ってきていることが確認できます。 さらに Edge 開発者ツールのネットワークでプリフライトリクエストのレスポンスヘッダーを確認すると、以下の通り Access-Control-Allow-HeadersAccess-Control-Allow-Methods が返って来ていることが確認できます。これによってメインのリクエストを送ることができ、レスポンスが得られたということです。

Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Origin: http://127.0.0.1:5500
Allow: GET, HEAD, OPTIONS, POST
Connection: close
Content-Length: 0
Content-Type: text/html; charset=utf-8

さてそもそもなぜ Content-Type: application/json を追加するとプリフライトリクエストが発生したのでしょうか。 プリフライトリクエストが発生する条件は様々で、詳細は オリジン間リソース共有 (CORS) - HTTP | MDN で説明されていますが、今回は Content-Type ヘッダーに application/x-www-form-urlencodedmultipart/form-datatext/plain 以外を設定したためです。

プリフライトリクエストが発生するとブラウザはサーバー側から Access-Control-Allow-Origin に加え Access-Control-Allow-HeadersAccess-Control-Allow-Methods を受け取ってアクセス許可があるか確認します。Flask-Cors の cross_origin デコレーターでは methods パラメーターで Access-Control-Allow-Methods で返すメソッド、allow_headers パラメーターで Access-Control-Allow-Headers で返すヘッダーを設定できます。なおこれらのパラメーターのデフォルト値は methods[GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE]allow_headers* となっているので、実は上の例では敢えて methods=["GET"] と書かなければ上手くいきます。説明の都合設定したのと、実際はフルオープンで運用することはないと思われるためです。

CORS 対応まとめ

かなり長くなってしまいましたが、CORS の対応としては、

プリフライトが発生しないケース (CORS request) ではサーバー側が Access-Control-Allow-Origin ヘッダーにアクセス許可するオリジンを入れて返す必要がある、

プリフライトが発生するケース (CORS-preflight request) では、上記に加え、Access-Control-Allow-Headers ヘッダーに許可する HTTP ヘッダー、Access-Control-Allow-Methods ヘッダーに許可する HTTP メソッドを入れて返す必要がある、

となります。

Flask-Cors の使い方

インストール

pip でインストールします。

pip install flask-cors

アプリケーション全体での CORS ポリシー設定

上記の例では cross_origin デコレーターでの設定を紹介しましたが、アプリケーション全体で CORS ポリシーを設定することもできます。 以下の例では / にも /api/api1 にも origins=["http://127.0.0.1:5500"], methods=["GET", "POST"] のポリシーが適用されます。

from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app, origins=["http://127.0.0.1:5500"], methods=["GET", "POST"])

@app.route("/", methods=["GET"])
def home():
    return "Home"

@app.route("/api/api1", methods=["POST"])
def api():
    return "API"

リソース毎の CORS ポリシー設定

CORSresources パラメーターを渡すことでリソース毎に CORS ポリシーを設定できます。 以下の例では /api/* のリソースにのみ CORS ポリシーを設定しています。/ には Cross-Origin Request できません。

from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app, resources={r"/api/*": {origins: ["http://127.0.0.1:5500"], methods: ["POST"]}})

@app.route("/", methods=["GET"])
def home():
    return "Home"

@app.route("/api/api1", methods=["POST"])
def api():
    return "API"

cross_origin デコレーターを使用したリソース毎の CORS ポリシー設定

上の例でも使用した cross_origin デコレーターを使用した CORS ポリシー設定です。

from flask import Flask
from flask_cors import cross_origin

app = Flask(__name__)

@app.route("/", methods=["GET"])
def home():
    return "Home"

@app.route("/api/api1", methods=["POST"])
@cross_origin(origins=["http://127.0.0.1:5500"], methods=["GET", "POST"])
def api():
    return "API"

その他

ここでは originsmethods といった代表的なパラメーターを紹介しましたが、それ以外のパラメーターは本家 API Docs – Flask-Cors 3.0.10 documentation を参照してみてください。

あとがき

簡単にとか言いながら CORS の説明でかなり字数を割いてしまい Flask-Cors の説明なのか CORS の説明なのかわからない記事になってしまいました。Flask-Cors は本家ドキュメント以外に細かい使い方が説明されているページが見当たらなかったので纏めてみました。