FastAPI + uvicorn + NginxでWebページを表示（Jinja2によるTemplates機能 ）

# 0. はじめに

- 「[FastAPI + uvicorn + nginxをdocker-composeで構築](https://qiita.com/junkor-1011/items/02ed76b94c60deba8282)」で作ったものを元にして、[Jinja2](https://jinja.palletsprojects.com/en/2.11.x/)によるTemplates機能などを使ってWebページの雛形を作る
    - 前に[Flask](https://flask.palletsprojects.com/en/1.1.x/)などを試しに使っていたことがあり、TemplateやStaticファイルの配信を同じような感じで出来るか調べたかった

※続編： [FastAPI + uvicorn + NginxでWebページを表示（SSL/HTTPS化 ）](https://qiita.com/junkor-1011/items/f7ff31b7df94e623e6b7)

# 実践：Webページ機能追加

※前提：「[FastAPI + uvicorn + nginxをdocker-composeで構築](https://qiita.com/junkor-1011/items/02ed76b94c60deba8282)」の状態からスタートする

## 1. Packageの追加

- Template機能用に`Jinja2`が、Staticファイルの配信に`aiofiles`の追加インストールが必要
    - 参考：[公式ドキュメント](https://fastapi.tiangolo.com/advanced/templates/#install-dependencies)
- `pyproject.toml`に以下のように追記

```toml:pyproject.toml(追加分)
[tool.poetry.dependencies]
# 以下の2つを追加
jinja2 = "*"
aiofiles = "*"
```

トータルでは例えば以下のようになる：

```toml:pyproject.toml
[tool.poetry]
name = "test_fastapi_app"
version = "0.1.0"
description = "just for test"
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.8"
uvicorn = "*"
fastapi = "*"
jinja2 = "*"
aiofiles = "*"

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"
```

## 2. `app`の中身の修正・追加

- 基本は[公式の例](https://fastapi.tiangolo.com/advanced/templates)を参考にすれば良い
- ここではマルチページ化したり、Rest-API機能などと機能分離して作り込んでいく可能性も考えて[ここ](https://fastapi.tiangolo.com/tutorial/bigger-applications/)を参考にサブアプリのような形でWebページを作ってみる

### ファイル構成（`app`）

```bash
$ tree
.
├── app
│   ├── Dockerfile
│   ├── app
│   │   ├── __init__.py
│   │   ├── main.py
│   │   ├── routers
│   │   │   ├── __init__.py
│   │   │   └── subpage.py
│   │   ├── static
│   │   │   ├── layout.css
│   │   │   └── subpage
│   │   │       ├── test.css
│   │   │       └── test.js
│   │   └── templates
│   │       ├── layout.html
│   │       └── subpage
│   │           └── index.html
│   ├── poetry.lock
│   └── pyproject.toml
├── docker-compose.yml
└── web
```

`app/app`内のファイルを変更・追加しているので以下詳細を見ていく

### `main.py`

- 以下のように内容を修正

```python:main.py
"""                         
app main                      
"""                        
                      
import pathlib
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse

from .routers import subpage

# pathlib.Pathを使って、staticディレクトリの絶対パスを取得
PATH_STATIC = str(pathlib.Path(__file__).resolve().parent / "static")


def create_app():
    """
    create app

    - 少し複雑化してきたので関数化している
    """
    _app = FastAPI()

    # routersモジュールのサブアプリ`subpage`をURL"/subpage/"以下にマウントする
    _app.include_router(
        subpage.router,
        prefix="/subpage",
        tags=["subpage"],
        responses={404: {"description": "not found"}},
    )

    # static
    # URL`/static"以下にstaticファイルをマウントする
    _app.mount(
        "/static",
        StaticFiles(directory=PATH_STATIC, html=False),
        name="static",
    )

    return _app


app = create_app()


@app.get('/')
async def redirect_subpage():
    """redirect webpage"""
    return RedirectResponse( # subpageのサブアプリで作ったWebページにリダイレクトさせている
        "/subpage",
    )
```
若干行数が増えたが、やっていることは主に

- `static`の追加
- サブアプリ：`subpage`のマウントとリダイレクト

### `routers/subpage.py`

※同じ場所にある`__init__.py`は空ファイル

```python:routers/subpage.py
"""
test subpage
"""

import pathlib

from fastapi import (
    APIRouter,
    Request,
)
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse

# templatesディレクトリの絶対パスを取得
PATH_TEMPLATES = str(
    pathlib.Path(__file__).resolve() \
        .parent.parent / "templates"
)
# Jinja2のobject生成
templates = Jinja2Templates(directory=PATH_TEMPLATES)


# サブアプリ
router = APIRouter()


@router.get("/", response_class=HTMLResponse)
async def site_root(
    request: Request,
):
    """test subpage"""
    title = "test subpage"
    return templates.TemplateResponse(
        "subpage/index.html",   # `templates`ディレクトリにおける相対パス
        context={   # 変数をdict形式で渡すことが出来る
            "request": request,
            "title": title,
        }
    )
```
- Jinja2のTemplate機能を使い、`templates`ディレクトリ下のファイルを指定してHTMLレスポンスとして返している
- Flaskなどのように、パラメータを渡すことが出来る

### `templates`ディレクトリ

#### `layout.html`

- 複数のhtmlファイルで共通して使うような想定
    - 他のファイルから`extends`で呼び出される

```html:layout.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
        <meta name="apple-mobile-web-app-capable" content="yes">
        {% if title %}
            <title>{{ title }}</title>
        {% else %}
            <title>Template</title>
        {% endif %}

        <!-- jQuery & Bootstrap4 -->
        <script
            src="https://code.jquery.com/jquery-3.4.1.min.js"
            integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
            crossorigin="anonymous"></script>
        <link
            rel="stylesheet"
            href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
            integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
            crossorigin="anonymous">

        <script
            src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
            integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
            crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"
            integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
            crossorigin="anonymous"></script>

        <!-- jQuery UI -->
        <script
            src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"
            integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU="
            crossorigin="anonymous"></script>
        <link
            rel="stylesheet"
            type="text/css"
            href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.min.css">

        <!-- CUSTOM STYLE -->
        <link rel="stylesheet" type="text/css" href="{{url_for('static', path='/layout.css')}}">
        {% block head %}{% endblock %}

    </head>

    <body>
        {% block content %}{% endblock %}
    </body>

</html>
```

- `jQuery`、`jQuery-UI`、`Boostrap`の読み込み
- `title`はパラメータとして受け取っている
- `head`と`contents`部分の中身は個別に別ファイルで記入していく
- テスト用に`layout.css`をstaticディレクトリからJinja2の`url_for`を使って読み込んでいる

#### `subpage/index.html`

```html:subpage/index.html
{% extends "layout.html" %}

{% block head %}
<link
    rel="stylesheet"
    type="text/css"
    href="{{ url_for('static', path='/subpage/test.css')  }}">
<script
    type="text/javascript"
    src="{{ url_for('static', path='subpage/test.js') }}"></script>
{% endblock %}


{% block content %}
<h2>Test Subpage</h2>

<br>

<h3>
    Hello, World.
</h3>

{% endblock %}
```

- `layout.html`を`extends`し、FastAPI側から必要なパラメータを受け取ることで完全なHTMLになる
- テスト用にstaticディレクトリから`test.css`と`test.js`を読み込んでいる

### `static`ディレクトリ

- テスト用に置いただけでどれも実質中身が無いので詳細は省略
- 後で実行時にちゃんと中身を読み込めていることを確認していく

## 3. 実行（その1）

以下を実行

```bash
# パッケージ追加、ソース修正・追加を行ったのでビルドし直す
docker-compose build

# サービスの起動
docker-compose up -d
```

ローカル環境で実行している場合、 http://localhost を見てみる

![スクリーンショット 2020-08-16 00-28-56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680173/c85abcff-0601-96bb-b976-811ec4972ef7.png)

![スクリーンショット 2020-08-16 00-34-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680173/f418d35e-7f8e-f806-2837-e3df672e2dc5.png)

ぱっと見ではうまくいってそうな気配がするが、よく見るとHTMLから`static`のファイルを読み込めていない:

```html
<link rel="stylesheet" type="text/css" href="http://backend/static/layout.css">
        
<link
    rel="stylesheet"
    type="text/css"
    href="http://backend/static/subpage/test.css">
<script
    type="text/javascript"
    src="http://backend/static/subpage/test.js"></script>
```

- **`src`や`href`のurlがこの場合`http://localhost/<url>`となっていて欲しいのに、↑のように`http://backend/<url>`となってしまう**
    - **HTMLファイル上で**`url_for`を使おうとすると上記の問題が起こる
      - なお、**FastAPIのコード中**（`main.py`や`routers/***.py`など）で`url_for`を使う分には特に問題無い


この辺りはproxyの問題が絡むため、`uvicorn`の起動オプションと`nginx`の設定を修正して対応する必要がある

## 4. 設定の修正と実行（その2）

### 4-1. `uvicorn`

- uvicorn公式の[deployment](https://www.uvicorn.org/deployment/)と[setting](https://www.uvicorn.org/settings/)の部分を頑張って読むと、`--proxy-headers`オプションの設定が必要な様子

結論から言うとDockerfileの`CMD`を以下のように変更して`uvicorn`の起動オプションを修正

```dockerfile:Dockerfile（修正分）
# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--proxy-headers", "--forwarded-allow-ips", "*"]
```

### 4-2. `Nginx`

次にNginxの設定ファイル（`web/conf.d/app.conf`）を修正する
(uvicornの[deployment](https://www.uvicorn.org/deployment/)にNginxの例があるので参考にする)

```bash
$ tree    
.
├── app
├── docker-compose.yml
└── web
    └── conf.d
        └── app.conf
```

- ↑の`app.conf`を以下のように修正する
    - `location /`内の項目を追加：

```conf:conf.d/app.conf
upstream backend {
    server app:8000;
}

server {
    listen 80;
    # server_name  localhost;
    # index index.html index.htm;

    location / {
        # 以下の5項目を追加
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
        proxy_buffering off;

        proxy_pass http://backend;
    }

    # log
    # access_log /var/log/nginx/access.log;
    # error_log /var/log/nginx/error.log;
}

# server_tokens off;
```

ここまでやるとHTMLファイル上で`url_for`をしたものも正しく`http://localhost/<url>`といった感じで期待通りのURLが呼び出されるようになる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680173/733dccaa-0b9d-65f0-20aa-61f4f4b8a88d.png)

- ↑staticから呼び出したファイルのURLが正しくなっている


# まとめ

- FastAPI + uvicorn + Nginx (docker-compose)の構成で、Flaskでやるような雰囲気のWeb機能を作成
- Template機能などを使ったWeb機能作る上ではFlaskの方が挙動が簡単だし、文献も多いので楽だと感じた
    - FastAPIはRestAPI機能などに特化したものでWeb機能は得意でないのかもしれない。ただ、非同期処理などを使いこなせればパフォーマンス面でのポテンシャルはあるかもしれない。
- SSL化するところもやった（そして結構面倒だった）~~ので後で更に続きを書く予定~~
    - → 続編： [FastAPI + uvicorn + NginxでWebページを表示（SSL/HTTPS化 ）](https://qiita.com/junkor-1011/items/f7ff31b7df94e623e6b7)

# 参考

- [FastAPI公式ドキュメント](https://fastapi.tiangolo.com/)
- [starlette公式ドキュメント](https://www.starlette.io/)
    - FastAPIはstarletteの拡張になっている（らしい）ので、適宜starletteの仕様を確認する必要がある
- [uvicorn公式ドキュメント](https://www.uvicorn.org/)
- https://note.com/yusugomori/n/n9f2c0422dfcd