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 操作
| 操作 | HTTP | URL | 意味 |
|---|---|---|---|
| Create | POST | /tasks | 新規作成 |
| Read | GET | /tasks / /tasks/{id} | 取得 |
| Update | PATCH | /tasks/{id} | 部分更新 |
| Delete | DELETE | /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_taskUpdate (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_taskid = next_id→next_id += 1の2 ステップが採番のレシピglobal next_idを忘れるとUnboundLocalErrorになる- サーバー再起動で
next_idは 1 に戻る (永続化は次章)
採番方式の選択肢
| 方式 | 書き方 | 特徴 |
|---|---|---|
next_id (推奨) | id = next_id; next_id += 1 | 単純・確実・本章で使う方式 |
max(id) + 1 | id = max([t["id"] for t in tasks], default=0) + 1 | リストが空でも壊れない書き方が必要 |
len(tasks) + 1 | id = len(tasks) + 1 | DELETE すると 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等) は基本そのまま
このガイドのドリル一覧
| # | ドリル名 | タイトル | タイプ | 難易度 |
|---|---|---|---|---|
| 1 | 01-fill-patch | @app.patch - 既存タスクを更新する | 🔧 穴埋め | ★ |
| 2 | 02-add-get-one | GET /tasks/{id} + HTTPException 404 - 1件取得 | ➕ 機能追加 | ★ |
| 3 | 03-add-search | クエリパラメータでフィルタ - 検索エンドポイント | ➕ 機能追加 | ★ |
| 4 | 04-add-validation | HTTPException 400 - 入力値のバリデーション | ➕ 機能追加 | ★ |
| 5 | 05-build-task-crud | CRUD + 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")確認方法
POST /tasksでタスクを1件追加するPATCH /tasks/1→{"title": "買い物", "done": true}を送信- レスポンスの
doneがtrueになること 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を返す
確認方法
POST /tasksでタスクを1件追加するGET /tasks/1→ 追加したタスクが返ること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 を追加して、タイトルの部分一致検索ができるようにせよ。
やること
GET /tasks?q=買い物のようにqを渡すと、titleにqを含むタスクだけ返すqを省略した場合は全件返す(既存の動作を壊さない)
仕様
- パラメータ名:
q(省略可能、デフォルトNone) - 大文字・小文字は区別しない(
"Buy"で"buy milk"もヒットする) qが空文字""のときは全件返す(省略と同じ扱い)- 一致するタスクが0件のときは
[]を返す(エラーにしない)
確認方法
POST /tasksで{"title": "買い物"}{"title": "掃除"}{"title": "Buy milk"}を登録GET /tasks?q=買い→[{"id":1,"title":"買い物",...}]が返ることGET /tasks?q=buy→[{"id":3,"title":"Buy milk",...}]が返ること(大文字小文字無視)GET /tasks?q=xyz→[]が返ることGET /tasks→ 全3件が返ること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) qがNoneまたは""のときは全件返せばよい。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 にバリデーションを追加せよ。
追加するバリデーション
titleが存在しない、または空文字のとき400 Bad Requestを返すdoneが渡されたとき、bool以外の型(例: 文字列"yes"や整数1)のとき400 Bad Requestを返す- 上記に引っかからない場合は通常通り 201 を返す
仕様
- エラー時のレスポンス例:
{"detail": "title is required"} doneの型チェックにはisinstance()を使うjson.loads()は使わない(request.json()のまま)
確認方法
POST /tasksに{}を送る → 400 と"title is required"が返ることPOST /tasksに{"title": ""}を送る → 400 が返ることPOST /tasksに{"title": "買い物", "done": "yes"}を送る → 400 が返ることPOST /tasksに{"title": "買い物", "done": true}を送る → 201 が返ること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 ではboolはintのサブクラス。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.py1ファイルで書く - 関数名は好きに付けてOK (
get_tasks/list_tasks/tasks_index等)
守る (テストが見ている)
GET /tasksを叩くとタスク配列が JSON で返るPOST /tasksでidが連番で振られる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等) は基本そのまま
このガイドのドリル一覧
| # | ドリル名 | タイトル | タイプ | 難易度 |
|---|---|---|---|---|
| 1 | 01-fill-patch | @app.patch - 既存タスクを更新する | 🔧 穴埋め | ★ |
| 2 | 02-add-get-one | GET /tasks/{id} + HTTPException 404 - 1件取得 | ➕ 機能追加 | ★ |
| 3 | 03-add-search | クエリパラメータでフィルタ - 検索エンドポイント | ➕ 機能追加 | ★ |
| 4 | 04-add-validation | HTTPException 400 - 入力値のバリデーション | ➕ 機能追加 | ★ |
| 5 | 05-build-task-crud | CRUD + 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")確認方法
POST /tasksでタスクを1件追加するPATCH /tasks/1→{"title": "買い物", "done": true}を送信- レスポンスの
doneがtrueになること 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を返す
確認方法
POST /tasksでタスクを1件追加するGET /tasks/1→ 追加したタスクが返ること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 を追加して、タイトルの部分一致検索ができるようにせよ。
やること
GET /tasks?q=買い物のようにqを渡すと、titleにqを含むタスクだけ返すqを省略した場合は全件返す(既存の動作を壊さない)
仕様
- パラメータ名:
q(省略可能、デフォルトNone) - 大文字・小文字は区別しない(
"Buy"で"buy milk"もヒットする) qが空文字""のときは全件返す(省略と同じ扱い)- 一致するタスクが0件のときは
[]を返す(エラーにしない)
確認方法
POST /tasksで{"title": "買い物"}{"title": "掃除"}{"title": "Buy milk"}を登録GET /tasks?q=買い→[{"id":1,"title":"買い物",...}]が返ることGET /tasks?q=buy→[{"id":3,"title":"Buy milk",...}]が返ること(大文字小文字無視)GET /tasks?q=xyz→[]が返ることGET /tasks→ 全3件が返ること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) qがNoneまたは""のときは全件返せばよい。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 にバリデーションを追加せよ。
追加するバリデーション
titleが存在しない、または空文字のとき400 Bad Requestを返すdoneが渡されたとき、bool以外の型(例: 文字列"yes"や整数1)のとき400 Bad Requestを返す- 上記に引っかからない場合は通常通り 201 を返す
仕様
- エラー時のレスポンス例:
{"detail": "title is required"} doneの型チェックにはisinstance()を使うjson.loads()は使わない(request.json()のまま)
確認方法
POST /tasksに{}を送る → 400 と"title is required"が返ることPOST /tasksに{"title": ""}を送る → 400 が返ることPOST /tasksに{"title": "買い物", "done": "yes"}を送る → 400 が返ることPOST /tasksに{"title": "買い物", "done": true}を送る → 201 が返ること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 ではboolはintのサブクラス。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.py1ファイルで書く - 関数名は好きに付けてOK (
get_tasks/list_tasks/tasks_index等)
守る (テストが見ている)
GET /tasksを叩くとタスク配列が JSON で返るPOST /tasksでidが連番で振られる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 /tasks と GET /tasks/{task_id} は別のエンドポイント。FastAPI はパスの形で自動的に振り分ける。raise を return にしてしまうとエラーにならないので注意。
ドリル 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 の bool は int のサブクラス。isinstance(1, int) は True だが isinstance(1, bool) は False。この順序で確認することで "yes" も 1 も弾ける。
"done" in body — done が省略されたリクエストは通す。渡されたときだけ型チェックする。
判断の選択肢(解説)
バリデーションの実装方法は複数ある:
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
ゼロから書く時の思考プロセス
- データ構造を決める: list[dict] が最もシンプル。後で DB に置き換えるならクラス化も。
- 状態管理: グローバル変数
tasksとnext_id。FastAPI の Depends を使う方法もあるがオーバースペック。 - 入力バリデーション: Pydantic の
BaseModelで型と必須項目を宣言。手動でdictから取ると壊れやすい。 - エラーハンドリング:
HTTPExceptionで 404 などを返す。 - ステータスコード: 作成は 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 が同じなら、どれも正解。