Open3

full-stack-fastapi-templateの構成調査

がっちりしたDevがっちりしたDev

最終的には1つの記事にまとめる予定

full-stack-fastapi-template の0.7.1時点での利用方法やFastAPI(バックエンド)周りで利用されている技術と構成全体についてを解明しながら、学習する目的のスクラップになります。
学習対象としては、以下を中心に実施予定です。

  • full-stack-fastapi-template の構成解析
  • Alembic
  • traefik

我流からの脱却を目標にしています。

がっちりしたDevがっちりしたDev

Alembicでモデルを追加したときのトラブル

Docker Compose 初回起動時にリビジョン追加によるエラーが発生する問題と解決策

問題の概要

Docker Compose で構築されたコンテナを初回起動する際に、新しく追加された Alembic のリビジョンがマウントされていないため、エラーが発生する事象があります。
Dockerコンテナに関連するマウントの問題です。

問題の発生時系列

  1. docker compose up でコンテナ環境を構築。
  2. 構築後に models.py を変更。
  3. Alembic を使ってリビジョンファイルを作成:
    alembic revision --autogenerate -m "create tables"
    
  4. リビジョンファイルを作成した状態で、Docker コンテナをdocker compose stopで停止した後、再起動を行う。
  5. docker compose up を実行しようとすると、Docker コンテナ内にリビジョンファイルが存在しないために、Alembic の実行時にエラーが発生します。

問題の原因

  • Alembic のリビジョン状態が DB テーブル上に保存されているため、Docker コンテナ内にリビジョンファイルが存在しないと、実行不能になります。
  • Docker Compose の構成上、backend ディレクトリがマウントされていない為、ローカルで作成したリビジョンファイルがコンテナ内に送られていない。

解決策

  1. リビジョンファイル作成:
    ローカル環境で新しく Alembic リビジョンファイルを作成する。

  2. Docker コンテナ内のリビジョン状態をリセット:

    docker compose down --volumes
    

    これにより DB テーブル上の Alembic リビジョン情報をリセット。

  3. Docker コンテナを再構築:
    ローカルの最新リビジョンを Docker コンテナに反映するために、キャッシュをクリアして構築:

    docker compose build --no-cache
    docker compose up
    
  4. Docker Compose Watch の利用:
    Docker Compose Watch を搭載して、ローカルの変更が Docker コンテナに反映されるように設定する。

確認作業

少し雑ですが、概要のみ記載します。

  1. backend/app/alembic/versions 内容確認:
    リビジョンファイルのリストを確認:

    - 1a31ce608336_add_cascade_delete_relationships.py
    - 9c0a54914c78_add_max_length_for_string_varchar_.py
    - d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py
    - e2412789c190_initialize_models.py
    
  2. prestart ログの確認:

    docker compose logs prestart
    

    ログの内容から Alembic の実行状況を確認。

  3. DB 内容の確認:
    Adminer や psql を使用し、alembic_version テーブルの状態を確認。

alembic_versionが存在するリビジョンファイルのリストと一致している場合は、正常に起動するはずです。

がっちりしたDevがっちりしたDev

リビジョンファイルの追加手順

全体を通した注意点ですが、docker compose watchでコンテナを起動した場合は、ローカルでのファイル変更をコンテナ上に反映することが出来ません。(バインドマウントはしていないです。)
コンテナの仕組みについては、イマイチわからない...という方は、ローカルで開発した作業内容を反映するためには、docker compose watchでコンテナを起動した状態でソースコードの変更をする。 と覚えておいてください。
とりあえず、ローカル環境とコンテナ環境がずれないように注意してください。ということです。

実際には、ファイル変更後にdocker compose watch を実行しても差分を判断してコンテナに反映してくれるようです。ただ、ローカル環境下で、alembic upgrade headをした場合、コンテナには最新のリビジョンファイルがないのに、DB上のrevision_versionは最新(コンテナに存在しないリビジョン)状態になります。そのため、docker compose up に失敗します。慣れていないとトラブルシューティングに躓くと思います。

1. プロジェクト構成変更内容

まずは、docker compose watch でコンテナを立ち上げてください。ローカルで開発している内容をコンテナに反映する必要があります。

以下の変更を行い、Alembic を利用してマイグレーションを作成し適用します。

2. モデルの追加:Order テーブル

Order クラスを追加し、既存の Item クラスを外部キーで参照するリレーションシップを構築します。

2.1. 変更後のモデルコード(models.py

import uuid
from sqlmodel import Field, Relationship, SQLModel

class Item(ItemBase, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
    title: str = Field(max_length=255)
    owner_id: uuid.UUID = Field(
        foreign_key="user.id", nullable=False, ondelete="CASCADE"
    )
    owner: User | None = Relationship(back_populates="items")
    orders: list["Order"] = Relationship(back_populates="item")

# ここを追加
class Order(SQLModel, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
    item_id: uuid.UUID = Field(foreign_key="item.id", nullable=False)
    quantity: int = Field(default=1, ge=1)
    item: Item | None = Relationship(back_populates="orders")

3. Alembic のマイグレーション手順

3.1. マイグレーションファイルの作成

まずはコンテナを立ち上げる必要があります。
docker compose watchで立ち上げます。バックエンドの更新内容をコンテナに反映する必要があります。

新しいモデルを基にマイグレーションファイルを生成します。

alembic revision --autogenerate -m "Add Order table with relationship to Item"

3.2. マイグレーションファイルの確認

生成されたマイグレーションファイルを確認し、リレーションシップや外部キー制約が正しく定義されていることを確認します。

以下のようなファイルが作成されます。Revision IDなどは自動で割り当てられます。

"""Add Order table With Item Relation

Revision ID: bbadec34fb2b
Revises: 1a31ce608336
Create Date: 2024-12-29 22:28:00.190985

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes


# revision identifiers, used by Alembic.
revision = 'bbadec34fb2b'
down_revision = '1a31ce608336'
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('order',
    sa.Column('id', sa.UUID(), nullable=False),
    sa.Column('item_id', sa.UUID(), nullable=False),
    sa.Column('quantity', sa.Integer(), nullable=False),
    sa.ForeignKeyConstraint(['item_id'], ['item.id'], ),
    sa.PrimaryKeyConstraint('id')
    )
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('order')
    # ### end Alembic commands ###

3.3. マイグレーションの適用

データベースに変更を適用します。

alembic upgrade head

4. API の実装

新しい Order テーブルを操作するためのエンドポイントを実装します。

4.1. エンドポイントコード(<workdir>/backend/app/api/routes/orders.py

CRUD構成になるようにAPIを作成しています。
また、本来はCurrentUserを利用することで、ユーザのチェックなどが出来ますがサンプルコードですので割愛します。

import uuid
from typing import Any

from fastapi import APIRouter, HTTPException
from sqlmodel import select

from app.api.deps import CurrentUser, SessionDep
from app.models import Item, Message, Order

router = APIRouter()

@router.get("/", response_model=list[Order])
def read_orders(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
    statement = select(Order).offset(skip).limit(limit)
    orders = session.exec(statement).all()
    return orders

@router.get("/{id}", response_model=Order)
def read_order(session: SessionDep, id: uuid.UUID) -> Any:
    order = session.get(Order, id)
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    return order

@router.post("/", response_model=Order)
def create_order(*, session: SessionDep, item_id: uuid.UUID, quantity: int) -> Any:
    item = session.get(Item, item_id)
    if not item:
        raise HTTPException(status_code=404, detail="Item not found")

    order = Order(item_id=item_id, quantity=quantity)
    session.add(order)
    session.commit()
    session.refresh(order)
    return order

@router.put("/{id}", response_model=Order)
def update_order(*, session: SessionDep, id: uuid.UUID, quantity: int) -> Any:
    order = session.get(Order, id)
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    order.quantity = quantity
    session.add(order)
    session.commit()
    session.refresh(order)
    return order

@router.delete("/{id}")
def delete_order(session: SessionDep, id: uuid.UUID) -> Message:
    order = session.get(Order, id)
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    session.delete(order)
    session.commit()
    return Message(message="Order deleted successfully")

4.2. ルーターの追加(main.py

from app.api.routes import items, login, orders, users, utils

api_router.include_router(orders.router, prefix="/orders", tags=["orders"])

5. 動作確認

  1. 内容の更新
    dockerを立ち上げる際にdocker compose watchを利用していると更新内容が反映されています。
    docker compose up で立ち上げたので一度落として docker compose watch などを実行すると、コンテナの立ち上げに失敗するはずです。コンテナのボリュームマウントの問題です。別のスクラップでまとめています。

  2. SwaggerUI を使用して以下の手順をテストします。

    • /items/ エンドポイントで Item を作成します。
    • /orders/ エンドポイントで Order を作成します。
      • 作成時に Item の ID が必要です。
    • /orders/{order_id} エンドポイントで Order を取得します。

6. 学びのメモ

6.1. リレーションシップの双方向定義

ItemOrder の間にリレーションシップを定義し、back_populates を使用して双方向のリンクを構築しました。型ヒントには from __future__ import annotations を利用することで、未定義のクラスを参照可能にしました。