FastAPI + SQLModel を使った簡単API開発をやってみた

## はじめに

この記事はNTTテクノクロス Advent Calendar 2023のシリーズ2の14日目です。

こんにちは、NTTテクノクロスの原です。社内向けのブログを運営しておりましたが、最近業務都合でなかなか記事をかけておらず久しぶりにブログを書いています。
私は普段、社内で他組織の開発チームの技術支援を担当しています。また、社内へ研修もチームで開催しています。

来年度から、Docker、Git、自動テスト、自動ビルド、CI/CD、アジャイル開発やモブプロなどベーススキルとして必要になる技術や開発手法を学ぶための研修を社内向けに開催する予定でした。
その中でまずは研修で手を動かしながら開発してもらうために、Pythonで簡単なAPIを作ってもらうような内容を考え、準備していく中で昨今話題のFastAPIを中心としたライブラリを利用したのでその紹介をできればと思います。

:::note
Pythonを選んだ理由としては、近年のAI・機械学習ブームによってPython利用者が増えてきたことが主な理由です。また、なるべく選定した言語に詳しい必要性を低くしたかったのでPythonを研修では選定しています。
:::

## 初期構成

はじめに以下の構成で開発環境を整えました。
Python関連のライブラリの管理、パッケージングにはryeを用いました。

- [rye](https://rye-up.com/)
    -  ライブラリの管理からPythonの仮想環境、ビルドなどのエコシステムを一括してコントロールできるようにしたものです。
- [FastAPI](https://fastapi.tiangolo.com/ja/)
    -  WebサーバでAPIを作るために特化したライブラリです。OpenAPIも一緒に作ってくれます。
- [pydantic](https://docs.pydantic.dev/latest/)
    - Pythonのデータ検証と設定管理のためのライブラリで、型ヒントを用いてデータの自動検証とシリアライズを行う機能を提供します。
- [SQLAlchemy](https://www.sqlalchemy.org/)
    - PythonのためのデータベースORM（オブジェクト関係マッピング）ライブラリです。

:::note warn
ryeは「It's not yet production ready」であり、所々でバグがあったりしましたが、今回は研修向けであるためで採用しました。
しかし、Pythonのパッケージ管理、仮想環境の各種ツールをそろえる必要がなくryeを起点として各ツールを実行できるというのはそれだけでも開発体験の良さに気がつけるので、一度は触れることをおすすめします。
:::

## 初期構成の環境作成

研修ではライブラリの使用方法や言語がメインテーマではないので、実装が簡単なTODOアプリを作成することになりました。

### Pythonの準備

ryeでテンプレートを生成します。

```shell
rye init todo-project
```

テンプレート作成後のディレクトリ構成は以下のような形になります。
```
.
├── .python-version
├── pyproject.toml
├── README.md
└── src
   └── todo_project
      └── __init__.py
```

次にPythonのバージョンを固定します。
これによって、プロジェクト直下の`.python-version`ファイルにバージョンが書き込まれ、プロジェクトで共通的にバージョンを固定することが出来ます。

```shell
rye pin 3.10.11
```

初回の同期を実施します。初回の同期では先ほど固定したPythonのvirtualenvの仮想環境の作成や、`requirements.lock`、`requirements-dev.lock`が作成されます。また、`.venv`ディレクトリは`pyproject.toml`と同じディレクトリに作成されます。

```shell
rye sync
```

### ライブラリのインストール

開発に必要な各ライブラリを追加して、syncすることでローカルに追加されます。
また、追加したライブラリのバージョンなどは`pyproject.toml`に記載され、`requirements.lock`、`requirements-dev.lock`にも記録されます。

```shell
rye add fastapi uvicorn　pydantic sqlalchemy　psycopg2-binary
rye sync
```

### Webサーバの起動

先ほど追加したuvicornを使って、Webサーバを起動することができます。

```shell
rye run uvicorn main:app --host 0.0.0.0 --reload
```

上記で起動すると、8000番ポートで起動するため、以下にアクセスすることでOpenAPIを確認することができます。
[http://localhost:8000/docs](http://localhost:8000/docs)

### 実装

中身のアプリに関しては以下のように実装していきました。

- router.py
    - FastAPIのエンドポイントを指定する。主にControllerの役割。
- models.py
    - モデル(ドメイン)を定義する。
- schemas.py
    - モデルの制約を定義する。
- db.py
    - データベースの関連情報を定義する。

```python:router.py
from typing import List
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy import select, Session
from .models import Todo, TodoRequestForCreateOrUpdate, TodoResponse
from db import get_session

router = APIRouter()


# TODOのリストを取得します。
@router.get("/todos/", response_model=List[TodoResponse])
def read_list_todos(session: Session = Depends(get_session)):
    return session.exec(select(Todo)).all()


# TODOを取得します。
@router.get("/todos/{todo_id}", response_model=TodoResponse)
def read_todos(todo_id: int, session: Session = Depends(get_session)):
    return session.exec(select(Todo).where(Todo.id == todo_id)).one()


# TODOを新規登録します。
@router.post("/todos/", response_model=TodoResponse)
def create_todo(request: TodoRequestForCreateOrUpdate, session: Session = Depends(get_session)):
    todo = Todo.from_orm(request)
    session.add(todo)
    session.commit()
    session.refresh(todo)
    return todo


# TODOを更新します。
@router.put("/todos/{todo_id}", response_model=TodoResponse)
def update_todo(todo_id: int, request: TodoRequestForCreateOrUpdate, session: Session = Depends(get_session)):
    todo = session.get(Todo, todo_id)
    if not todo:
        raise HTTPException(status_code=404, detail="TODO not found")
    for key, value in request.dict(exclude_unset=True).items():
        setattr(todo, key, value)
    session.add(todo)
    session.commit()
    session.refresh(todo)
    return todo


# TODOを削除します。
@router.delete("/todos/{todo_id}")
def delete_todo(todo_id: int, session: Session = Depends(get_session)):
    db_todo = session.get(Todo, todo_id)
    if not db_todo:
        raise HTTPException(status_code=404, detail="TODO not found")
    session.delete(db_todo)
    session.commit()
    return {"ok": True}

```

```python:models.py
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class Todo(Base):
    __tablename = "todos"
    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    is_done: Column(Bool)
```

```python:schemas.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel


class TodoBase(BaseModel):
    id: Optional[int]
    title: str
    is_done: bool
    created_at = datetime


class TodoCreate(TodoBase):
    pass

class TodoUpdate(TodoBase):
    title = Optional[str]
    is_done = Optional[bool]

```

```python:db.py
import os
from sqlmodel import create_engine, Session

DATABASE = os.environ.get('DATABASE', 'postgresql')
USER = os.environ.get('DB_USER', 'user')
PASSWORD = os.environ.get('DB_PASSWORD', 'secret')
HOST = os.environ.get('DB_HOST', 'localhost')
PORT = os.environ.get('DB_PORT', '5432')
DB_NAME = os.environ.get('DB_NAME', 'unknown')

DATABASE_URL = f'{DATABASE}://{USER}:{PASSWORD}@{HOST}:{PORT}/{DB_NAME}'
ECHO_LOG = False

engine = create_engine(DATABASE_URL, echo=ECHO_LOG)


def get_session():
    with Session(engine) as session:
        yield session
```

## SQLModelを使った実装

実装を進めていく中で、FastAPIの作者が作成したSQLModelを発見しました。

https://sqlmodel.tiangolo.com/

これはpydanticやSQLAlchemyで重複するコードをシンプルに記述するために開発されたライブラリで、このライブラリを使って、実装を変えてみると以下の通りとなりました。

:::note
この記事を書いた当初はSQLModelのバージョンが低かったのですが、昨年度からリリースが1年近くストップしていたのですが、11月から無事リリースが続いています！今後の継続的なリリースにも期待したいですね。
:::

- router.py
    - sqlmodelのselect, Sessionの置き換えで大きくは変わっていません。
- models.py
    - モデルの定義と制約を記述しています。

```python: router.py
from sqlmodel import select, Session
```

```python:models.py
from typing import Optional
from sqlmodel import SQLModel, Field
from pydantic import BaseModel


class Todo(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    is_done: bool


class TodoRequestForCreateOrUpdate(BaseModel):
    id: Optional[int]
    title: str
    is_done: bool


class TodoResponse(BaseModel):
    id: int
    title: str
    is_done: bool
```

このようにモデルの定義とリクエストとレスポンスデータの検証できるようにすることで、シンプルにコードを記述することができるようになりました。

## まとめ

スタートからしばらく経って、月中頃となりました。
たいへんではありましたが最後までご覧頂きありがとうございました!
あと、今回はSQLModelがそこまで使われておらず日本語記事も多くなかったので執筆しました。
早すぎるライブラリかもしれませんが、今後バージョンアップしたときに役立つかと思ったので、ぜひpydantic、SQLAlchemyを利用していた方はSQLmodelも触ってみて頂けるとよいかと思います。
どんどん明日以降も面白い記事がもりだくさん！ぜひNTTテクノクロスアドベントカレンダーをご覧ください！