FastAPIとSQLModelでつくる簡単なToDoアプリ

プログラミング

はじめに

この記事では、python をつかって簡単なToDoアプリを作成します。

内容は、FastAPI を使用したAPIの作成にのみ焦点を当てますので、フロントエンドに関してはノータッチでいきます。また、今回データベースを扱うORM(Object Relational Mapping)は SQLModel を使用していきます。さらにデータベースのスキーマ管理は Alembic を用います。

以下、使用している Python は Ver 3.10 で、必要なライブラリは次の通りです。

fastapi==0.95.1
sqlmodel==0.0.8
alembic==1.10.4

この記事に記載しているプログラム一式は次の GitHub で利用することができます。

GitHub - Joichiro433/Blog-fastapi-todo
Contribute to Joichiro433/Blog-fastapi-todo development by creating an account on GitHub.

ToDoアプリのREST API一覧とフォルダ構成

今回作成するToDoアプリのREST APIは以下のとおりとします。

HTTPメソッドパス説明リクエストレスポンス
GET/api/tasksタスクを全て取得するタスクの一覧
GET/api/tasks/{id}特定idのタスクを取得するタスク
POST/api/tasksタスクを新しく登録するタスクの内容登録したタスク
PATCH/api/tasks/{id}タスクを更新するタスクの状態更新したタスク
DELETE/api/tasks/{id}タスクを削除する

なるべくシンプルにするため、ユーザー認証等は考えないことにします。

上記のAPIを FastAPI と SQLModel で実装するにあたって、ソースコードのフォルダ構成は以下のようにしました。今回のようなシンプルなAPIではこのような細分化した構造をとる意義は薄いかもしれませんが、今後アプリケーションが複雑になっても耐えられる構造の練習として細かくフォルダを分割しています。

.
├── alembic.ini
├── cruds/
│   └── task.py
├── models/
│   └── task.py
├── routers/
│   └── task.py
├── migrations/
│   ├── env.py
│   └── script.py.mako
├── db.py
├── main.py
└── settings.py

フォルダそれぞれの役割としては、

  • models/ : データベースのスキーマを定義する
  • cruds/ : データベースとのやりとりをおこなう関数を定義する
  • routers/ : RestAPI のパスオペレーション関数を定義する

また、詳しくは後述しますが、 alembic.inimigrations/ は Alembic によって自動生成します。

DBの接続とスキーマを定義する

今回はユーザー認証等は考えないので、ToDoアプリのテーブル構造は以下の task テーブルのみとします。

このテーブルのスキーマを models/task.py に実装しましょう。

from typing import Optional

from sqlmodel import SQLModel, Field


class TaskBase(SQLModel):
    title: str = Field(description='task name', nullable=False)
    done: bool = Field(default=False, description='done flag', nullable=False)


class Task(TaskBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)


class TaskCreate(TaskBase):
    pass


class TaskRead(TaskBase):
    id: int


class TaskUpdate(SQLModel):
    title: str
    done: bool

task テーブルのスキーマは、class Task(TaskBase, table=True) がそれに当たります。ここで、3つのクラス: TaskCreate, TaskRead, TaskUpdate がありますが、これらはAPIの リクエスト/レスポンス の構造として定義しています。routers/task.py にて使用します。


次に、DBとの接続部分を実装していきましょう。

まずは、DB接続情報を settings.py に記載しておきます。今回は簡単のため SQLite を使用しました。

SQLITE_NAME = 'db.sqlite3'
DB_URI = f'sqlite:///{SQLITE_NAME}'

また、DBとのセッションを確立する関数を db.py に定義します。

from collections.abc import Generator

from sqlmodel import create_engine, Session

import settings


engine = create_engine(settings.DB_URI, echo=True)

def get_session() -> Generator[Session, None, None]:
    with Session(engine) as session:
        yield session

DBを操作する関数の実装

DBの操作(CRUDs)をおこなう関数を cruds/task.py に実装します。DB操作の部分を routers/ のコードから切り離して実装することで、 routers/ のコードをよりシンプルにする狙いがあります。

冒頭で述べた、実装する RestAPI の機能を満足するように関数を定義しました。以下の表はその実装したい RestAPI の再掲です。

HTTPメソッドパス説明リクエストレスポンス
GET/api/tasksタスクを全て取得するタスクの一覧
GET/api/tasks/{id}特定idのタスクを取得するタスク
POST/api/tasksタスクを新しく登録するタスクの内容登録したタスク
PATCH/api/tasks/{id}タスクを更新するタスクの状態更新したタスク
DELETE/api/tasks/{id}タスクを削除する
from sqlmodel import select, Session

from models.task import Task, TaskCreate, TaskUpdate


def read_task(session: Session, task_id: int) -> Task | None:
    task: Task | None = session.get(Task, task_id)
    return task


def read_tasks(session: Session, done: bool | None = None) -> list[Task]:
    query = select(Task)
    if done is not None:
        query = query.where(Task.done == done)
    tasks: list[Task] = session.exec(query).all()
    return tasks


def create_task(session: Session, task: TaskCreate) -> Task:
    db_task: Task = Task.from_orm(task)
    session.add(db_task)
    session.commit()
    session.refresh(db_task)
    return db_task


def update_task(session: Session, db_task: Task, task: TaskUpdate) -> Task:
    task_data = task.dict(exclude_unset=True)
    for key, value in task_data.items():
        setattr(db_task, key, value)
    session.add(db_task)
    session.commit()
    session.refresh(db_task)
    return db_task


def delete_task(session: Session, db_task: Task) -> None:
    session.delete(db_task)
    session.commit()
    return

上記の関数の全てに 引数 session が用意されています。ここには、db.py で定義した 関数 get_session() の値を渡します。

ルーターの実装

routers/task.py にRestAPI のパスオペレーション関数を実装します。

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session

import cruds.task as task_crud
from models.task import Task, TaskRead, TaskCreate, TaskUpdate
from db import get_session


router = APIRouter(
    prefix='/tasks',
    tags=['Task'],
    responses={404: {'message': 'Not found'}})


@router.get('/', response_model=list[TaskRead])
def read_tasks(
        *, 
        session: Session = Depends(get_session), 
        done: bool | None = Query(None)
    ) -> list[TaskRead]:
    """
    Read tasks from the database, filtered by the 'done' flag.

    Parameters
    ----------
    done : bool | None, optional, **[query parameter]**\n
        The value of the 'done' flag to filter tasks by, by default None.

    Returns
    -------
    list[TaskRead]\n
        The list of tasks.
    """
    return task_crud.read_tasks(session=session, done=done)


@router.get('/{task_id}', response_model=TaskRead)
def read_task(
        *, 
        session: Session = Depends(get_session), 
        task_id: int
    ) -> TaskRead:
    """
    Read a task from the database by its ID.

    Parameters
    ----------
    task_id : int, **[path parameter]**\n
        The ID of the task to read.

    Returns
    -------
    TaskRead\n
        The requested task if found, otherwise raises a 404 HTTPException.
    """

    task: Task | None = task_crud.read_task(session=session, task_id=task_id)
    if task is None:
        raise HTTPException(status_code=404, detail='Task not found')
    return task


@router.post('/', response_model=TaskCreate)
def create_task(
        *, 
        session: Session = Depends(get_session), 
        task: TaskCreate
    ) -> TaskCreate:
    """
    Create a new task in the database.

    Parameters
    ----------
    task : TaskCreate, **[body parameter]**\n
        The task creation object.

    Returns
    -------
    TaskCreate\n
        The created task.
    """
    created_task = task_crud.create_task(session=session, task=task)
    return created_task


@router.patch('/{task_id}', response_model=TaskRead)
def update_task(
        *, 
        session: Session = Depends(get_session), 
        task_id: int, 
        task: TaskUpdate
    ) -> TaskRead:
    """
    Update a task in the database.

    Parameters
    ----------
    task_id : int, **[path parameter]**\n
        The ID of the task to update.
    task : TaskUpdate, **[body parameter]**\n
        The update object containing the new task data.

    Returns
    -------
    TaskRead\n
        The updated task.
    """
    org_task: Task | None = task_crud.read_task(session=session, task_id=task_id)
    if org_task is None:
        raise HTTPException(status_code=404, detail='Task not found')
    return task_crud.update_task(session=session, db_task=org_task, task=task)


@router.delete('/{task_id}')
def delete_task(
        *, 
        session: Session = Depends(get_session), 
        task_id: int
    ) -> dict[str, bool]:
    """
    Delete a task from the database.

    Parameters
    ----------
    task_id : int, **[path parameter]**\n
        The ID of the task to delete.

    Returns
    -------
    dict[str, bool]\n
        A dictionary with a single key 'ok' and a value of True, indicating successful deletion.
    """
    task_to_delete: Task | None = task_crud.read_task(session=session, task_id=task_id)
    if task_to_delete is None:
        raise HTTPException(status_code=404, detail='Task not found')
    task_crud.delete_task(session=session, db_task=task_to_delete)
    return {'ok': True}

そして、main.py でアプリケーションを作成します。

from fastapi import FastAPI

from routers import task


app = FastAPI()
app.include_router(task.router)

DBを作成する

今回、データベースの作成は Alembic を用います。ターミナルで以下のコマンドを実行すると、データベース管理をおこなうファイル群が自動生成されます。

$ alembic init migrations

上記のコマンドで生成されるものは、以下になります。

.
├── alembic.ini
└── migrations/
    ├── README
    ├── env.py
    ├── script.py.mako
    └── versions/

次に、このマイグレーションファイルでSQLModelで定義したテーブルを作成してもらうために、env.pyscript.py.mako を次のように微修正します。

from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlmodel import SQLModel

from settings import DB_URI
from models.task import Task

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode.

    This configures the context with just a URL
    and not an Engine, though an Engine is acceptable
    here as well.  By skipping the Engine creation
    we don't even need a DBAPI to be available.

    Calls to context.execute() here emit the given string to the
    script output.

    """
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -> None:
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """
    conf = config.get_section(config.config_ini_section, {})
    conf['sqlalchemy.url'] = DB_URI
    connectable = engine_from_config(
        conf,
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata
        )

        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade() -> None:
    ${upgrades if upgrades else "pass"}


def downgrade() -> None:
    ${downgrades if downgrades else "pass"}

これで、マイグレーションファイルの準備は完了です。

以下のコマンドを実行することで、models/task.py で定義したスキーマのテーブルが作成されます。今回の場合は db.sqlite3 ファイルが作成されます。

$ alembic revision --autogenerate -m "init"
$ alembic upgrade head

ToDoアプリの Rest API を立ち上げる

実装が完了したので、ToDoアプリの Rest API を立ち上げてみましょう。次のコマンドを実行します。

$ uvicorn main:app --reload
## 出力

INFO:     Will watch for changes in these directories: ['xxxxx/']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [70402] using StatReload
INFO:     Started server process [70405]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

これでAPIが立ち上がりました。試しに http://localhost:8000/docs にアクセスしてみましょう。FastAPI によって生成された APIドキュメントにアクセスすることができます。

この画面で実装した機能を実際に試すことができます。 例えば、POSTメソッドを試すとDBにタスクが新規登録されることが確認できます。

以上で ToDoアプリの簡単な Rest API を実装することができました。

まとめ

  • Fast API と SQLModel で ToDoアプリの Rest API を実装した
  • データベースのスキーマ管理は Alembic でおこなった

今回実装したToDoアプリのテストコードは次の記事▼で実装しています。

タイトルとURLをコピーしました