PythonでREST APIを作ろう

このハンズオンでは、FastAPIとSQLModelを組み合わせ、シンプルなノートアプリのREST APIを実際にハンズオン形式で手を動かしながら体験します。

  • SQLModelのテーブルモデルを使ったデータベース定義
  • FastAPIとSQLModelの連携(Dependsパターン)
  • CRUD操作(作成・取得・更新・削除)のAPIエンドポイント構築
  • HTTPExceptionによるエラーハンドリング
  • 環境変数によるデータベース接続情報の管理

1. 事前準備

このハンズオンでは、以下のツールが必要です。まだ準備できていない場合は、リンク先の手順に沿って準備をお願いします。

2. ハンズオンの概要

このハンズオンで構築するノートAPIの全体像を以下に示します。

flowchart LR
    Client[クライアント<br>curl / ブラウザ] --> FastAPI[FastAPI<br>main.py]
    FastAPI --> Router[APIRouter<br>routers/notes.py]
    Router --> Session[SQLModel<br>Session]
    Session --> MySQL[(MySQL<br>notedb)]
    Router --> Models[モデル定義<br>models.py]
    FastAPI --> DB[DB接続設定<br>database.py]

このハンズオンでは、ノートの作成・取得・更新・削除ができるREST APIを構築します。ノートアプリのバックエンド(サーバ側)として、以下のエンドポイントを実装していきます。

HTTPメソッド パス 操作
POST /notes 新しいノートを作成する
GET /notes ノート一覧を取得する
GET /notes/{note_id} 指定したノートを取得する
PUT /notes/{note_id} 指定したノートを更新する
DELETE /notes/{note_id} 指定したノートを削除する
GET /health ヘルスチェック

また前の講座SQLModel入門で学んだSQLModelを使って、データベースの操作を行います。

3. プロジェクトの準備

3.1 パッケージのインストール

以下のコマンドで、必要なパッケージをまとめてインストールします。すでにインストール済みのパッケージがある場合は、自動的にスキップされるだけなので、そのまま実行して問題ありません。

Windowsの場合:

pip install fastapi uvicorn sqlmodel pymysql python-dotenv

Macの場合:

pip3 install fastapi uvicorn sqlmodel pymysql python-dotenv

ここで、今回インストールした各ライブラリの役割を確認しておきましょう。

ライブラリ 役割
FastAPI PythonでREST APIを構築するためのWebフレームワーク
uvicorn FastAPIアプリケーションを動かすためのASGI対応Webサーバ
SQLModel Pythonのクラスでデータベースのテーブルを扱えるようにするORM
PyMySQL PythonからMySQLに接続するためのドライバ
python-dotenv .envファイルから環境変数を読み込むためのライブラリ
⚠️ 「WARNING: You are using pip version ...」と表示される場合
これはpip自体のバージョンが古いことを知らせる警告です。パッケージのインストール自体は正常に完了しているため、この警告は無視して問題ありません。気になる場合は、表示されたコマンド(python3 -m pip install --upgrade pip)を実行するとpipを最新版に更新できます。

3.2 フォルダとファイルの作成

まず、任意の場所にnote-apiフォルダを作成します。

note-api/  ← このフォルダを作成

作成したフォルダをVisual Studio Codeで開きます。Visual Studio Codeのメニューから「ファイル」→「フォルダーを開く」を選択し、作成したフォルダを開いてください。

このハンズオンでは、以下のファイル構成でプロジェクトを作成します。

note-api/
├── main.py            ← アプリケーションの起動設定
├── models.py          ← SQLModelのモデル定義
├── database.py        ← データベース接続の設定
├── setup_db.py        ← テーブル作成・テストデータ挿入
├── routers/           ← このフォルダを作成
│   └── notes.py       ← ノートAPIのエンドポイント
└── .env               ← 環境変数

前の講座ではすべてのコードをmain.pyにまとめていましたが、このハンズオンではファイルを役割ごとに分割して管理します。特にエンドポイントはrouters/フォルダに分離しています。これはFastAPI公式ドキュメントのBigger Applicationsで推奨されている構成で、実際のプロジェクトでもこのようにファイルを分けることで、コードの見通しがよくなり保守がしやすくなります。

まず、環境変数を管理するための.envファイルを作成します。Visual Studio Codeのエクスプローラーでnote-apiフォルダを右クリックし、「新しいファイル」を選択して.envという名前で作成してください。

note-api/
└── .env  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=notedb
💡 ポイント
DB_PASSWORDには、MySQLのインストール時に設定したrootパスワードを指定してください。本講座のコード例ではyour_passwordと記載していますが、ご自身のパスワードに置き換えてください。
💡 ポイント
.envファイルにはパスワードなどの機密情報を記載します。チーム開発ではGitリポジトリにコミットしないよう、.gitignore.envを追加してください。

3.3 データベースの作成

前の講座と同様に、データベース自体の作成はMySQL CLIから行います。

mysql -u root -p -e "CREATE DATABASE notedb;"

パスワードを入力すると、データベースnotedbが作成されます。データベースが正しく作成されたことを確認します。

mysql -u root -p -e "SHOW DATABASES;"

パスワードを入力すると、データベースの一覧が表示されます。

+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| notedb             |
| performance_schema |
| sys                |
+--------------------+

一覧にnotedbが含まれていれば成功です。

4. モデルの設計

4.1 NoteCreateモデル

まず、ノートの作成・更新時にリクエストボディとして受け取るデータのモデルを定義します。note-apiフォルダ内にmodels.pyを作成してください。

note-api/
├── .env
└── models.py  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

from datetime import datetime
from typing import Optional

from sqlalchemy import Column, Text, TIMESTAMP, text
from sqlmodel import Field, SQLModel


class NoteCreate(SQLModel):
    title: str = Field(max_length=100)
    content: str

コードを解説します。

class NoteCreate(SQLModel):
    title: str = Field(max_length=100)
    content: str

NoteCreateは、ノートの作成時にクライアントから送信されるデータを定義するモデルです。前の講座で学んだように、table=Trueを指定していないため、データベースのテーブルには対応せず、データのバリデーション(入力チェック)のみを行います。

ここでtable=Trueを指定しないのは、クライアントが送るデータとデータベースのテーブル構造が異なるためです。クライアントが送るのはtitlecontentの2つだけですが、データベースのnotesテーブルにはidcreated_atなどのカラムもあります。これらはクライアントが指定するものではなく、データベースが自動的に付与する値です。このように、リクエストの受け取り用とデータベース操作用でモデルを分けることで、APIの設計がシンプルになります。

4.2 NoteUpdateモデル

次に、ノートの更新時にクライアントから送信されるデータのモデルを定義します。models.pyに以下のコードを追加してください。

class NoteUpdate(SQLModel):
    title: Optional[str] = None
    content: Optional[str] = None

コードを解説します。

class NoteUpdate(SQLModel):
    title: Optional[str] = None
    content: Optional[str] = None

NoteUpdateは、ノートの更新時に使用するモデルです。NoteCreateとの違いは、すべてのフィールドがOptionalでデフォルト値がNoneになっている点です。

更新APIでは、変更したいフィールドだけを送るケースが一般的です。たとえば、タイトルだけを変更したい場合は{"title": "新しいタイトル"}とだけ送り、contentは送りません。NoteCreateをそのまま使うと全フィールドが必須になるため、タイトルだけ変更したいときにもcontentを送る必要が出てしまいます。NoteUpdateではOptionalにすることで、送られなかったフィールドはNoneになり、「変更しない」ことを表現できます。

4.3 Noteテーブルモデル

次に、データベースのnotesテーブルに対応するモデルを定義します。models.pyに以下のコードを追加します。

class Note(SQLModel, table=True):
    __tablename__ = "notes"
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str = Field(max_length=100)
    content: str = Field(sa_column=Column(Text, nullable=False))
    created_at: Optional[datetime] = Field(
        default=None,
        sa_column=Column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP")),
    )
    updated_at: Optional[datetime] = Field(
        default=None,
        sa_column=Column(
            TIMESTAMP,
            server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
        ),
    )

追加したコードを解説します。

class Note(SQLModel, table=True):
    __tablename__ = "notes"

Noteモデルはtable=Trueを指定しているため、データベースのnotesテーブルに対応します。各フィールドの意味は以下のとおりです。

フィールド 説明
id 主キー。新規作成時はNoneで、データベースが自動付与する
title ノートのタイトル。最大100文字
content ノートの本文。Text型で長い文章に対応
created_at 作成日時。MySQLが自動的に現在時刻を設定する
updated_at 更新日時。レコードが更新されるたびにMySQLが自動的に更新する
📝 sa_columnとは
sa_columnは、SQLAlchemyのColumnオブジェクトを直接指定するオプションです。SQLModelの標準的なField()では表現できないデータベース固有の設定(TIMESTAMP型やDEFAULT CURRENT_TIMESTAMPなど)を指定する場合に使用します。server_default=text("CURRENT_TIMESTAMP")は、MySQLのサーバ側でデフォルト値を設定することを意味します。

5. データベース接続の設定

データベースへの接続を管理するdatabase.pyを作成します。

note-api/
├── .env
├── models.py
└── database.py  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

import os

from dotenv import load_dotenv
from sqlmodel import Session, SQLModel, create_engine

load_dotenv()

DB_HOST = os.getenv("DB_HOST", "localhost")
DB_USER = os.getenv("DB_USER", "root")
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
DB_NAME = os.getenv("DB_NAME", "notedb")

DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"

engine = create_engine(DATABASE_URL)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


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

コードを解説します。

DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"

engine = create_engine(DATABASE_URL)

.envファイルから読み込んだ接続情報で接続URLを組み立て、create_engine()でデータベースへの接続を管理するengineを作成しています。

def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

models.pyで定義したテーブルモデルに基づいて、データベースにテーブルを自動作成する関数です。テーブルがすでに存在する場合はスキップされます。

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

データベース操作に必要なSessionを生成する関数です。yieldを使うことで、処理が終わった後にセッションが自動的に閉じられます。

📝 yieldとは
yieldはPythonのジェネレータ構文です。通常のreturnと異なり、yieldは値を返した後もその位置で処理を一時停止し、ブロックの終了時に残りの処理(with文によるSessionのクローズ)が実行されます。FastAPIのDependsと組み合わせることで、リクエストの開始時にセッションを作成し、レスポンス後に自動でクローズする仕組みを実現しています。

6. セットアップスクリプトの作成

テーブルの作成とテストデータの挿入を行うsetup_db.pyを作成します。

note-api/
├── .env
├── models.py
├── database.py
└── setup_db.py  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

from sqlalchemy import text
from sqlmodel import Session, select

from database import create_db_and_tables, engine
from models import Note

# テーブルの作成(すでに存在する場合はスキップ)
with engine.connect() as conn:
    result = conn.execute(text("SHOW TABLES LIKE 'notes'")).fetchone()

if result is None:
    create_db_and_tables()
    print("テーブル notes を作成しました")
else:
    print("テーブル notes はすでに存在するためスキップしました")

# テストデータの挿入(すでにデータが存在する場合はスキップ)
with Session(engine) as session:
    existing = session.exec(select(Note)).all()
    if len(existing) == 0:
        note1 = Note(
            title="最初のノート", content="これはテスト用の最初のノートです。"
        )
        note2 = Note(
            title="2番目のノート",
            content="FastAPIとSQLModelの連携を学んでいます。",
        )
        session.add(note1)
        session.add(note2)
        session.commit()
        print("テストデータを2件挿入しました")
    else:
        print(f"テストデータはすでに{len(existing)}件存在するためスキップしました")

コードを解説します。

with engine.connect() as conn:
    result = conn.execute(text("SHOW TABLES LIKE 'notes'")).fetchone()

if result is None:
    create_db_and_tables()

SHOW TABLES LIKE 'notes'で、データベースにnotesテーブルがすでに存在するかをMySQLに直接問い合わせています。結果がNone(テーブルが見つからない)の場合のみcreate_db_and_tables()を呼び出してテーブルを作成します。

with Session(engine) as session:
    existing = session.exec(select(Note)).all()
    if len(existing) == 0:

テストデータを挿入する前に、すでにデータが存在するかを確認しています。select(Note)で全件取得し、件数が0の場合のみテストデータを挿入することで、重複挿入を防いでいます。

6.1 セットアップの実行

setup_db.pyを実行します。

Windowsの場合:

python setup_db.py

Macの場合:

python3 setup_db.py

以下のような出力が表示されます。

テーブル notes を作成しました
テストデータを2件挿入しました

もう一度実行しても、データが重複挿入されないことを確認できます。

Windowsの場合:

python setup_db.py

Macの場合:

python3 setup_db.py

以下のように、テストデータの挿入がスキップされます。

テーブル notes はすでに存在するためスキップしました
テストデータはすでに2件存在するためスキップしました

MySQLに接続して、データが正しく作成されているか確認します。

mysql -u root -p -e "SELECT * FROM notedb.notes;"

パスワードを入力すると、以下の結果が表示されます。

+----+-----------------------+----------------------------------------------------------+---------------------+---------------------+
| id | title                 | content                                                  | created_at          | updated_at          |
+----+-----------------------+----------------------------------------------------------+---------------------+---------------------+
|  1 | 最初のノート          | これはテスト用の最初のノートです。                        | 2025-01-01 10:00:00 | 2025-01-01 10:00:00 |
|  2 | 2番目のノート         | FastAPIとSQLModelの連携を学んでいます。                   | 2025-01-01 10:00:00 | 2025-01-01 10:00:00 |
+----+-----------------------+----------------------------------------------------------+---------------------+---------------------+
💡 ポイント
created_atupdated_atの値は、実行した日時によって異なります。

7. Dependsパターンによるセッション管理

FastAPIにはDepends(依存性注入)という仕組みがあります。Dependsを使うと、エンドポイントの関数が呼ばれるたびに、必要なオブジェクト(この場合はデータベースセッション)を自動的に生成し、処理後に自動的にクリーンアップすることができます。

基本的な構文は以下のとおりです。

from fastapi import Depends
from sqlmodel import Session
from database import get_session

@app.get("/パス")
def 関数名(session: Session = Depends(get_session)):
    # sessionを使ってデータベース操作

Depends(get_session)を指定すると、FastAPIがリクエストのたびにget_session()を呼び出し、生成されたSessionを関数の引数sessionに自動的に渡します。レスポンスが返された後、セッションは自動的に閉じられます。

📝 Dependsを使うメリット
Dependsを使わない場合、各エンドポイントでセッションの作成とクローズを手動で行う必要があります。Dependsを使うことで、セッション管理のコードを一箇所(get_session())にまとめられるため、各エンドポイントのコードがシンプルになります。

8. エラーハンドリング

ノートAPIの構築に入る前に、HTTPExceptionについて確認しておきます。HTTPExceptionは、FastAPIが提供するエラーレスポンスを返すための仕組みです。基本的な構文は以下のとおりです。

from fastapi import HTTPException

raise HTTPException(status_code=ステータスコード, detail="エラーメッセージ")

status_codeにHTTPステータスコード、detailにエラーメッセージを指定します。raise文により処理がその場で中断され、指定したステータスコードとメッセージがクライアントに返されます。

たとえば、指定されたノートが見つからない場合に404 Not Foundを返すには、以下のように記述します。

raise HTTPException(status_code=404, detail="ノートが見つかりません")

このコードは、この後のエンドポイント実装で使用していきます。

9. ノートAPIの構築

ここから、ノートアプリのCRUD APIを構築していきます。エンドポイントを一つずつ追加しながら、コードの記述、解説、動作確認を繰り返して進めます。

9.1 ベースコードの作成

まずmain.pyを作成します。

note-api/
├── .env
├── models.py
├── database.py
├── setup_db.py
└── main.py  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

from contextlib import asynccontextmanager

from fastapi import FastAPI

from database import create_db_and_tables
from routers.notes import router as notes_router


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(lifespan=lifespan)

app.include_router(notes_router)


@app.get("/health")
def health_check():
    return {"status": "healthy"}


if __name__ == "__main__":
    import uvicorn

    uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

コードを解説します。

from routers.notes import router as notes_router

routers/notes.pyからルータをインポートしています。as notes_routerで別名を付けることで、どのルータかが分かりやすくなります。

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield

lifespanは、FastAPIアプリケーションの起動時に実行される処理を定義する仕組みです。yieldの前に書いた処理(create_db_and_tables())はアプリケーションの起動時に1回だけ実行されます。これにより、サーバ起動時にテーブルが存在しなければ自動的に作成されます。

app = FastAPI(lifespan=lifespan)

app.include_router(notes_router)

FastAPIのインスタンスを作成し、app.include_router()routers/notes.pyに定義したエンドポイントをアプリケーションに登録しています。これにより、routers/notes.pyで定義した全てのエンドポイントがmain.pyのアプリケーションに組み込まれます。

@app.get("/health")
def health_check():
    return {"status": "healthy"}

ヘルスチェック用のエンドポイントです。アプリケーション全体の状態を確認するものなので、main.pyに直接定義しています。

if __name__ == "__main__":
    import uvicorn

    uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

python main.pyで直接実行した場合に、uvicornサーバを起動する処理です。reload=Trueを指定すると、コードを変更するたびにサーバが自動的に再起動されます。

📝 APIRouterとは
FastAPIのAPIRouterは、エンドポイントをグループ化して別ファイルに分離するための仕組みです。main.pyにすべてのエンドポイントを書くとファイルが肥大化するため、機能ごとにルータファイルを分けてapp.include_router()で登録するのがFastAPIの推奨パターンです。詳しくはFastAPI公式ドキュメントのBigger Applications - Multiple Filesに記載があります。

9.2 ルータファイルの作成

次に、ノートAPIのエンドポイントを定義するrouters/notes.pyを作成します。まずroutersフォルダを作成し、その中にnotes.pyを作成してください。

note-api/
├── .env
├── models.py
├── database.py
├── setup_db.py
├── main.py
└── routers/           ← このフォルダを作成
    └── notes.py       ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

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

from database import get_session
from models import Note, NoteCreate

router = APIRouter()


# ---- エンドポイント(この下に追加していきます) ----

コードを解説します。

from fastapi import APIRouter, Depends, HTTPException

APIRouterをインポートしています。APIRouterFastAPIと同じようにエンドポイントを定義できますが、単体ではアプリケーションとして動作しません。main.pyapp.include_router()で登録することで有効になります。

router = APIRouter()

APIRouterのインスタンスを作成しています。このrouterに対してエンドポイントを定義していきます。main.pyでは@app.get()のようにアプリケーションに直接定義していましたが、ルータファイルでは@router.get()のようにルータに対して定義します。

この時点でサーバを起動しておきます。--reloadオプションにより、コードを変更するたびにサーバが自動的に再起動されるため、エンドポイントを追加するたびにサーバを手動で再起動する必要はありません。

Windowsの場合:

python main.py

Macの場合:

python3 main.py

以下のような出力が表示されれば、サーバが正常に起動しています。

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [xxxxx] using StatReload
INFO:     Started server process [xxxxx]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

動作確認は、サーバとは別のターミナルを開いて行います。Visual Studio Codeでは、ターミナルパネル右上の「+」ボタンをクリックすると、新しいターミナルを追加できます。サーバを起動したターミナルはそのまま残し、新しいターミナルでcurlコマンドを実行してください。

9.3 ノート作成(POST /notes)

最初に、ノートを作成するエンドポイントを実装します。routers/notes.py# ---- エンドポイントのコメントの下に、以下のコードを追加します。

@router.post("/notes", status_code=201)
def create_note(note: NoteCreate, session: Session = Depends(get_session)):
    db_note = Note(title=note.title, content=note.content)
    session.add(db_note)
    session.commit()
    session.refresh(db_note)
    return db_note

追加したコードを解説します。

@router.post("/notes", status_code=201)
def create_note(note: NoteCreate, session: Session = Depends(get_session)):

@router.post("/notes", status_code=201)で、POST /notesにリクエストが来たときに実行されるエンドポイントを定義しています。status_code=201は、リソースの作成が成功したことを示すHTTPステータスコードです。引数のnote: NoteCreateにより、リクエストボディのJSONデータがNoteCreateモデルに自動変換されます。session: Session = Depends(get_session)で、データベースセッションが自動的に注入されます。

db_note = Note(title=note.title, content=note.content)
session.add(db_note)
session.commit()

Note(title=..., content=...)でテーブルモデルのインスタンスを作成し、session.add()でセッションに追加、session.commit()でデータベースに保存しています。

session.refresh(db_note)
return db_note

session.refresh(db_note)は、データベースが自動付与したidcreated_atなどの値をインスタンスに反映します。これにより、レスポンスにこれらの値を含めることができます。

動作確認

ファイルを保存すると、サーバが自動的に再起動されます。curlコマンドで動作を確認します。

curl -X POST http://localhost:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "新しいノート", "content": "curlコマンドでノートを作成しました。"}'

以下のようなレスポンスが返されます。

{"title":"新しいノート","content":"curlコマンドでノートを作成しました。","id":3,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"}

新しいノートがid: 3で作成されました。created_atupdated_atも自動的に設定されていることが確認できます。

💡 ポイント
setup_db.pyでテストデータを2件挿入しているため、新しく作成されたノートのIDは3になります。created_atupdated_atの値は実行した日時によって異なります。

9.4 ノート一覧取得(GET /notes)

次に、ノートの一覧を取得するエンドポイントを追加します。先ほど追加したcreate_note関数の下に、以下のコードを追加します。

@router.get("/notes")
def get_notes(session: Session = Depends(get_session)):
    notes = session.exec(select(Note).order_by(Note.id.desc())).all()
    return notes

追加したコードを解説します。

notes = session.exec(select(Note).order_by(Note.id.desc())).all()
return notes

select(Note).order_by(Note.id.desc())で、全ノートをIDの降順(新しい順)で取得しています。.all()で全件をリストとして取得し、FastAPIが自動的にJSON形式に変換してレスポンスを返します。

動作確認

curlコマンドで動作を確認します。

curl http://localhost:8000/notes

以下のようなレスポンスが返されます。

[{"title":"新しいノート","content":"curlコマンドでノートを作成しました。","id":3,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"},{"title":"2番目のノート","content":"FastAPIとSQLModelの連携を学んでいます。","id":2,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"},{"title":"最初のノート","content":"これはテスト用の最初のノートです。","id":1,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"}]

テストデータの2件と、先ほど作成したノートの計3件が、新しい順に返されています。

9.5 ノート詳細取得(GET /notes/{note_id})

特定のノートを1件取得するエンドポイントを追加します。

@router.get("/notes/{note_id}")
def get_note(note_id: int, session: Session = Depends(get_session)):
    note = session.get(Note, note_id)
    if not note:
        raise HTTPException(status_code=404, detail="ノートが見つかりません")
    return note

追加したコードを解説します。

note = session.get(Note, note_id)

session.get(Note, note_id)で、主キー(ID)を指定してノートを1件取得します。ノートが存在しない場合はNoneが返されます。

if not note:
    raise HTTPException(status_code=404, detail="ノートが見つかりません")
return note

ノートが存在しない場合はHTTPExceptionで404エラーを返します。存在する場合はそのままnoteを返します。

動作確認

IDが1のノートを取得します。

curl http://localhost:8000/notes/1

以下のようなレスポンスが返されます。

{"title":"最初のノート","content":"これはテスト用の最初のノートです。","id":1,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"}

存在しないIDを指定した場合も確認してみましょう。

curl http://localhost:8000/notes/999

以下のようなエラーレスポンスが返されます。

{"detail":"ノートが見つかりません"}

HTTPExceptionにより、404 Not Foundのステータスコードとエラーメッセージが返されています。

9.6 ノート更新(PUT /notes/{note_id})

ノートを更新するエンドポイントを追加します。

from models import NoteCreate, NoteUpdate, Note

ファイルの先頭にあるimport文に、NoteUpdateを追加します。

@router.put("/notes/{note_id}")
def update_note(
    note_id: int, note_data: NoteUpdate, session: Session = Depends(get_session)
):
    note = session.get(Note, note_id)
    if not note:
        raise HTTPException(status_code=404, detail="ノートが見つかりません")
    if note_data.title is not None:
        note.title = note_data.title
    if note_data.content is not None:
        note.content = note_data.content
    session.add(note)
    session.commit()
    session.refresh(note)
    return note

追加したコードを解説します。

note = session.get(Note, note_id)
if not note:
    raise HTTPException(status_code=404, detail="ノートが見つかりません")

ノート詳細取得と同様に、session.get()で対象のノートを取得し、存在しない場合は404エラーを返します。

if note_data.title is not None:
    note.title = note_data.title
if note_data.content is not None:
    note.content = note_data.content

NoteUpdateのフィールドはOptionalのため、クライアントが送信しなかったフィールドはNoneになります。is not Noneで判定し、値が送られたフィールドのみ更新しています。

ここでif note_data.title:ではなくif note_data.title is not None:と書いているのがポイントです。Pythonでは空文字""や数値の0if value:の条件でfalse(偽)と判定されるため、if note_data.title:と書くと空文字をセットしたい場合にスキップされてしまいます。is not Noneで判定することで、「値が送られたかどうか」を正しく判定できます。

session.add(note)
session.commit()
session.refresh(note)
return note

前の講座で学んだとおり、属性を変更してからsession.add()session.commit()で変更を確定させます。session.refresh()updated_atの更新値をインスタンスに反映しています。

💡 ポイント
HTTPメソッドにはPUTのほかにPATCHというメソッドもあります。PUTは「リソース全体の置き換え」を意味し、すべてのフィールドを送信するのが基本です。一方PATCHは「部分更新」を意味し、変更したいフィールドだけを送信します。今回の実装はNoteUpdate(全フィールドOptional)を使った部分更新の挙動なので、厳密にはPATCHが適切です。PUTを使う場合は全フィールドを必須にして全体を置き換える設計にします。実務ではPATCHを使うケースが多いですが、今回はHTTPメソッドの違いよりも更新処理のロジックを学ぶことを優先し、PUTで実装しています。

動作確認

先ほど作成したid: 3のノートを更新します。

curl -X PUT http://localhost:8000/notes/3 \
  -H "Content-Type: application/json" \
  -d '{"title": "更新したノート", "content": "内容を更新しました。"}'

以下のようなレスポンスが返されます。

{"title":"更新したノート","content":"内容を更新しました。","id":3,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T11:00:00"}

titlecontentが更新され、updated_atが更新日時に変わっていることが確認できます。created_atは変わっていません。

詳細取得APIで、更新内容が反映されていることを確認してみましょう。

curl http://localhost:8000/notes/3

以下のようなレスポンスが返されます。

{"title":"更新したノート","content":"内容を更新しました。","id":3,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T11:00:00"}

先ほど更新したtitlecontentが正しく保存されていることが確認できます。

9.7 ノート削除(DELETE /notes/{note_id})

ノートを削除するエンドポイントを追加します。

@router.delete("/notes/{note_id}")
def delete_note(note_id: int, session: Session = Depends(get_session)):
    note = session.get(Note, note_id)
    if not note:
        raise HTTPException(status_code=404, detail="ノートが見つかりません")
    session.delete(note)
    session.commit()
    return {"message": "ノートを削除しました"}

追加したコードを解説します。

note = session.get(Note, note_id)
if not note:
    raise HTTPException(status_code=404, detail="ノートが見つかりません")

ノート詳細取得と同様に、session.get()で対象のノートを取得し、存在しない場合は404エラーを返します。

session.delete(note)
session.commit()
return {"message": "ノートを削除しました"}

session.delete()でセッションから削除対象として登録し、session.commit()で削除を確定させます。削除が完了したら、メッセージを返します。

💡 ポイント
DELETEのレスポンスとして、今回はメッセージを含む200 OKを返していますが、REST APIの慣例ではレスポンスボディを返さない204 No Contentを使うことも一般的です。204を使う場合はreturn文を省略し、@router.delete("/notes/{note_id}", status_code=204)のようにステータスコードを指定します。

動作確認

id: 3のノートを削除します。

curl -X DELETE http://localhost:8000/notes/3

以下のようなレスポンスが返されます。

{"message":"ノートを削除しました"}

削除されたことを一覧取得で確認します。

curl http://localhost:8000/notes

以下のようなレスポンスが返されます。

[{"title":"2番目のノート","content":"FastAPIとSQLModelの連携を学んでいます。","id":2,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"},{"title":"最初のノート","content":"これはテスト用の最初のノートです。","id":1,"created_at":"2025-01-01T10:00:00","updated_at":"2025-01-01T10:00:00"}]

id: 3のノートが一覧から消えていることが確認できます。

9.8 ヘルスチェック(GET /health)

ヘルスチェック用のエンドポイントは、すでにmain.pyに定義済みです。アプリケーションが正常に動作しているかを確認するためのエンドポイントで、ロードバランサや監視ツールが定期的にこのエンドポイントにリクエストを送信し、アプリケーションの状態を監視します。ノートの操作とは異なりアプリケーション全体に関わるものなので、routers/notes.pyではなくmain.pyに配置しています。

動作確認

curlコマンドで動作を確認します。

curl http://localhost:8000/health

以下のようなレスポンスが返されます。

{"status":"healthy"}

9.9 バリデーションエラーの確認

ここまでの実装で、NoteCreateモデルによるバリデーションも自動的に機能しています。必須フィールドであるtitleを省略した場合の動作を確認してみましょう。

curl -X POST http://localhost:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"content": "タイトルなし"}'

以下のようなエラーレスポンスが返されます。

{"detail":[{"type":"missing","loc":["body","title"],"msg":"Field required","input":{"content":"タイトルなし"}}]}

NoteCreateモデルのバリデーションが自動的に行われ、titleフィールドが必須であることを示すエラーが返されます。

💡 ポイント
全てのエンドポイントは、ブラウザでhttp://localhost:8000/docsにアクセスしてSwagger UIからもテストできます。特にPOSTやPUTのリクエストは、Swagger UIの「Try it out」ボタンから簡単に実行できるため、curlコマンドに不慣れな場合はこちらも活用してください。

動作確認が完了したら、ターミナルでCtrl + Cを押してサーバを停止してください。

10. クリーンアップ

ハンズオンが完了したら、作成したデータベースを削除します。MySQL CLIから以下のコマンドを実行してください。

mysql -u root -p -e "DROP DATABASE notedb;"

パスワードを入力すると、データベースnotedbが削除されます。

10.1 MySQLで確認

MySQLに接続して、データベースが削除されているか確認してみましょう。

mysql -u root -p -e "SHOW DATABASES;"

パスワードを入力すると、データベースの一覧が表示されます。notedbが一覧に存在しないことが確認できます。

11. まとめ

このハンズオンでは、FastAPIとSQLModelを組み合わせたREST APIの構築を体験しました。

  • SQLModelのテーブルモデルを使うことで、Pythonのクラス定義からデータベースのテーブルを作成できる
  • NoteCreateモデルでリクエストボディのバリデーションを行い、NoteUpdateモデルで更新時の部分更新に対応し、Noteモデルでデータベースのテーブルを定義する
  • 更新用モデルではOptionalを活用し、is not Noneで「値が送られたかどうか」を判定することで、変更したいフィールドだけを更新できる
  • FastAPIのDependsパターンを使うと、セッションの作成と解放が自動的に管理される
  • session.add()でデータの作成、session.get()でIDによる取得、session.exec(select(...))で一覧取得、session.delete()で削除ができる
  • HTTPExceptionを使って適切なエラーレスポンス(404 Not Foundなど)を返すことができる
  • 環境変数.envファイルで管理することで、接続情報をコードから分離できる
  • APIRouterを使ってエンドポイントをrouters/フォルダに分離することで、ファイルの役割が明確になり保守性が向上する
  • ファイルを役割ごとに分割(models.pydatabase.pyrouters/notes.pymain.py)することで、コードの見通しがよくなる
  • 全てのエンドポイントはSwagger UI/docs)からもテスト実行できる