採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

L11 ch04 — CRUD エンドポイント

テクノロジ系 / L11 fastapi-basic — FastAPI 入門

L11 ch04 — CRUD エンドポイント

C/R/U/D の 4 操作を 1 つの API にまとめる

ゴール

  • CRUD 4 エンドポイントを実装
  • メモリ内 list で共有データ
  • HTTPException で 404 / 400 を返す

CRUD = 4 操作

操作HTTPURL意味
CreatePOST/tasks新規作成
ReadGET/tasks / /tasks/{id}取得
UpdatePATCH/tasks/{id}部分更新
DeleteDELETE/tasks/{id}削除

共有データ (メモリ list)

from fastapi import FastAPI, HTTPException

app = FastAPI()
tasks: list[dict] = []

→ サーバー再起動で消える。永続化は L13 で。

Read 全件 + 1 件

@app.get("/tasks")
def get_all():
    return tasks

@app.get("/tasks/{task_id}")
def get_one(task_id: int):
    for t in tasks:
        if t["id"] == task_id:
            return t
    raise HTTPException(status_code=404, detail="Task not found")

Create

@app.post("/tasks", status_code=201)
async def create(request: Request):
    body = await request.json()
    new_task = {
        "id": len(tasks) + 1,
        "title": body["title"],
        "done": body.get("done", False),
    }
    tasks.append(new_task)
    return new_task

Update (PATCH)

@app.patch("/tasks/{task_id}")
async def update(task_id: int, request: Request):
    body = await request.json()
    for t in tasks:
        if t["id"] == task_id:
            t.update(body)  # 渡されたキーだけ更新
            return t
    raise HTTPException(status_code=404, detail="Task not found")

Delete

@app.delete("/tasks/{task_id}", status_code=204)
def delete(task_id: int):
    global tasks
    for t in tasks:
        if t["id"] == task_id:
            tasks = [x for x in tasks if x["id"] != task_id]
            return  # 204 = No Content
    raise HTTPException(status_code=404, detail="Task not found")

ID を連番で振る

POST で新規作成するとき、ID をどうやって決めるかは必ず出てくる課題。

一番シンプルな方法は next_id というグローバル変数で 1 から連番採番するパターン。

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

tasks: list[dict] = []
next_id: int = 1            # ← 次に振る ID。最初は 1

class TaskIn(BaseModel):
    title: str
    done: bool = False

@app.post("/tasks", status_code=201)
def create(payload: TaskIn):
    global next_id          # ← 関数内で書き換えるので global 宣言
    new_task = {
        "id": next_id,
        "title": payload.title,
        "done": payload.done,
    }
    tasks.append(new_task)
    next_id += 1            # ← 次に備えてインクリメント
    return new_task
  • id = next_idnext_id += 12 ステップが採番のレシピ
  • global next_id を忘れると UnboundLocalError になる
  • サーバー再起動で next_id は 1 に戻る (永続化は次章)

採番方式の選択肢

方式書き方特徴
next_id (推奨)id = next_id; next_id += 1単純・確実・本章で使う方式
max(id) + 1id = max([t["id"] for t in tasks], default=0) + 1リストが空でも壊れない書き方が必要
len(tasks) + 1id = len(tasks) + 1DELETE すると ID が重複する罠。非推奨
uuid.uuid4()id = str(uuid.uuid4())衝突しない。連番ではなく文字列ID

> 「連番のID が欲しい」なら next_id パターンが最も素直

> len(tasks) + 1 は一見動くが、削除があると破綻するので使わない感覚を身につける。

drill

drill/ — fill-patch / add-get-one / add-search / add-validation

ハンズオン

ch04-crud 演習ガイド

受講生向け演習問題集

採点の仕組み (読んでから始めてください)

採点テストは 「お題の通りに動くか」 を機械的にチェックします。

ドリルごとに 2つのタイプ があるので、各ドリル冒頭の 【タイプ】 を確認してください。

タイプ fill (穴埋め問題)

  • ___ (3個並んだアンダースコア) の空欄を埋める
  • 既存の構造は そのまま
  • ロジック自由度: 低 (お題どおりに直す)

タイプ add (機能追加問題)

  • 既存コードを残したまま、新しいエンドポイント/機能を 追加 する
  • 実装方法は自由 (関数名・変数名・内部ロジックは好きに)
  • ただしテストが要求する I/F (URL・HTTPメソッド・レスポンスのキー名) は守る
  • 既存テストを壊さないこと (= 既存エンドポイントの URL/レスポンスは変えない)

どちらも共通で守ること

  • ファイル名 (main.py) は変えない
  • tests/ フォルダは触らない (採点ロジック)
  • 関数の引数の型ヒント (item_id: int 等) は基本そのまま

このガイドのドリル一覧

#ドリル名タイトルタイプ難易度
101-fill-patch@app.patch - 既存タスクを更新する🔧 穴埋め
202-add-get-oneGET /tasks/{id} + HTTPException 404 - 1件取得➕ 機能追加
303-add-searchクエリパラメータでフィルタ - 検索エンドポイント➕ 機能追加
404-add-validationHTTPException 400 - 入力値のバリデーション➕ 機能追加
505-build-task-crudCRUD + HTTPException + 連番ID - タスク管理 API を白紙から🛠️ ゼロ実装★★

> 詰まったら ヒント ページを開く。それでも分からなければ講師に質問。

ドリル 1: @app.patch - 既存タスクを更新する

ドリル名: 01-fill-patch

> 🔧 【タイプ: 穴埋め問題】 ___ の部分を埋めて完成させてください。既存のコード構造は変えないこと。

問題

___ を埋めて、タスクを更新する PATCH /tasks/{task_id} を完成させよ。

@app.___ ("/tasks/{task_id}")
def update_task(task_id: int, task: Task):
    tasks = load()
    for t in tasks:
        if t["id"] == ___:
            t.update(task.model_dump())
            save(tasks)
            return t
    raise HTTPException(status_code=___, detail="Task not found")

確認方法

  1. POST /tasks でタスクを1件追加する
  2. PATCH /tasks/1{"title": "買い物", "done": true} を送信
  3. レスポンスの donetrue になること
  4. GET /tasks でも変更が反映されていること

ドリル 1: ヒント

ドリル名: 01-fill-patch

> ⚠️ 自力で考えてから読んでください

> - HTTPメソッドの名前がそのままデコレータになる。部分更新には patch を使う。

> - t["id"] == ___ の右辺には、パスパラメータで受け取った変数名を入れる。

> - 見つからないときのステータスコードは「Not Found」を表す番号。

ドリル 2: GET /tasks/{id} + HTTPException 404 - 1件取得

ドリル名: 02-add-get-one

> ➕ 【タイプ: 機能追加問題】 既存コードを残したまま新しい機能を追加してください。実装は自由ですが、テストが要求する I/F・表示テキストは守ること。

問題

GET /tasks/{task_id} エンドポイントを追加せよ。

仕様

  • task_id に一致するタスクを返す
  • 一致するタスクがない場合は 404 を返す

確認方法

  1. POST /tasks でタスクを1件追加する
  2. GET /tasks/1 → 追加したタスクが返ること
  3. GET /tasks/999{"detail": "Task not found"}404 が返ること

★ 余裕がある人向け: 拡張課題

このドリルが PASS したら、以下の発展課題に挑戦してみてください (採点はなし、自由演習)。

  • task_id が負の数や 0 のとき、404 ではなく 400 (Bad Request) を返すように分岐させる
  • GET /tasks/{task_id} のレスポンスに requested_at (取得時刻) を含めて、メタ情報も一緒に返す
  • 同じ ID への問い合わせ回数を辞書でカウントし、GET /tasks/{task_id}/stats で読み出せるようにしてみる

採点ロジックは元のテストのままです。拡張部分を実装してもテストは PASS のまま、自分の力試しになります。

ドリル 2: ヒント

ドリル名: 02-add-get-one

> ⚠️ 自力で考えてから読んでください

> GET /tasks と同じ仕組みで、パスに {task_id} を加えると1件取得になる。

>

> リストをループして task["id"] == task_id で一致するものを探す。見つかったら return、ループを抜けたら HTTPException(status_code=404, ...)raise する。

ドリル 3: クエリパラメータでフィルタ - 検索エンドポイント

ドリル名: 03-add-search

> ➕ 【タイプ: 機能追加問題】 既存コードを残したまま新しい機能を追加してください。実装は自由ですが、テストが要求する I/F・表示テキストは守ること。

base/main.py は CRUD が動く状態。GET /tasks にクエリパラメータ q を追加して、タイトルの部分一致検索ができるようにせよ。

やること

  1. GET /tasks?q=買い物 のように q を渡すと、titleq を含むタスクだけ返す
  2. q を省略した場合は全件返す(既存の動作を壊さない)

仕様

  • パラメータ名: q(省略可能、デフォルト None
  • 大文字・小文字は区別しない("Buy""buy milk" もヒットする)
  • q が空文字 "" のときは全件返す(省略と同じ扱い)
  • 一致するタスクが0件のときは [] を返す(エラーにしない)

確認方法

  1. POST /tasks{"title": "買い物"} {"title": "掃除"} {"title": "Buy milk"} を登録
  2. GET /tasks?q=買い[{"id":1,"title":"買い物",...}] が返ること
  3. GET /tasks?q=buy[{"id":3,"title":"Buy milk",...}] が返ること(大文字小文字無視)
  4. GET /tasks?q=xyz[] が返ること
  5. GET /tasks → 全3件が返ること
  6. GET /tasks?q= → 全3件が返ること

★ 余裕がある人向け: 拡張課題

このドリルが PASS したら、以下の発展課題に挑戦してみてください (採点はなし、自由演習)。

  • q と一緒に done クエリも受け取り、両方の AND 条件 (タイトル部分一致 + 完了状態一致) で絞り込めるようにする
  • ページング用に limit / offset クエリを追加し、GET /tasks?limit=2&offset=1 で件数を制御できるようにする
  • 検索結果に matched_count フィールドを付けたメタ付きレスポンス ({"items": [...], "matched_count": 3}) を返す別エンドポイント /tasks/search を作ってみる

採点ロジックは元のテストのままです。拡張部分を実装してもテストは PASS のまま、自分の力試しになります。

ドリル 3: ヒント

ドリル名: 03-add-search

> ⚠️ 自力で考えてから読んでください

  • FastAPI のクエリパラメータは関数の引数として宣言する: def get_tasks(q: str | None = None)
  • qNone または "" のときは全件返せばよい。if not q: で両方まとめて判定できる
  • 部分一致は q in title で判定できる
  • 大文字小文字を無視するには q.lower()title.lower() を比較する
  • リスト内包表記 [t for t in tasks if ...] でフィルタできる

ドリル 4: HTTPException 400 - 入力値のバリデーション

ドリル名: 04-add-validation

> ➕ 【タイプ: 機能追加問題】 既存コードを残したまま新しい機能を追加してください。実装は自由ですが、テストが要求する I/F・表示テキストは守ること。

base/main.py は CRUD が動く状態。POST /tasks にバリデーションを追加せよ。

追加するバリデーション

  1. title が存在しない、または空文字のとき 400 Bad Request を返す
  2. done が渡されたとき、bool 以外の型(例: 文字列 "yes" や整数 1)のとき 400 Bad Request を返す
  3. 上記に引っかからない場合は通常通り 201 を返す

仕様

  • エラー時のレスポンス例: {"detail": "title is required"}
  • done の型チェックには isinstance() を使う
  • json.loads() は使わない(request.json() のまま)

確認方法

  1. POST /tasks{} を送る → 400 と "title is required" が返ること
  2. POST /tasks{"title": ""} を送る → 400 が返ること
  3. POST /tasks{"title": "買い物", "done": "yes"} を送る → 400 が返ること
  4. POST /tasks{"title": "買い物", "done": true} を送る → 201 が返ること
  5. POST /tasks{"title": "買い物"} を送る → 201 が返ること(done 省略は OK)

★ 余裕がある人向け: 拡張課題

このドリルが PASS したら、以下の発展課題に挑戦してみてください (採点はなし、自由演習)。

  • title の最大文字数 (例: 100文字) を超えたら 400 を返すバリデーションを追加する
  • バリデーションエラーメッセージを {"detail": [{"field": "title", "message": "..."}]} のように複数項目まとめて返す形に変える
  • 同じバリデーションを Pydantic BaseModel で書き直して、isinstance 連打との実装量・読みやすさを比較してみる

採点ロジックは元のテストのままです。拡張部分を実装してもテストは PASS のまま、自分の力試しになります。

ドリル 4: ヒント

ドリル名: 04-add-validation

> ⚠️ 自力で考えてから読んでください

  • body.get("title")None(キーなし)と "" のどちらも falsy。if not body.get("title"): で両方まとめて検査できる
  • isinstance(value, bool) — Python では boolint のサブクラス。isinstance(1, bool)False なので isinstance で区別できる
  • raise HTTPException(status_code=400, detail="...") で 400 を返す
  • バリデーションは new_task = ... よりに置く

ドリル 5: CRUD + HTTPException + 連番ID - タスク管理 API を白紙から

ドリル名: 05-build-task-crud

> 🛠️ 【タイプ: ゼロベース実装問題】 ほぼ空の App.vue から仕様を読んで自力で実装してください。<template> 構造・id/class・内部ロジックは完全に自由。ただしテストが見る 画面の表示テキスト・ボタンの動作 は仕様どおりに揃えること。

問題

タスク管理 API を FastAPI で 白紙から 実装してください。

main.py には最小スケルトンしかありません。ファイル構造・関数名・内部ロジック・データ構造はすべて自由ですが、下記のインターフェースを満たすこと。

仕様

メモリ上でタスクを管理する API を作る (永続化不要)。各タスクは以下の形式:

{ "id": 1, "title": "牛乳を買う", "done": false }

エンドポイント

メソッドURLリクエストレスポンス説明
GET/tasks-[ { id, title, done }, ... ]全件取得
POST/tasks{ "title": "..." }{ id, title, done } (201)新規追加。id は 1 から連番、done は false
PATCH/tasks/{task_id}{ "done": true } (任意){ id, title, done }既存タスクを更新。存在しなければ 404
DELETE/tasks/{task_id}-204 (ボディなし)削除。存在しなければ 404

補足

  • 初期状態のタスクは空 ([])
  • id は POST のたびに 1 ずつ増える (1, 2, 3, ...)
  • 1ファイル (main.py) で完結させること
  • データ構造はリストでも辞書でもデータベースでも OK (ただし永続化は不要、メモリで OK)

確認方法

  • 起動.bat (Windows) / 起動.sh (Mac) をダブルクリック
  • ブラウザにテスト結果が表示される

自由に設計できる例

  • タスク管理を list[dict] で持つ / dict[int, dict] で持つ / class Task を作る
  • ファイル分割 (例: models.py / db.py ) は禁止main.py 1ファイルで書く
  • 関数名は好きに付けてOK (get_tasks / list_tasks / tasks_index 等)

守る (テストが見ている)

  • GET /tasks を叩くとタスク配列が JSON で返る
  • POST /tasksid が連番で振られる
  • PATCH /tasks/{id}done が更新できる
  • DELETE /tasks/{id} で 204 が返る
  • 存在しない id への PATCH/DELETE は 404

このドリルで学ぶこと

学習要素学習資料との対応
CRUD 4動詞 (GET/POST/PATCH/DELETE) を一つの API で扱うch04 学習目標
next_id グローバル変数で ID を連番採番ch04 slide「ID を連番で振る」
メモリ内 list を共有データとして使うch04 学習目標
HTTPException で 404 / 400 を返すch02/ch04 slide

このドリルは 小さな状態機械の設計 が学習目標。後続のフルスタック演習 (= app-tasks/ch01-router-computed-watch)でバックエンド側を考える土台になります。

ドリル 5: ヒント

ドリル名: 05-build-task-crud

> ⚠️ 自力で考えてから読んでください

段階1: データ保持の方法を決める

タスクをどこに持つ? 答えは「グローバルな変数」が一番シンプル。

tasks: list[dict] = []
next_id: int = 1

段階2: GET /tasks

タスクのリストを return するだけ。

段階3: POST /tasks

  • リクエストボディから title を受け取る → Pydantic の BaseModel で定義する
  • next_id を採番してインクリメント
  • {"id": ..., "title": ..., "done": False} を作って tasks に append + return

段階4: PATCH /tasks/{task_id}

  • task_id で該当タスクを検索
  • 見つからなければ HTTPException(status_code=404)
  • リクエストボディに done があれば更新

段階5: DELETE /tasks/{task_id}

  • 検索 → 見つからなければ 404
  • 見つかれば tasks.remove(task)
  • ステータスコード 204 を返す: @app.delete("/tasks/{task_id}", status_code=204)

考えてみよう

既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。

ch04-crud 演習ガイド

受講生向け演習問題集

採点の仕組み (読んでから始めてください)

採点テストは 「お題の通りに動くか」 を機械的にチェックします。

ドリルごとに 2つのタイプ があるので、各ドリル冒頭の 【タイプ】 を確認してください。

タイプ fill (穴埋め問題)

  • ___ (3個並んだアンダースコア) の空欄を埋める
  • 既存の構造は そのまま
  • ロジック自由度: 低 (お題どおりに直す)

タイプ add (機能追加問題)

  • 既存コードを残したまま、新しいエンドポイント/機能を 追加 する
  • 実装方法は自由 (関数名・変数名・内部ロジックは好きに)
  • ただしテストが要求する I/F (URL・HTTPメソッド・レスポンスのキー名) は守る
  • 既存テストを壊さないこと (= 既存エンドポイントの URL/レスポンスは変えない)

どちらも共通で守ること

  • ファイル名 (main.py) は変えない
  • tests/ フォルダは触らない (採点ロジック)
  • 関数の引数の型ヒント (item_id: int 等) は基本そのまま

このガイドのドリル一覧

#ドリル名タイトルタイプ難易度
101-fill-patch@app.patch - 既存タスクを更新する🔧 穴埋め
202-add-get-oneGET /tasks/{id} + HTTPException 404 - 1件取得➕ 機能追加
303-add-searchクエリパラメータでフィルタ - 検索エンドポイント➕ 機能追加
404-add-validationHTTPException 400 - 入力値のバリデーション➕ 機能追加
505-build-task-crudCRUD + HTTPException + 連番ID - タスク管理 API を白紙から🛠️ ゼロ実装★★

> 詰まったら ヒント ページを開く。それでも分からなければ講師に質問。

ドリル 1: @app.patch - 既存タスクを更新する

ドリル名: 01-fill-patch

> 🔧 【タイプ: 穴埋め問題】 ___ の部分を埋めて完成させてください。既存のコード構造は変えないこと。

問題

___ を埋めて、タスクを更新する PATCH /tasks/{task_id} を完成させよ。

@app.___ ("/tasks/{task_id}")
def update_task(task_id: int, task: Task):
    tasks = load()
    for t in tasks:
        if t["id"] == ___:
            t.update(task.model_dump())
            save(tasks)
            return t
    raise HTTPException(status_code=___, detail="Task not found")

確認方法

  1. POST /tasks でタスクを1件追加する
  2. PATCH /tasks/1{"title": "買い物", "done": true} を送信
  3. レスポンスの donetrue になること
  4. GET /tasks でも変更が反映されていること

ドリル 1: ヒント

ドリル名: 01-fill-patch

> ⚠️ 自力で考えてから読んでください

> - HTTPメソッドの名前がそのままデコレータになる。部分更新には patch を使う。

> - t["id"] == ___ の右辺には、パスパラメータで受け取った変数名を入れる。

> - 見つからないときのステータスコードは「Not Found」を表す番号。

ドリル 2: GET /tasks/{id} + HTTPException 404 - 1件取得

ドリル名: 02-add-get-one

> ➕ 【タイプ: 機能追加問題】 既存コードを残したまま新しい機能を追加してください。実装は自由ですが、テストが要求する I/F・表示テキストは守ること。

問題

GET /tasks/{task_id} エンドポイントを追加せよ。

仕様

  • task_id に一致するタスクを返す
  • 一致するタスクがない場合は 404 を返す

確認方法

  1. POST /tasks でタスクを1件追加する
  2. GET /tasks/1 → 追加したタスクが返ること
  3. GET /tasks/999{"detail": "Task not found"}404 が返ること

★ 余裕がある人向け: 拡張課題

このドリルが PASS したら、以下の発展課題に挑戦してみてください (採点はなし、自由演習)。

  • task_id が負の数や 0 のとき、404 ではなく 400 (Bad Request) を返すように分岐させる
  • GET /tasks/{task_id} のレスポンスに requested_at (取得時刻) を含めて、メタ情報も一緒に返す
  • 同じ ID への問い合わせ回数を辞書でカウントし、GET /tasks/{task_id}/stats で読み出せるようにしてみる

採点ロジックは元のテストのままです。拡張部分を実装してもテストは PASS のまま、自分の力試しになります。

ドリル 2: ヒント

ドリル名: 02-add-get-one

> ⚠️ 自力で考えてから読んでください

> GET /tasks と同じ仕組みで、パスに {task_id} を加えると1件取得になる。

>

> リストをループして task["id"] == task_id で一致するものを探す。見つかったら return、ループを抜けたら HTTPException(status_code=404, ...)raise する。

ドリル 3: クエリパラメータでフィルタ - 検索エンドポイント

ドリル名: 03-add-search

> ➕ 【タイプ: 機能追加問題】 既存コードを残したまま新しい機能を追加してください。実装は自由ですが、テストが要求する I/F・表示テキストは守ること。

base/main.py は CRUD が動く状態。GET /tasks にクエリパラメータ q を追加して、タイトルの部分一致検索ができるようにせよ。

やること

  1. GET /tasks?q=買い物 のように q を渡すと、titleq を含むタスクだけ返す
  2. q を省略した場合は全件返す(既存の動作を壊さない)

仕様

  • パラメータ名: q(省略可能、デフォルト None
  • 大文字・小文字は区別しない("Buy""buy milk" もヒットする)
  • q が空文字 "" のときは全件返す(省略と同じ扱い)
  • 一致するタスクが0件のときは [] を返す(エラーにしない)

確認方法

  1. POST /tasks{"title": "買い物"} {"title": "掃除"} {"title": "Buy milk"} を登録
  2. GET /tasks?q=買い[{"id":1,"title":"買い物",...}] が返ること
  3. GET /tasks?q=buy[{"id":3,"title":"Buy milk",...}] が返ること(大文字小文字無視)
  4. GET /tasks?q=xyz[] が返ること
  5. GET /tasks → 全3件が返ること
  6. GET /tasks?q= → 全3件が返ること

★ 余裕がある人向け: 拡張課題

このドリルが PASS したら、以下の発展課題に挑戦してみてください (採点はなし、自由演習)。

  • q と一緒に done クエリも受け取り、両方の AND 条件 (タイトル部分一致 + 完了状態一致) で絞り込めるようにする
  • ページング用に limit / offset クエリを追加し、GET /tasks?limit=2&offset=1 で件数を制御できるようにする
  • 検索結果に matched_count フィールドを付けたメタ付きレスポンス ({"items": [...], "matched_count": 3}) を返す別エンドポイント /tasks/search を作ってみる

採点ロジックは元のテストのままです。拡張部分を実装してもテストは PASS のまま、自分の力試しになります。

ドリル 3: ヒント

ドリル名: 03-add-search

> ⚠️ 自力で考えてから読んでください

  • FastAPI のクエリパラメータは関数の引数として宣言する: def get_tasks(q: str | None = None)
  • qNone または "" のときは全件返せばよい。if not q: で両方まとめて判定できる
  • 部分一致は q in title で判定できる
  • 大文字小文字を無視するには q.lower()title.lower() を比較する
  • リスト内包表記 [t for t in tasks if ...] でフィルタできる

ドリル 4: HTTPException 400 - 入力値のバリデーション

ドリル名: 04-add-validation

> ➕ 【タイプ: 機能追加問題】 既存コードを残したまま新しい機能を追加してください。実装は自由ですが、テストが要求する I/F・表示テキストは守ること。

base/main.py は CRUD が動く状態。POST /tasks にバリデーションを追加せよ。

追加するバリデーション

  1. title が存在しない、または空文字のとき 400 Bad Request を返す
  2. done が渡されたとき、bool 以外の型(例: 文字列 "yes" や整数 1)のとき 400 Bad Request を返す
  3. 上記に引っかからない場合は通常通り 201 を返す

仕様

  • エラー時のレスポンス例: {"detail": "title is required"}
  • done の型チェックには isinstance() を使う
  • json.loads() は使わない(request.json() のまま)

確認方法

  1. POST /tasks{} を送る → 400 と "title is required" が返ること
  2. POST /tasks{"title": ""} を送る → 400 が返ること
  3. POST /tasks{"title": "買い物", "done": "yes"} を送る → 400 が返ること
  4. POST /tasks{"title": "買い物", "done": true} を送る → 201 が返ること
  5. POST /tasks{"title": "買い物"} を送る → 201 が返ること(done 省略は OK)

★ 余裕がある人向け: 拡張課題

このドリルが PASS したら、以下の発展課題に挑戦してみてください (採点はなし、自由演習)。

  • title の最大文字数 (例: 100文字) を超えたら 400 を返すバリデーションを追加する
  • バリデーションエラーメッセージを {"detail": [{"field": "title", "message": "..."}]} のように複数項目まとめて返す形に変える
  • 同じバリデーションを Pydantic BaseModel で書き直して、isinstance 連打との実装量・読みやすさを比較してみる

採点ロジックは元のテストのままです。拡張部分を実装してもテストは PASS のまま、自分の力試しになります。

ドリル 4: ヒント

ドリル名: 04-add-validation

> ⚠️ 自力で考えてから読んでください

  • body.get("title")None(キーなし)と "" のどちらも falsy。if not body.get("title"): で両方まとめて検査できる
  • isinstance(value, bool) — Python では boolint のサブクラス。isinstance(1, bool)False なので isinstance で区別できる
  • raise HTTPException(status_code=400, detail="...") で 400 を返す
  • バリデーションは new_task = ... よりに置く

ドリル 5: CRUD + HTTPException + 連番ID - タスク管理 API を白紙から

ドリル名: 05-build-task-crud

> 🛠️ 【タイプ: ゼロベース実装問題】 ほぼ空の App.vue から仕様を読んで自力で実装してください。<template> 構造・id/class・内部ロジックは完全に自由。ただしテストが見る 画面の表示テキスト・ボタンの動作 は仕様どおりに揃えること。

問題

タスク管理 API を FastAPI で 白紙から 実装してください。

main.py には最小スケルトンしかありません。ファイル構造・関数名・内部ロジック・データ構造はすべて自由ですが、下記のインターフェースを満たすこと。

仕様

メモリ上でタスクを管理する API を作る (永続化不要)。各タスクは以下の形式:

{ "id": 1, "title": "牛乳を買う", "done": false }

エンドポイント

メソッドURLリクエストレスポンス説明
GET/tasks-[ { id, title, done }, ... ]全件取得
POST/tasks{ "title": "..." }{ id, title, done } (201)新規追加。id は 1 から連番、done は false
PATCH/tasks/{task_id}{ "done": true } (任意){ id, title, done }既存タスクを更新。存在しなければ 404
DELETE/tasks/{task_id}-204 (ボディなし)削除。存在しなければ 404

補足

  • 初期状態のタスクは空 ([])
  • id は POST のたびに 1 ずつ増える (1, 2, 3, ...)
  • 1ファイル (main.py) で完結させること
  • データ構造はリストでも辞書でもデータベースでも OK (ただし永続化は不要、メモリで OK)

確認方法

  • 起動.bat (Windows) / 起動.sh (Mac) をダブルクリック
  • ブラウザにテスト結果が表示される

自由に設計できる例

  • タスク管理を list[dict] で持つ / dict[int, dict] で持つ / class Task を作る
  • ファイル分割 (例: models.py / db.py ) は禁止main.py 1ファイルで書く
  • 関数名は好きに付けてOK (get_tasks / list_tasks / tasks_index 等)

守る (テストが見ている)

  • GET /tasks を叩くとタスク配列が JSON で返る
  • POST /tasksid が連番で振られる
  • PATCH /tasks/{id}done が更新できる
  • DELETE /tasks/{id} で 204 が返る
  • 存在しない id への PATCH/DELETE は 404

このドリルで学ぶこと

学習要素学習資料との対応
CRUD 4動詞 (GET/POST/PATCH/DELETE) を一つの API で扱うch04 学習目標
next_id グローバル変数で ID を連番採番ch04 slide「ID を連番で振る」
メモリ内 list を共有データとして使うch04 学習目標
HTTPException で 404 / 400 を返すch02/ch04 slide

このドリルは 小さな状態機械の設計 が学習目標。後続のフルスタック演習 (= app-tasks/ch01-router-computed-watch)でバックエンド側を考える土台になります。

ドリル 5: ヒント

ドリル名: 05-build-task-crud

> ⚠️ 自力で考えてから読んでください

段階1: データ保持の方法を決める

タスクをどこに持つ? 答えは「グローバルな変数」が一番シンプル。

tasks: list[dict] = []
next_id: int = 1

段階2: GET /tasks

タスクのリストを return するだけ。

段階3: POST /tasks

  • リクエストボディから title を受け取る → Pydantic の BaseModel で定義する
  • next_id を採番してインクリメント
  • {"id": ..., "title": ..., "done": False} を作って tasks に append + return

段階4: PATCH /tasks/{task_id}

  • task_id で該当タスクを検索
  • 見つからなければ HTTPException(status_code=404)
  • リクエストボディに done があれば更新

段階5: DELETE /tasks/{task_id}

  • 検索 → 見つからなければ 404
  • 見つかれば tasks.remove(task)
  • ステータスコード 204 を返す: @app.delete("/tasks/{task_id}", status_code=204)

ch04-crud 演習解説

⚠️ 自力でドリルを解いてから読んでください

ドリル 1: @app.patch - 既存タスクを更新する — 模範解答

ドリル名: 01-fill-patch

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

tasks: list[dict] = []

@app.get("/tasks")
def get_tasks():
    return tasks

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")

@app.post("/tasks", status_code=201)
async def create_task(request: Request):
    body = await request.json()
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    return new_task

@app.patch("/tasks/{task_id}")
async def update_task(task_id: int, request: Request):
    body = await request.json()
    for t in tasks:
        if t["id"] == task_id:
            t.update({"title": body["title"], "done": body["done"]})
            return t
    raise HTTPException(status_code=404, detail="Task not found")

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    tasks[:] = [t for t in tasks if t["id"] != task_id]

ドリル 1: @app.patch - 既存タスクを更新する — 解説

ドリル名: 01-fill-patch

@app.patch("/tasks/{task_id}")
def update_task(task_id: int, task: Task):
    tasks = load()
    for t in tasks:
        if t["id"] == task_id:
            t.update(task.model_dump())
            save(tasks)
            return t
    raise HTTPException(status_code=404, detail="Task not found")

> ポイント: PATCH は一部のフィールドだけ更新する HTTP メソッド(PUT は全フィールドの置換)。404 は「リソースが見つからない」を表す標準的なステータスコード。

>

> 補足: この実装は task.model_dump() で全フィールドを送る前提になっている。「送ったフィールドだけを更新する」厳密な PATCH を実装するには Optional フィールドと exclude_unset=True が必要になる(応用)。

ドリル 2: GET /tasks/{id} + HTTPException 404 - 1件取得 — 模範解答

ドリル名: 02-add-get-one

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

tasks: list[dict] = []

@app.get("/tasks")
def get_tasks():
    return tasks

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")

@app.post("/tasks", status_code=201)
async def create_task(request: Request):
    body = await request.json()
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    return new_task

@app.patch("/tasks/{task_id}")
async def update_task(task_id: int, request: Request):
    body = await request.json()
    for t in tasks:
        if t["id"] == task_id:
            t.update({"title": body["title"], "done": body["done"]})
            return t
    raise HTTPException(status_code=404, detail="Task not found")

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    tasks[:] = [t for t in tasks if t["id"] != task_id]

ドリル 2: GET /tasks/{id} + HTTPException 404 - 1件取得 — 解説

ドリル名: 02-add-get-one

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    tasks = load()
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")

> ポイント: GET /tasksGET /tasks/{task_id} は別のエンドポイント。FastAPI はパスの形で自動的に振り分ける。raisereturn にしてしまうとエラーにならないので注意。

ドリル 3: クエリパラメータでフィルタ - 検索エンドポイント — 模範解答

ドリル名: 03-add-search

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

tasks: list[dict] = []

@app.get("/tasks")
def get_tasks(q: str | None = None):
    if not q:
        return tasks
    return [t for t in tasks if q.lower() in t["title"].lower()]

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")

@app.post("/tasks", status_code=201)
async def create_task(request: Request):
    body = await request.json()
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    return new_task

@app.patch("/tasks/{task_id}")
async def update_task(task_id: int, request: Request):
    body = await request.json()
    for t in tasks:
        if t["id"] == task_id:
            t.update({"title": body["title"], "done": body["done"]})
            return t
    raise HTTPException(status_code=404, detail="Task not found")

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    tasks[:] = [t for t in tasks if t["id"] != task_id]

ドリル 3: クエリパラメータでフィルタ - 検索エンドポイント — 解説

ドリル名: 03-add-search

@app.get("/tasks")
def get_tasks(q: str | None = None):
    if not q:
        return tasks
    return [t for t in tasks if q.lower() in t["title"].lower()]

ポイント

q: str | None = None — FastAPI はクエリパラメータをそのまま関数の引数として受け取る。None がデフォルトなので省略可能になる。

if not q:None(省略)と ""(空文字)はどちらも falsy。まとめて「全件返す」条件にできる。

q.lower() in t["title"].lower() — 両方を小文字化してから比較することで大文字小文字を無視した部分一致になる。

設計上の選択肢

大文字小文字を区別したい場合: q in t["title"] のまま(.lower() を外す)

完全一致にしたい場合: in== に変える

別解: filter() を使う

return list(filter(lambda t: q.lower() in t["title"].lower(), tasks))

リスト内包表記と同じ結果。好みの問題だが、リスト内包表記の方が Python らしく読みやすい。

チェックポイント(自問自答用)

  • q="" を渡したとき、全件返るか?(not ""True なので全件になる)
  • 日本語の大文字小文字変換は .lower() で問題ないか?(ひらがな・カタカナは .lower() で変化しないので問題なし)

ドリル 4: HTTPException 400 - 入力値のバリデーション — 模範解答

ドリル名: 04-add-validation

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

tasks: list[dict] = []

@app.get("/tasks")
def get_tasks():
    return tasks

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")

@app.post("/tasks", status_code=201)
async def create_task(request: Request):
    body = await request.json()
    if not body.get("title"):
        raise HTTPException(status_code=400, detail="title is required")
    if "done" in body and not isinstance(body["done"], bool):
        raise HTTPException(status_code=400, detail="done must be a boolean")
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    return new_task

@app.patch("/tasks/{task_id}")
async def update_task(task_id: int, request: Request):
    body = await request.json()
    for t in tasks:
        if t["id"] == task_id:
            t.update({"title": body["title"], "done": body["done"]})
            return t
    raise HTTPException(status_code=404, detail="Task not found")

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    tasks[:] = [t for t in tasks if t["id"] != task_id]

ドリル 4: HTTPException 400 - 入力値のバリデーション — 解説

ドリル名: 04-add-validation

@app.post("/tasks", status_code=201)
async def create_task(request: Request):
    body = await request.json()
    if not body.get("title"):
        raise HTTPException(status_code=400, detail="title is required")
    if "done" in body and not isinstance(body["done"], bool):
        raise HTTPException(status_code=400, detail="done must be a boolean")
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    return new_task

ポイント

not body.get("title")None(キーなし)と ""(空文字)の両方が falsy なのでまとめて検査できる。

isinstance(body["done"], bool) — Python の boolint のサブクラス。isinstance(1, int)True だが isinstance(1, bool)False。この順序で確認することで "yes"1 も弾ける。

"done" in bodydone が省略されたリクエストは通す。渡されたときだけ型チェックする。

判断の選択肢(解説)

バリデーションの実装方法は複数ある:

  • if not body.get("title"): ← 今回の実装(簡潔)
  • if "title" not in body or body["title"] == "": ← 明示的で読みやすい
  • Pydantic の validator を使う ← 本格的だが pydantic トピックの知識が必要

どれも正解。「なぜその書き方を選んだか」を説明できることが大事。

ドリル 5: CRUD + HTTPException + 連番ID - タスク管理 API を白紙から — 模範解答

ドリル名: 05-build-task-crud

"""タスク管理 API 模範実装 (1例)。

これは「答えの一例」。実装方法は自由なので、これと違っていてもテストが通ればOK。
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class TaskCreate(BaseModel):
    title: str

class TaskUpdate(BaseModel):
    title: str | None = None
    done: bool | None = None

tasks: list[dict] = []
next_id: int = 1

@app.get("/tasks")
def list_tasks():
    return tasks

@app.post("/tasks", status_code=201)
def create_task(payload: TaskCreate):
    global next_id
    task = {"id": next_id, "title": payload.title, "done": False}
    next_id += 1
    tasks.append(task)
    return task

def _find(task_id: int) -> dict:
    for t in tasks:
        if t["id"] == task_id:
            return t
    raise HTTPException(status_code=404, detail="not found")

@app.patch("/tasks/{task_id}")
def update_task(task_id: int, payload: TaskUpdate):
    task = _find(task_id)
    if payload.title is not None:
        task["title"] = payload.title
    if payload.done is not None:
        task["done"] = payload.done
    return task

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    task = _find(task_id)
    tasks.remove(task)
    return None

ドリル 5: CRUD + HTTPException + 連番ID - タスク管理 API を白紙から — 解説

ドリル名: 05-build-task-crud

ゼロから書く時の思考プロセス

  1. データ構造を決める: list[dict] が最もシンプル。後で DB に置き換えるならクラス化も。
  2. 状態管理: グローバル変数 tasksnext_id。FastAPI の Depends を使う方法もあるがオーバースペック。
  3. 入力バリデーション: Pydantic の BaseModel で型と必須項目を宣言。手動で dict から取ると壊れやすい。
  4. エラーハンドリング: HTTPException で 404 などを返す。
  5. ステータスコード: 作成は 201、削除は 204。デコレータの status_code パラメータで指定。

ファイル分割の判断

このドリルでは「main.py 1ファイル」と縛っている。理由は:

  • 1ドリル = 1つの仕様 → 分割すると採点 (テスト) が複雑になる
  • 分割の練習はもっと大きな演習で

ただし実務では models.py / services.py / routers.py に分けるのが普通。

実装が複数あり得る点

  • データ保持: list[dict] / dict[int, dict] / class TaskRepo
  • ID 採番: グローバル変数 / 関数の attribute / itertools.count()
  • 検索: 線形検索 / dict での O(1) ルックアップ

テストが叩く I/F が同じなら、どれも正解。

力試ししたい方は クイズ形式の演習(任意) もあります。読むだけでも十分です。