FlaskにSPAバックエンドを全て任せたい話

- Flask 1.0.2
- REST APIサーバだけでなく、SPA（Single Page Application）のWebフロントサーバーとしての役割を`Flask`が担うにはどうするか？

# 概要

SPAルーターに定義された各URLパスに対するリクエストを捌きつつ、SPAのエントリポイントとなる`index.html`や各種JavaScript/CSSなど静的ファイルを担当し、さらにRESTエンドポイントとしても動作させるための設定をまとめた。

通常はフロントにWebサーバーを置いて、静的リソースへのリクエストはそちらに担当させることが多い。しかし簡易的なツールなど「Python単独で動作すること」が要件になることもある。その場合に参考にして欲しい。

# 全体像

次に示す4パターンのリクエストに対処する必要がある。


|#|分類|種別|パスのパターン|備考|
|---|---|---|---|---|
|1|バックエンド|REST API|`/api/**/*`||
|2|フロントエンド|静的ファイル<br/>（HTML/JS/CSS）|`/`<br/>`/*`|ファイルが存在する場合のみ|
|3||静的ファイル<br/>（画像/JSON）|`/images/*`<br/>`/json/*`||
|4||SPAルータに定義されたURLパス|上記以外|ブラウザリロードやURL直打ちで発生する|

本記事で示すサンプルコードのプロジェクト構成を以下に示す。

```
<root>
+---src/
|   |   main.py
|   +---app/
|           __init__.py
|           api.py
|           view.py
+---static/ 
    +---html/
    |       index.html
    |       bundle.js
    |       bundle.css
    +---images/
    |      image001.png
    +---json/
           messages.json
```

# バックエンド

`Blueprint`としてRESTエンドポイントを定義する。

ここでは`Flask-RESTful`を利用しているが、特に必須ではない。`Flask`単体でも良いし、他のプラグインを使っても良い。

```python:app/api.py
from typing import Dict
from flask import Blueprint
from flask_restful import Resource, Api

hello = Blueprint('hello', __name__)
api = Api(hello)


@api.resource('/hello')
class HelloResource(Resource):
    def get(self) -> Dict[str, str]:
        return {
            'hello': 'world',
        }

```

`main.py`にて定義したRESTエンドポイントを登録する。

`Flask#register_blueprint()`を呼び出す際にURLプリフィックス`/api`を指定するのがポイント。

```python:main.py（途中）
from flask import Flask

from app.api import hello

flask_app = Flask(__name__)

# REST API を定義
flask_app.register_blueprint(hello, url_prefix='/api')

```

上記の`main.py`は後ほど追記する。

# フロントエンド

HTML/JavaScript/CSSファイルをserveする設定。これもやはり`Blueprint`として定義する。

リクエストとして受け取ったファイル名が`<root>/static/html/`ディレクトリに存在すればそのファイルを返し、存在しなければ`index.html`を返すのがポイント。これにより、SPAルーターに定義されたURLパスに対するリクエストの処理をJavaScriptへ委ねることができる。

```python:app/view.py
from flask import Blueprint, send_from_directory
from flask.helpers import NotFound

html = Blueprint('html', __name__)


@html.route('/', defaults={'filename': ''})
@html.route('/<path:filename>')
def index(filename: str):
    try:
        return send_from_directory('../static/html', filename)
    except NotFound:
        return send_from_directory('../static/html', 'index.html')

```

`main.py`に追記し、上記で定義した`Blueprint`を登録する。さらに画像ファイルおよびJSONファイルといった他の静的リソースに関する`Blueprint`も作成・登録している。

```python:main.py（完成）
from flask import Flask, Blueprint

from app.api import hello
from app.view import html

flask_app = Flask(__name__)

# REST API を定義
flask_app.register_blueprint(hello, url_prefix='/api')

# 静的ファイル（画像、JSON）
json = Blueprint('json', __name__, static_url_path='/json', static_folder='../static/json')
images = Blueprint('images', __name__, static_url_path='/images', static_folder='../static/images')
flask_app.register_blueprint(json)
flask_app.register_blueprint(images)

# 静的ファイル（HTML/JavaScript/CSS）
flask_app.register_blueprint(html)

if __name__ == '__main__':
    flask_app.run(host='0.0.0.0', port=8000)


```

- 注意
    - `Blueprint`の定義・登録順が上記のサンプルコードと異なると正しく作動しない可能性がある

# 補足

本記事のサンプルコードの動作確認のために、以下の８パターンのHTTPリクエストに対して期待するレスポンスが返却されることを確認した。

|#|リクエスト|レスポンス|
|---|---|---|
|1|`/`|`index.html`|
|2|`/index.html`|`index.html`|
|3|`/bundle.js`|`bundle.js`|
|4|`/bundle.css`|`bundle.css`|
|5|`/foo/bar/buz`|`index.html`|
|6|`/images/image001.png`|`image001.png`|
|7|`/json/messages.json`|`messages.json`|
|8|`/api/hello`|`{"Hello":"World"}`|