Docker を利用した FastAPI の CRUD 実装まで。

# 概要

FastAPI での CRUD の構築手順と動作確認するために　Windows10 WSL2 の Ubuntu 18.04 上に FastAPI を実装します。
その際、Docker でアプリケーションサーバと DB サーバを構築します。
ここではセキュリティについての考慮は一切していません。

内容としては、最低限の情報を持った著者（authors）と書籍（books）を登録、閲覧するシンプルな API となります。

FastAPI については公式サイトをご確認ください。
https://fastapi.tiangolo.com/ja/

# 前提

以下が WSL2 上にインストール済みであること。

- Python3（3.6+）
- pip3
- Docker
- docker-compose
- MySQL Client（必要に応じて）
- ~~FastAPI~~（コンテナ側で対応するため WSL2 上では必要なし）
- ~~uvicorn~~（コンテナ側で対応するため WSL2 上では必要なし）

また、`sudo service docker start` で docker が起動済みであること。

# ファイル構成

```
├── app
│   ├── conf
│   │   ├── __init__.py
│   │   ├── database.py
│   │   └── wait-for-it.sh
│   ├── cruds
│   │   ├── __init__.py
│   │   ├── authors.py
│   │   └── books.py
│   ├── main.py
│   ├── models
│   │   ├── __init__.py
│   │   ├── authors.py
│   │   └── books.py
│   └── schemas
│       ├── __init__.py
│       ├── authors.py
│       └── books.py
├── docker
│   ├── app
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   └── mysql
│       ├── Dockerfile
│       └── conf.d
│           └── my.cnf
└── docker-compose.yml
```

* `__init__.py` はすべて空ファイルです。
* `wait-for-it.sh` は https://github.com/vishnubob/wait-for-it からいただきました。最初に `chmod +x app/conf/wait-for-it.sh` で実行権限を付加しておいてください。

# Docker の設定

DB は MySQL で設定します。
文字化け対策をしつこくしていますが、ここまで必要ない気がします。
app サーバ起動時に MySQL が起動している必要があるため、`wait-for-it.sh` を使用して db サーバの起動まで待機をするようにしています。
これである程度の事故は防げますが、それでもたまに app サーバが起動でコケる場合があります。
上手く動作しない場合などは、`docker logs app` または `docker logs db` コマンドでそれぞれのサーバのログを確認するようにしてください。

## docker-compose.yml
```docker-compose.yml
version: "3.3"

services:
  db:
    container_name: "db"
    build: ./docker/mysql
    restart: always
    tty: true
    environment:
      MYSQL_DATABASE: sample_db
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_ROOT_PASSWORD: password
      TZ: 'Asia/Tokyo'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - "3306:3306"
    volumes:
      - ./docker/mysql/conf.d/my.cnf:/etc/mysql/conf.d/my.cnf
    networks:
      - backend

  app:
    container_name: "app"
    build: ./docker/app
    ports:
      - "8000:8000"
    depends_on:
      - "db"
    command: ["./conf/wait-for-it.sh", "db:3306", "--", "uvicorn", "main:app", "--reload", "--port=8000", "--host=0.0.0.0"]
    volumes:
      - ./app:/usr/src/server
    networks:
      - frontend
      - backend

networks:
  frontend:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 192.168.10.0/24
  backend:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 192.168.20.0/24
```

## docker/mysql/Dockerfile
```:docker/mysql/Dockerfile
FROM mysql:5.7
```

## docker/app/Dockerfile
```:docker/app/Dockerfile
FROM python:3.8-alpine

WORKDIR /usr/src/server

ADD requirements.txt .

RUN apk add --no-cache build-base bash mariadb-client mariadb-connector-c-dev
RUN pip install --no-cache-dir --trusted-host pypi.python.org -r requirements.txt
```

## docker/app/requirements.txt
```docker/app/requirements.txt
mysqlclient
sqlalchemy
uvicorn
fastapi
```

## docker/mysql/conf.d/my.cnf
```docker/mysql/conf.d/my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_bin
default-storage-engine=INNODB
explicit-defaults-for-timestamp=1
general-log=1
general-log-file=/var/log/mysql/mysqld.log

[mysqldump]
default-character-set=utf8mb4

[mysql]
default-character-set=utf8mb4

[client]
default-character-set=utf8mb4
```

# FastAPI アプリ実装

## 設定ファイル
### app/conf/database.py
DB の設定となります。
FastAPI から MySQL への接続設定とセッションの設定をします。
セッションの `autocommit` と `autoflush` を `False` にしているため、`commit` や `refresh` を明示的に行わないと更新されません。


```app/conf/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

# DB 接続情報
user_name = "user"
password = "password"
host = "db"
database_name = "sample_db"

# DB 接続設定
DATABASE_URL = 'mysql://%s:%s@%s/%s?charset=utf8mb4' % (
    user_name,
    password,
    host,
    database_name,
)

ENGINE = create_engine(
    DATABASE_URL,
    encoding="utf8",
    echo=True
)

# セッション設定
SessionLocal = sessionmaker(
    autocommit=False,
    autoflush=False,
    bind=ENGINE
)

Base = declarative_base()
```

## モデル設定
ここで設定した内容をもとに DB へマイグレーションを行います。

### app/models/authors.py
著者（authors）テーブル
`name` を持ち、books とリレーションを張っています。
実際のリレーションは books の `author_id` で設定されます。

```app/models/authors.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from conf.database import Base

class Author(Base):
    __tablename__ = "authors"

    id = Column(Integer, primary_key=True, index=True)
    name = Column(String(20), unique=True, index=True)

    books = relationship("Book", back_populates="author")
```

### app/models/books.py
書籍（books）テーブル
`title`, `description`, `author_id` を持ち、authors とリレーションを張っています。

```app/models/books.py
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from conf.database import Base

class Book(Base):
    __tablename__ = "books"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(20), index=True)
    description = Column(String(200), index=True)
    author_id = Column(Integer, ForeignKey("authors.id"))

    author = relationship("Author", back_populates="books")
```

## スキーマ設定
入出力の定義。

### app/schemas/authors.py

```app/schemas/authors.py
from typing import List, Optional

from pydantic import BaseModel
from schemas.books import Book

class AuthorBase(BaseModel):
    name: str

class AuthorCreate(AuthorBase):
    pass

class Author(AuthorBase):
    id: int
    books: List[Book] = []

    class Config:
        orm_mode = True
```

### app/schemas/books.py

```app/schemas/books.py
from typing import List, Optional

from pydantic import BaseModel

class BookBase(BaseModel):
    title: str
    description: Optional[str] = None

class BookCreate(BookBase):
    pass

class Book(BookBase):
    id: int
    author_id: int

    class Config:
        orm_mode = True
```

# CRUD 定義
実際の DB 制御ロジックはここに記述します。
`.add` で `INSERT`、`.delete` で `DELETE` が発行されます。
`UPDATE` はメソッドがなく、各カラムに代入後に `.commit` することで更新されます。
`.commit` 後に `.refresh` を行わないとセッションが更新されず、DB 側が更新されてもブラウザなどでは更新前のデータが表示されることになります。

### app/cruds/authors.py

```app/cruds/authors.py
from sqlalchemy.orm import Session
from schemas.authors import AuthorCreate
from models.authors import Author

def get_author(db: Session, author_id: int):
    return db.query(Author).filter(Author.id == author_id).first()

def get_author_by_name(db: Session, name: str):
    return db.query(Author).filter(Author.name == name).first()

def get_authors(db: Session, skip: int = 0, limit: int = 100):
    return db.query(Author).offset(skip).limit(limit).all()

def create_author(db: Session, author: AuthorCreate):
    db_author = Author(**author.dict())
    db.add(db_author)
    db.commit()
    db.refresh(db_author)
    return db_author

def update_author(db: Session, author_id: int, name: str):
    db_author = db.query(Author).filter(Author.id == author_id).first()
    db_author.name = name
    db.commit()
    db.refresh(db_author)
    return db_author

def delete_author(db: Session, author_id: int):
    db_author = db.query(Author).filter(Author.id == author_id).first()
    db.delete(db_author)
    db.commit()
```

### app/cruds/books.py

```app/cruds/books.py
from sqlalchemy.orm import Session

from schemas.books import BookCreate
from models.books import Book

def get_books(db: Session, skip: int = 0, limit: int = 100):
    return db.query(Book).offset(skip).limit(limit).all()

def create_author_book(db: Session, book: BookCreate, author_id: int):
    db_book = Book(**book.dict(), author_id=author_id)
    db.add(db_book)
    db.commit()
    db.refresh(db_book)
    return db_book
```

## main
### main.py
アプリのルーティング設定となります。
実際は `./routing/` 配下などで分割して管理したいところですが、今回は `main.py` で対応しています。
`async` を付けていますが、非同期の動作確認はしていません。

```main.py
from typing import List

from fastapi import Depends, FastAPI, HTTPException, Response, status
from sqlalchemy.orm import Session

from cruds import authors as cruds_authors
from cruds import books as cruds_books
from models import authors as models_authors
from models import books as models_books
from schemas import authors as schemas_authors
from schemas import books as schemas_books
from conf.database import SessionLocal, ENGINE

# ここでモデルファイルを元に DB のマイグレーションを行う
models_books.Base.metadata.create_all(bind=ENGINE)
models_authors.Base.metadata.create_all(bind=ENGINE)

app = FastAPI()

# Dependency
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# 著者登録
@app.post("/authors/", response_model=schemas_authors.Author)
async def create_author(author: schemas_authors.AuthorCreate, db: Session = Depends(get_db)):
    db_author = cruds_authors.get_author_by_name(db, name=author.name)
    if db_author:
        raise HTTPException(status_code=400, detail="Name already registered")
    return cruds_authors.create_author(db=db, author=author)

# 著者更新
@app.put("/authors/{author_id}", response_model=schemas_authors.Author)
async def update_author(author_id: int, author: schemas_authors.AuthorUpdate, db: Session = Depends(get_db)):
    db_author = cruds_authors.update_author(db, author_id=author_id, name=author.name)
    if db_author is None:
        raise HTTPException(status_code=404, detail="Author not found")
    return db_author

# 著者一覧取得
@app.get("/authors/", response_model=List[schemas_authors.Author])
async def read_authors(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    authors = cruds_authors.get_authors(db, skip=skip, limit=limit)
    return authors

# 著者単体取得
@app.get("/authors/{author_id}", response_model=schemas_authors.Author)
async def read_author(author_id: int, db: Session = Depends(get_db)):
    db_author = cruds_authors.get_author(db, author_id=author_id)
    if db_author is None:
        raise HTTPException(status_code=404, detail="Author not found")
    return db_author

# 著者削除
@app.delete("/authors/{author_id}", status_code=200)
async def delete_author(author_id: int, db: Session = Depends(get_db)):
    db_author = cruds_authors.delete_author(db, author_id=author_id)
    return

# 書籍登録
@app.post("/authors/{author_id}/books/", response_model=schemas_books.Book)
async def create_book_for_author(
    author_id: int, book: schemas_books.BookCreate, db: Session = Depends(get_db)
):
    return cruds_books.create_author_book(db=db, book=book, author_id=author_id)

# 書籍取得
@app.get("/books/", response_model=List[schemas_books.Book])
async def read_books(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    books = cruds_books.get_books(db, skip=skip, limit=limit)
    return books
```

## 実行・動作確認

docker-compose で起動し、Windows10 のブラウザで http://localhost:8000/docs にアクセスすることで FastAPI の SwaggerUI が開きます。

```console
docker-compose up -d --build
```

WSL2 に MySQL Client がインストールされていれば、コマンドから DB コンテナに入り MySQL の操作が可能となります。

```console
mysql -u user -h 127.0.0.1 -D sample_db -p
```


# 参考資料

以下の記事、情報を参考にさせていただきました。
ありがとうございます。

- [FastAPI](https://fastapi.tiangolo.com/)
- [Docker Compose入門](https://knowledge.sakura.ad.jp/21387/)
- [Compose における起動順の制御](https://docs.docker.jp/compose/startup-order.html)
- [FastAPIをMySQLと接続してDockerで管理してみる](https://qiita.com/satto_sann/items/4fbc1a4e2b33fa2237d2)