PythonのWebフレームワーク「FastAPI」とTypeScript・OpenAPIで、型つきでWebアプリを作ってみる - 機械学習WebAppのための技術スタック

# Intro
Pythonで実装した機械学習や画像処理をバックエンドにしたWebアプリをサクッと作るための技術スタックとして、FastAPI+TypeScript+OpenAPIを紹介します。

# モチベーション

- PythonでサクッとWebサーバ（APIサーバ）を立てたい
  - 今まではFlaskを使ってたような用途
- 「Pythonで」
  - 機械学習・画像処理のサービスなので
- 「サクッと」
  - バリデーションとか楽したい
- サーバ、クライアント共に型の保証が欲しい
  - 機械学習や画像処理のアプリはパラメータが多くなりがち・一貫した慣習が無いのでミスしやすい
    - `width` or `w`
    - 値の範囲は`[0, w]` or `[0, 1]` ?
  - →型アノテーションでカバーしたい
- やりたいこと
  - API endpoint公開
  - メディアファイルアップロード・ダウンロード
  - Additional: 非同期通信、WebSocket

# 検討した選択肢

## Webフレームワーク

- (Flask)
- Sanic
- Starlette
- FastAPI

## 型保証

- gRPC-web
- OpenAPI

# 先に結論
* FastAPIで簡単に、宣言的にWebAPIが作れる
    * [Pydantic](https://pydantic-docs.helpmanual.io/)によるスキーマ定義
* FastAPIでのサーバサイドの定義から、OpenAPIを経由してフロントエンドライブラリを生成できる。
    * 本稿では生成先言語としてTypeScriptを使う。サーバサイドと一致した型定義を利用できる。

# FastAPI?
![FastAPI logo](https://fastapi.tiangolo.com/img/logo-margin/logo-teal.png)
https://fastapi.tiangolo.com/

- starletteベース
- ASGI
- 国内では2019年初頭にバズった

# Demo

上記モチベーションのうち、

- サクッとAPIを立てて
- 型を保証したクライアントが使える

ことのデモ

サンプルRepositoryはこちら: https://github.com/tuttieee/fastapi-typescript-openapi-example

基本的にFastAPIのチュートリアルの通りに進めていくが、↑のために若干改変してある。

- WebAppを作るところまでカバーするため、frontend (TypeScript)もサンプルに含める
- Dockerで開発、デプロイする

## Dockerで立ち上げる

https://fastapi.tiangolo.com/deployment/ の通り。

- frontendとのモノレポにするため、１階層掘って `/backend` に置く。
- あとでmigrationなどを足すため、もう１階層掘って `/backend/app/app` とする。`/backend/app` がdockerコンテナにマウントされる
- docker-composeで管理する

`docker-compose build`, `docker-compose up` のあと、`curl localhost:8000` をやってみる

```shell
$ curl localhost:8000
{"Hello":"World"}
```

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/703af24fff8d39e261e1f1ce5ba859f28e806fb2

## 開発用設定

- `docker-compose.devel.yml`
  - Host側ディレクトリをマウント
  - auto-reloadを有効化
    - docker run のcommandに`/start-reload.sh`を指定するとオートリロードが有効になる（利用している [公式Dockerイメージ](https://hub.docker.com/r/tiangolo/uvicorn-gunicorn-fastapi/) のオプション）

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/33f6e91bc48c9c6396a9ceaf1e720b88c78a6822

## SQLと繋がったAPIを作ってみる

基本的に↓に従って進める。
MySQLを使う部分は独自に追記。

https://fastapi.tiangolo.com/tutorial/sql-databases/

### SQLAlchemyインストール

とりあえずDockerfileで直に `RUN pip install sqlalchemy`

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/75b7d2d7ccb8bc5a142b86bd3800b5e190732628

### まずSQLite

- `app/app/__init__.py`, `app/app/database.py`, `app/app/models.py` を作成
- 動くか確かめてみる
  - shellに入り、`python -i`
  - ↓を叩いてみる

```python
from app import models
from app.database import SessionLocal, engine
models.Base.metadata.create_all(bind=engine)
user = models.User(email='test@example.com', hashed_password='')
db = SessionLocal()
db.add(user)
db.commit()
db.close()
db = SessionLocal()
db.query(models.User).all()
```

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/8f0d85445c33bfddd05466060b11501c2f3748b3

### MySQL

#### backendコンテナに関連ソフトウェアをインストール

```dockerfile
RUN apt-get update && apt-get install -y \
    default-mysql-client \ 
&& apt-get clean \ 
&& rm -rf /var/lib/apt/lists/*

RUN pip install sqlalchemy PyMySQL
```

サンプル:

- https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/feceece4e736153a63386549e4470fec9b7eabac
- https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/13716ae2ba492c49ab158a1ba1cfa7cffc731ad4

#### MySQLコンテナ追加

- [公式イメージ](https://hub.docker.com/_/mysql) を利用。基本的に、READMEの通りに`docker-compose.yml`に設定を書く
- Credentialsを`.env`に追記、`docker-compose.yml`で環境変数として渡す


サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/22d760d963394f340622ea402fdcf72f02cd240f

#### Alembic
- `Dockerfile`に`pip install alembic`追記（いずれrequirements.txt化？） -> `docker-compose build`
- `database.py`を更新
  - database URLをMySQL用に更新
  - `create_engine` をMySQL用に更新（不要な引数を除去）
  - naming conventionを追記

Tips: starletteのクラスがそのまま使える

その後shellに入り、以下の作業をする

##### 初期化
- `alembic init migrations` Ref: https://alembic.sqlalchemy.org/en/latest/tutorial.html

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/979f4b3982b3ce3e7631661af3171000d6ea0381

##### 設定
Ref: https://alembic.sqlalchemy.org/en/latest/autogenerate.html

- `alembic.ini` 編集
  - `sqlalchemy.url` を削除（`database.py`から取るようにするので）
- `migrations/env.py` 編集
  - ↓を追記

```python
from app.models import Base
from app.database import SQLALCHEMY_DATABASE_URL

config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
target_metadata = Base.metadata
```

##### Migration生成
- `models.py` の定義をMySQL用にしておく（`String` に長さを指定する）
- `alembic revision --autogenerate -m "Add users and items tables"`

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/4ceae799f6f6b83ffc71cfec5ce13a669e137757

##### Migration実行
- `alembic upgrade head`

##### 試してみる
- `python -i` で以下を実行

```python
from app import models
from app.database import SessionLocal
user = models.User(email='test@example.com', hashed_password='')
db = SessionLocal()
db.add(user)
db.commit()
db.close()

db = SessionLocal()
db.query(models.User).all()
```

### API作成

#### Pydanticモデル作成
See https://fastapi.tiangolo.com/tutorial/sql-databases/#create-initial-pydantic-models-schemas
`app/schemas.py` 作成

#### CRUD utils作成

See https://fastapi.tiangolo.com/tutorial/sql-databases/#crud-utils
`app/crud.py` 作成

#### メインAPI作成

See https://fastapi.tiangolo.com/tutorial/sql-databases/#main-fastapi-app
`app/main.py` 更新

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/1e7d140c1d7929c15f7815648deb14f831807ddd

### 動作確認

cURL例:

- `curl localhost:8000/users/`
- `curl -X POST -d '{"email": "testtest@example.com", "password": "test"}' localhost:8000/users/`

OpenAPIから
<http://localhost:8000/docs>

### MISC

- `prestart.sh`
  - Wait DB container
  - Migration
  - サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/9a52eb1415a484647da43c6f65002213647cc577
- Makefile
  - Migration
  - サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/bb72cdd3ac7b212938d5e8122ad3e6f0ec918de4
- API endpoint名を簡略化する（関数名に一致させる）
  - `backend/app/app/main.py` に追記
  - サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/36474ed029fbf6a03dc3d815ecfe7462ac3cf1fe

```python
from fastapi.routing import APIRoute

...

def use_route_names_as_operation_ids(app: FastAPI) -> None:
  """
  Simplify operation IDs so that generated API clients have simpler function
  names.

  Should be called only after all routes have been added.
  """
  for route in app.routes:
      if isinstance(route, APIRoute):
          route.operation_id = route.name

use_route_names_as_operation_ids(app)
```

## Frontend
### Create React App

```shell
$ yarn create react-app frontend --template typescript
$ cd frontend
$ yarn start
```

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/21b1f0f926132fdbf9c1dbef967ef4286fa27279

### （とりあえず）APIアクセス

- CORS設定
  - `backend/app/app/main.py` に以下を追記

```python
# TODO: This is for development. Remove it for production.
origins = [
    "<http://localhost:3000",>
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
```

- `yarn add axios`
- `frontend/src/App.tsx` で雑にfetchしてみる

```tsx
import React, { useState, useEffect } from 'react';
import './App.css';
import { DefaultApi, Configuration, User } from './api-client';

const config = new Configuration({ basePath: 'http://localhost:8000' }); // TODO: This is for dev
export const apiClient = new DefaultApi(config);

const App: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    apiClient.readUsers().then((response) => {
      setUsers(response.data);
    })
  })

  return (
    <div className="App">
      <ul>
        {users.map(user =>
          <li key={user.id}>{user.email}</li>
        )}
      </ul>
    </div>
  );
}

export default App;
```

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/79fc0bb26c6ffa7e6e43473f0ef497dd1c2f53ff

#### 現コードベースの問題点
- 手動で型定義しなければならない
  - 自分で `interface User` とか書く

## OpenAPIによるクライアント自動生成
### Intro
[OpenAPI](https://www.openapis.org/): 旧Swagger
FastAPIがOpenAPIのJSON定義を生成してくれる: <http://localhost:8000/openapi.json>
このJSONを食って様々な言語のクライアントを生成してくれる[OpenAPI Generator](https://openapi-generator.tech/)

### DockerでOpenAPI Generatorを動かす
See https://openapi-generator.tech/docs/installation.html#docker

- `docker-compose.openapi-generator.yml` を作成
- 
```yaml
version: "3.7"

services:
  openapi-generator:
    image: openapitools/openapi-generator-cli
    volumes:
      - ./frontend:/frontend
    working_dir: /frontend
    command:
      - generate
      - -g
      - typescript-axios
      - -i
      - <http://backend/openapi.json>
      - -o
      - /frontend/src/api-client
      - --additional-properties=supportsES6=true,modelPropertyNaming=original
      # modelPropertyNaming=original is necessary though camelCase is preferred
      # See <https://github.com/OpenAPITools/openapi-generator/issues/2976>
```

- `Makefile` にコマンドを追記

```makefile
oapi/gen:
  docker-compose -f docker-compose.yml -f docker-compose.openapi-generator.yml up openapi-generator \
  && docker-compose -f docker-compose.yml -f docker-compose.openapi-generator.yml rm -f openapi-generator
```

### Frontendを自動生成コードを使うよう書き換える

`frontend/src/App.tsx`

```tsx
import React, { useState, useEffect } from 'react';
import './App.css';
import { DefaultApi, Configuration, User } from './api-client';

const config = new Configuration({ basePath: '<http://localhost:8000'> }); // TODO: This is for dev
export const apiClient = new DefaultApi(config);

const App: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    apiClient.readUsers().then((response) => {
      setUsers(response.data);
    })
  })

  return (
    <div className="App">
      <ul>
        {users.map(user =>
          <li>{user.email}</li>
        )}
      </ul>
    </div>
  );
}

export default App;
```

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/1d38242a63c9a5477fa1cde506f36f68a19060a7

# その他

## このDemoで扱わなかったこと

実際のプロジェクトでは、Unittest, CI, Linterなどは適宜採用

## FastAPIのその他の利点、なぜ他の技術と比べてFastAPIを採用したか

- Python
  - 画像扱ったり、機械学習的なことをしたり→Pillowやnumpyは欲しい
- 非同期通信（WebSocket）のサポート
  - ASGIベース
  - Flaskでやろうとするとgeventとかハックが必要
- 普通のHTTP
  - メディアファイルのアップロードなどはmultipart/formdataでやりたいし、ダウンロードは直接ファイルを落としたい
    - gRPC-webだとこの辺が微妙
    - https://github.com/grpc/grpc-web/issues/517
- オールインワン
  - 「サクッと」に通じる
  - ドキュメントが充実、production-readyなdockerイメージも公式が提供
  - 例えば、FlaskやSanicとかでも、プラグインを入れればOpenAPIを使えるなど