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

L09 ch02 — JSON ファイル永続化

テクノロジ系 / L09 file-io — ファイル永続化

L09 ch02 — JSON ファイル永続化

メモリ上のデータをファイルに残し、再起動後も使う

ゴール

  • json.dump でファイル書き込み
  • json.load でファイル読み込み
  • API データの永続化パターン

書き込み

import json

tasks = [
    {"id": 1, "title": "買い物", "done": False},
    {"id": 2, "title": "掃除", "done": True},
]

with open("tasks.json", "w", encoding="utf-8") as f:
    json.dump(tasks, f, ensure_ascii=False, indent=2)

読み込み

with open("tasks.json", "r", encoding="utf-8") as f:
    tasks = json.load(f)

print(tasks)
# [{'id': 1, 'title': '買い物', ...}, ...]

ensure_ascii=False が重要

# ❌ デフォルト
json.dump({"name": "太郎"}, f)
# → {"name": "太郎"}

# ✅ 日本語そのまま
json.dump({"name": "太郎"}, f, ensure_ascii=False)
# → {"name": "太郎"}

load/save パターン

DB_FILE = Path("tasks.json")

def load() -> list[dict]:
    if not DB_FILE.exists():
        return []
    return json.loads(DB_FILE.read_text(encoding="utf-8"))

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(
        json.dumps(tasks, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

FastAPI と組み合わせ

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

@app.post("/tasks")
async def create(req: Request):
    body = await req.json()
    tasks = load()
    tasks.append({"id": len(tasks)+1, **body})
    save(tasks)
    return tasks[-1]

  • 同時書き込み競合 → ロックが必要 (本格運用は DB へ)
  • 大きな JSON は読込時にメモリ展開 (1GB+ なら ndjson)
  • ファイル破損対策: 一旦 .tmp に書いて rename

drill

drill/ — JSON 永続化付き CRUD

ハンズオン

ch02-json 演習ガイド

受講生向け演習問題集

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

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

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

タイプ fill (穴埋め問題) — 文法・APIを知っているか

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

タイプ add (機能追加問題) — 既存コードに機能を足せるか

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

タイプ build (ゼロベース実装問題) — 仕様から実装を組み立てられるか

  • ほぼ空の main.py から 自力で全部書く
  • ファイル構造・関数分割・命名・内部ロジック: 完全に自由
  • 守るのはインターフェースだけ: URL・HTTP メソッド・レスポンス JSON 形式
  • 一番ハードだが、実装力が一番つくタイプ

3タイプ共通で守ること

  • ファイル名 (main.py) は変えない
  • tests/ フォルダは触らない (採点ロジック)

このガイドのドリル一覧

#ドリル名タイトルタイプ難易度
101-fill-load-savejson.load / json.dump - ファイル読み書き🔧 穴埋め
202-add-savejson.dump - JSON ファイルへの保存➕ 機能追加
303-add-query-filterJSON 読み込み + クエリパラメータ - ファイル経由のフィルタ➕ 機能追加

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

ドリル 1: json.load / json.dump - ファイル読み書き

ドリル名: 01-fill-load-save

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

問題

___ を3箇所埋めて、JSONファイルへの読み書きを完成させよ。

def load() -> list[dict]:
    if not DB_FILE.___():        # ファイルの存在確認メソッドを埋める
        return []
    return json.___(DB_FILE.read_text(encoding="utf-8"))  # JSON文字列→Pythonオブジェクト変換を埋める

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(
        json.___(tasks, ensure_ascii=False, indent=2),  # Pythonオブジェクト→JSON文字列変換を埋める
        encoding="utf-8"
    )

確認方法

  1. POST /tasks{"title": "牛乳を買う"} を送信する
  2. カレントディレクトリに tasks.json が作成されること
  3. GET /tasks でタスク一覧が返ること

ドリル 1: ヒント

ドリル名: 01-fill-load-save

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

> - pathlib.Path にはファイルが存在するかどうかを確認するメソッドがある。辞書の in チェックのような使い方で、真偽値を返す。

> - json モジュールには文字列とPythonオブジェクトを相互変換する関数が2つある。

> - 文字列 → Python オブジェクト: json.loadssstring の s)

> - Python オブジェクト → 文字列: json.dumpss は同じく string の s)

ドリル 2: json.dump - JSON ファイルへの保存

ドリル名: 02-add-save

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

問題

base/main.py のメモリ実装を、JSONファイル永続化に書き直せ。

仕様:

  • jsonpathlib.Path をインポートする
  • DB_FILE = Path("tasks.json") を定義する
  • load() / save() 関数を追加する
  • 各エンドポイントでメモリ変数 tasks の代わりに load() を使う
  • 変更があるエンドポイントは変更後に save() を呼ぶ
  • global next_id は不要になる。ID採番は len(tasks) + 1 で行う

確認方法

  1. POST /tasks{"title": "牛乳を買う"} を送信する
  2. サーバーを Ctrl+C で停止し、再起動する
  3. GET /tasks でタスクが残っていること

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

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

  • save() 内で tasks.json.bak というバックアップファイルも一緒に書き出して、tasks.json が壊れたとき復旧できるようにする
  • ID 採番を len(tasks) + 1 から「現在保持している最大 ID + 1」に変更し、削除後に追加しても ID が衝突しないようにする
  • load() に簡易ログ (print(f"loaded {len(tasks)} tasks")) を入れて、起動時にどれだけ読み込んだか確認する

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

ドリル 2: ヒント

ドリル名: 02-add-save

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

> - ファイルの先頭に import jsonfrom pathlib import Path を追加する。

> - DB_FILE = Path("tasks.json") をアプリ直下(app = FastAPI() の近く)に定義する。

> - load() / save() の実装は fill-load-save で埋めたものと同じ。

> - 各エンドポイントの書き換えパターンは共通:

> 1. 関数の先頭で tasks = load() を呼ぶ

> 2. リストを変更する

> 3. save(tasks) を呼ぶ

> - global next_id と変数 next_id は削除してよい。ID は len(tasks) + 1 で採番できる。

ドリル 3: JSON 読み込み + クエリパラメータ - ファイル経由のフィルタ

ドリル名: 03-add-query-filter

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

base/main.py は JSONファイルCRUDが動く状態。GET /tasks にクエリパラメータ done を追加せよ。

仕様

リクエストレスポンス
GET /tasks全件返す(変更なし)
GET /tasks?done=truedone: true のタスクだけ返す
GET /tasks?done=falsedone: false のタスクだけ返す
GET /tasks?done=invalid400 Bad Request

FastAPI でのクエリパラメータの受け取り方

パスパラメータと違い、クエリパラメータは関数の引数にデフォルト値付きで書く

@app.get("/tasks")
def get_tasks(done: str | None = None):
    ...
  • done=None のとき → クエリパラメータなし(全件返す)
  • done="true" のとき → 完了タスクのみ
  • done="false" のとき → 未完了タスクのみ
  • done がそれ以外の文字列 → 400

確認方法

  1. タスクをいくつか追加して done が混在した状態にする
  2. GET /tasks → 全件返ること
  3. GET /tasks?done=true → 完了タスクのみ返ること
  4. GET /tasks?done=false → 未完了タスクのみ返ること
  5. GET /tasks?done=yes → 400 が返ること

> Swagger UI では GET /tasksdone パラメータが自動で表示される。

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

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

  • donebool | None 型 (FastAPI が自動で true / false を bool に変換) で受け取り直し、文字列ベースの実装との違いを比較する
  • created_after / created_before のような日付クエリを追加して期間フィルタを実装する (created_at フィールドの追加が必要)
  • sort クエリ (asc / desc) で id 降順・昇順を切り替えられるようにする

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

ドリル 3: ヒント

ドリル名: 03-add-query-filter

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

  • done: str | None = None で「省略可能なクエリパラメータ」を宣言できる
  • クエリパラメータの値は文字列として届く("true" / "false")。bool ではないことに注意
  • 許容する値: None(省略)/ "true" / "false" の3種類。それ以外は raise HTTPException(400, ...)
  • 絞り込みには [t for t in tasks if t["done"] == ...] のリスト内包表記を使う
  • "true"True"false"False への変換は done_value = done == "true" で1行でできる

考えてみよう

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

ch02-json 演習ガイド

受講生向け演習問題集

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

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

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

タイプ fill (穴埋め問題) — 文法・APIを知っているか

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

タイプ add (機能追加問題) — 既存コードに機能を足せるか

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

タイプ build (ゼロベース実装問題) — 仕様から実装を組み立てられるか

  • ほぼ空の main.py から 自力で全部書く
  • ファイル構造・関数分割・命名・内部ロジック: 完全に自由
  • 守るのはインターフェースだけ: URL・HTTP メソッド・レスポンス JSON 形式
  • 一番ハードだが、実装力が一番つくタイプ

3タイプ共通で守ること

  • ファイル名 (main.py) は変えない
  • tests/ フォルダは触らない (採点ロジック)

このガイドのドリル一覧

#ドリル名タイトルタイプ難易度
101-fill-load-savejson.load / json.dump - ファイル読み書き🔧 穴埋め
202-add-savejson.dump - JSON ファイルへの保存➕ 機能追加
303-add-query-filterJSON 読み込み + クエリパラメータ - ファイル経由のフィルタ➕ 機能追加

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

ドリル 1: json.load / json.dump - ファイル読み書き

ドリル名: 01-fill-load-save

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

問題

___ を3箇所埋めて、JSONファイルへの読み書きを完成させよ。

def load() -> list[dict]:
    if not DB_FILE.___():        # ファイルの存在確認メソッドを埋める
        return []
    return json.___(DB_FILE.read_text(encoding="utf-8"))  # JSON文字列→Pythonオブジェクト変換を埋める

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(
        json.___(tasks, ensure_ascii=False, indent=2),  # Pythonオブジェクト→JSON文字列変換を埋める
        encoding="utf-8"
    )

確認方法

  1. POST /tasks{"title": "牛乳を買う"} を送信する
  2. カレントディレクトリに tasks.json が作成されること
  3. GET /tasks でタスク一覧が返ること

ドリル 1: ヒント

ドリル名: 01-fill-load-save

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

> - pathlib.Path にはファイルが存在するかどうかを確認するメソッドがある。辞書の in チェックのような使い方で、真偽値を返す。

> - json モジュールには文字列とPythonオブジェクトを相互変換する関数が2つある。

> - 文字列 → Python オブジェクト: json.loadssstring の s)

> - Python オブジェクト → 文字列: json.dumpss は同じく string の s)

ドリル 2: json.dump - JSON ファイルへの保存

ドリル名: 02-add-save

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

問題

base/main.py のメモリ実装を、JSONファイル永続化に書き直せ。

仕様:

  • jsonpathlib.Path をインポートする
  • DB_FILE = Path("tasks.json") を定義する
  • load() / save() 関数を追加する
  • 各エンドポイントでメモリ変数 tasks の代わりに load() を使う
  • 変更があるエンドポイントは変更後に save() を呼ぶ
  • global next_id は不要になる。ID採番は len(tasks) + 1 で行う

確認方法

  1. POST /tasks{"title": "牛乳を買う"} を送信する
  2. サーバーを Ctrl+C で停止し、再起動する
  3. GET /tasks でタスクが残っていること

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

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

  • save() 内で tasks.json.bak というバックアップファイルも一緒に書き出して、tasks.json が壊れたとき復旧できるようにする
  • ID 採番を len(tasks) + 1 から「現在保持している最大 ID + 1」に変更し、削除後に追加しても ID が衝突しないようにする
  • load() に簡易ログ (print(f"loaded {len(tasks)} tasks")) を入れて、起動時にどれだけ読み込んだか確認する

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

ドリル 2: ヒント

ドリル名: 02-add-save

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

> - ファイルの先頭に import jsonfrom pathlib import Path を追加する。

> - DB_FILE = Path("tasks.json") をアプリ直下(app = FastAPI() の近く)に定義する。

> - load() / save() の実装は fill-load-save で埋めたものと同じ。

> - 各エンドポイントの書き換えパターンは共通:

> 1. 関数の先頭で tasks = load() を呼ぶ

> 2. リストを変更する

> 3. save(tasks) を呼ぶ

> - global next_id と変数 next_id は削除してよい。ID は len(tasks) + 1 で採番できる。

ドリル 3: JSON 読み込み + クエリパラメータ - ファイル経由のフィルタ

ドリル名: 03-add-query-filter

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

base/main.py は JSONファイルCRUDが動く状態。GET /tasks にクエリパラメータ done を追加せよ。

仕様

リクエストレスポンス
GET /tasks全件返す(変更なし)
GET /tasks?done=truedone: true のタスクだけ返す
GET /tasks?done=falsedone: false のタスクだけ返す
GET /tasks?done=invalid400 Bad Request

FastAPI でのクエリパラメータの受け取り方

パスパラメータと違い、クエリパラメータは関数の引数にデフォルト値付きで書く

@app.get("/tasks")
def get_tasks(done: str | None = None):
    ...
  • done=None のとき → クエリパラメータなし(全件返す)
  • done="true" のとき → 完了タスクのみ
  • done="false" のとき → 未完了タスクのみ
  • done がそれ以外の文字列 → 400

確認方法

  1. タスクをいくつか追加して done が混在した状態にする
  2. GET /tasks → 全件返ること
  3. GET /tasks?done=true → 完了タスクのみ返ること
  4. GET /tasks?done=false → 未完了タスクのみ返ること
  5. GET /tasks?done=yes → 400 が返ること

> Swagger UI では GET /tasksdone パラメータが自動で表示される。

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

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

  • donebool | None 型 (FastAPI が自動で true / false を bool に変換) で受け取り直し、文字列ベースの実装との違いを比較する
  • created_after / created_before のような日付クエリを追加して期間フィルタを実装する (created_at フィールドの追加が必要)
  • sort クエリ (asc / desc) で id 降順・昇順を切り替えられるようにする

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

ドリル 3: ヒント

ドリル名: 03-add-query-filter

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

  • done: str | None = None で「省略可能なクエリパラメータ」を宣言できる
  • クエリパラメータの値は文字列として届く("true" / "false")。bool ではないことに注意
  • 許容する値: None(省略)/ "true" / "false" の3種類。それ以外は raise HTTPException(400, ...)
  • 絞り込みには [t for t in tasks if t["done"] == ...] のリスト内包表記を使う
  • "true"True"false"False への変換は done_value = done == "true" で1行でできる

ch02-json 演習解説

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

ドリル 1: json.load / json.dump - ファイル読み書き — 模範解答

ドリル名: 01-fill-load-save

import json
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
DB_FILE = Path("tasks.json")

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

def load() -> list[dict]:
    if not DB_FILE.exists():
        return []
    return json.loads(DB_FILE.read_text(encoding="utf-8"))

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(
        json.dumps(tasks, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

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

@app.post("/tasks", status_code=201)
def create_task(task: Task):
    tasks = load()
    new_task = {"id": len(tasks) + 1, **task.model_dump()}
    tasks.append(new_task)
    save(tasks)
    return new_task

ドリル 1: json.load / json.dump - ファイル読み書き — 解説

ドリル名: 01-fill-load-save

def load() -> list[dict]:
    if not DB_FILE.exists():
        return []
    return json.loads(DB_FILE.read_text(encoding="utf-8"))

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(
        json.dumps(tasks, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

> ポイント: loads / dumps の末尾の sstring の s。

> - loadsload from string(文字列を受け取ってPythonオブジェクトを返す)

> - dumpsdump to string(PythonオブジェクトをJSON文字列に変換する)

>

> ファイルから読むときは read_text() で文字列にしてから loads

> ファイルに書くときは dumps で文字列にしてから write_text() に渡す、

> という2段構えを覚えておくと混乱しない。

ドリル 2: json.dump - JSON ファイルへの保存 — 模範解答

ドリル名: 02-add-save

import json
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

DB_FILE = Path("tasks.json")

def load() -> list[dict]:
    if not DB_FILE.exists():
        return []
    return json.loads(DB_FILE.read_text(encoding="utf-8"))

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8")

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

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in load():
        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()
    tasks = load()
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    save(tasks)
    return new_task

@app.patch("/tasks/{task_id}")
async def update_task(task_id: int, request: Request):
    body = await request.json()
    tasks = load()
    for t in tasks:
        if t["id"] == task_id:
            t.update({"title": body["title"], "done": body["done"]})
            save(tasks)
            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 load() if t["id"] != task_id]
    save(tasks)

ドリル 2: json.dump - JSON ファイルへの保存 — 解説

ドリル名: 02-add-save

import json
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
DB_FILE = Path("tasks.json")

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

def load() -> list[dict]:
    if not DB_FILE.exists():
        return []
    return json.loads(DB_FILE.read_text(encoding="utf-8"))

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(
        json.dumps(tasks, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

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

@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")

@app.post("/tasks", status_code=201)
def create_task(task: Task):
    tasks = load()
    new_task = {"id": len(tasks) + 1, **task.model_dump()}
    tasks.append(new_task)
    save(tasks)
    return new_task

@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")

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

> ポイント: 変更のたびに load → 変更 → save の3ステップを踏む。

> リクエストをまたいでメモリ上の tasks を使い回さないことが永続化の核心。

>

> 補足: global next_id が不要になった理由は、ID を len(tasks) + 1 で採番するようにしたため。

> ただしタスクを削除すると ID が重複する可能性があるため、本格的なアプリでは UUID や

> データベースのオートインクリメントを使うのが一般的。

ドリル 3: JSON 読み込み + クエリパラメータ - ファイル経由のフィルタ — 模範解答

ドリル名: 03-add-query-filter

import json
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

DB_FILE = Path("tasks.json")

def load() -> list[dict]:
    if not DB_FILE.exists():
        return []
    return json.loads(DB_FILE.read_text(encoding="utf-8"))

def save(tasks: list[dict]) -> None:
    DB_FILE.write_text(json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8")

@app.get("/tasks")
def get_tasks(done: str | None = None):
    tasks = load()
    if done is None:
        return tasks
    if done not in ("true", "false"):
        raise HTTPException(status_code=400, detail="done must be 'true' or 'false'")
    done_value = done == "true"
    return [t for t in tasks if t["done"] == done_value]

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in load():
        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()
    tasks = load()
    new_task = {"id": len(tasks) + 1, "title": body["title"], "done": body.get("done", False)}
    tasks.append(new_task)
    save(tasks)
    return new_task

@app.patch("/tasks/{task_id}")
async def update_task(task_id: int, request: Request):
    body = await request.json()
    tasks = load()
    for t in tasks:
        if t["id"] == task_id:
            t.update({"title": body["title"], "done": body["done"]})
            save(tasks)
            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 load() if t["id"] != task_id]
    save(tasks)

ドリル 3: JSON 読み込み + クエリパラメータ - ファイル経由のフィルタ — 解説

ドリル名: 03-add-query-filter

@app.get("/tasks")
def get_tasks(done: str | None = None):
    tasks = load()
    if done is None:
        return tasks
    if done not in ("true", "false"):
        raise HTTPException(status_code=400, detail="done must be 'true' or 'false'")
    done_value = done == "true"
    return [t for t in tasks if t["done"] == done_value]

ポイント

クエリパラメータの宣言def get_tasks(done: str | None = None): と書くだけで FastAPI がURLの ?done=... を受け取る。パスパラメータと違い {} は不要。

done は文字列で届く — URLのクエリ文字列はすべて str として渡される。bool への暗黙変換はない。done == "true"bool に変換する。

バリデーション位置if done not in ("true", "false"): を絞り込み前に置く。None チェックを先に済ませておかないと None not in (...)True になってしまう。

別解

# bool 型のクエリパラメータを FastAPI に任せる方法
@app.get("/tasks")
def get_tasks(done: bool | None = None):
    tasks = load()
    if done is None:
        return tasks
    return [t for t in tasks if t["done"] == done]

done: bool | None = None と書くと FastAPI が "true"/"false"True/False に自動変換し、それ以外は 422 を返す。"invalid" のとき 400 ではなく 422 になるが動作は同じ。どちらの実装を選ぶかは要件次第。

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