# FastAPI 入力検証 実践ガイド：クエリ/パス/ボディ/フォームを Annotated で型安全に、外部入力を境界で殺す

> FastAPIでクエリ/パス/ボディ/フォームの宣言とバリデーションを型安全に実装するガイド。公式最新版に忠実なAnnotated×Query/Path/Bodyの制約（min_length・pattern・ge/le・gt/lt）、複数値・alias・deprecated、クエリパラメータモデルとextra=forbid、Body(embed)・Field、UUID/datetime等の特殊型、422の整形、境界バリデーションのテストまで実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, FastAPI, バリデーション, 型安全, Pydantic, セキュリティ
- URL: https://tomodahinata.com/blog/fastapi-request-validation-query-path-body-parameters-guide

## 要点

- 外部入力は信用しない。FastAPIは『型ヒント＋Annotated[T, Query/Path/Body(...)]』の一本で、検証・変換・ドキュメント化を同時に行う。不正な入力は自動的に422で弾かれ、ハンドラに汚れた値は届かない
- Annotatedが現在の正。制約はQuery/Pathの引数で宣言する：文字列はmin_length・max_length・pattern、数値はge/gt/le/lt。必須はデフォルト値を省く、任意はNoneを与える、複数値はlist[str]
- 関連するクエリ群はPydanticモデルに畳む（Annotated[FilterParams, Query()]、0.115.0+）。model_config={'extra':'forbid'}でタイポ・未知パラメータを422で拒否し、APIの契約を締める
- ボディは複数パラメータ・Body(embed=True)・Fieldで設計する。FieldはQuery/Path/Bodyと同じ制約を持ち、モデル内部の検証とドキュメントを担う。UUID/datetime/Decimal/Enum/frozenset等の特殊型も型一本で検証・変換される
- よく使う制約付きパラメータはAnnotated型エイリアスに畳んで再利用（DRY）。response_modelで出力契約、422本文は例外ハンドラで整形。境界値と422を必ずテストする

---

「とりあえず受け取って、あとで `if` で弾けばいい」——APIの入力検証を、そう後回しにした経験はありませんか。`item_id` が負の数で来た、`limit` に `99999` が入って DB が全件を返した、クエリに `?admin=true` という**見覚えのないパラメータ**が紛れ込んでいた——どれも「ハンドラに値が届いてから」気づくのでは遅すぎます。**外部から来た値は、境界で殺す。** これはセキュリティの第一原則であり、FastAPI が最も得意とする領域です。

この記事は、FastAPI で**クエリ・パス・ボディ・フォームのパラメータを型安全に宣言し、検証する**ためのガイドです。FastAPI 公式チュートリアルの該当章を**最新仕様に忠実**に追いながら、公式が教材ゆえに触れない領域——**再利用する `Annotated` 型エイリアス、クエリパラメータモデルで契約を締める、422 の本文整形、境界値のテスト**——まで踏み込みます。題材として、私が国内大手放送事業者向けに構築した社内AIプラットフォーム（[複数の FastAPI サービスをモノレポで束ね、外部から持ち込まれる素材をゼロトラストに検証](/case-studies/broadcaster-ai-content-platform)。マルウェア検査でクリーン／隔離に振り分ける入口を初期から設計）での判断も交えます。

> **この記事のルール**：API・記法は **FastAPI 公式ドキュメント（2026年6月時点）** に基づきます。FastAPI は近年、検証メタデータの宣言を「デフォルト値に `Query()` を置く旧式」から **`Annotated[T, Query(...)]`** へ正式に推奨を切り替え、**クエリパラメータモデル（0.115.0+）**を導入しました。本記事はこの最新版に準拠します。仕様は改定されるため、本番投入前に必ず公式で最新の挙動を確認してください。**シークレットはコードに書かない**（本記事のサンプルにもハードコードはありません）。

---

## 0. まず原則：外部入力は信用しない（型一本で検証・変換・ドキュメント）

実装に入る前に、設計思想を一行に固定します。

> **すべての外部入力は、境界（route handler）で検証・変換・拒否する。ハンドラ本体には『検証済みの型付き値』しか届かせない。**

FastAPI はこの原則を、**Python の型ヒントそのもの**で実現します。`item_id: int` と書けば、文字列で来たリクエストは整数へ**変換**され、変換できなければ**422 で自動的に弾かれます**。そこに `Annotated[int, Path(ge=1)]` と制約を足せば、「1 以上の整数」という契約が型に刻まれます。ここで効いているのは**3つの仕事を1箇所で**やれることです。

- **検証（validate）**：制約を満たさない値を拒否する。
- **変換（convert）**：文字列の `"42"` を `int` の `42` に、`"2026-06-26"` を `date` に変える。
- **ドキュメント化（document）**：制約とメタデータが、そのまま OpenAPI（`/docs`）に反映される。

| パラメータの種類 | 値はどこから来るか | FastAPI の道具 | 例 |
| --- | --- | --- | --- |
| パスパラメータ | URL パスの一部 | `Path` | `/items/{item_id}` の `item_id` |
| クエリパラメータ | `?key=value` | `Query` | `/items/?q=foo&limit=10` |
| ボディ | リクエストボディ（JSON） | Pydantic モデル / `Body` | `PUT` の JSON ペイロード |
| フォーム | `application/x-www-form-urlencoded` | `Form` | ログインフォーム等 |

そして**不正な入力は、例外なく `422 Unprocessable Entity`** で返ります。型が宣言に合わなければハンドラは1行も実行されません——これが「境界で殺す」の正体です。本記事は、この4種を**`Annotated` 一本**で正確に宣言する方法を、順に詰めていきます。

> **なぜ検証はセキュリティなのか**：未検証の入力は、SQL インジェクション・パストラバーサル・リソース枯渇（巨大な `limit`）・列挙攻撃の入口です。「型で潰せる入力」を型で潰しておけば、後段のビジネスロジックは**安全な前提**の上に立てます。認証・認可で「誰か・何を許すか」を守るのと同じ層の話です（[FastAPI の認証・認可はこちら](/blog/fastapi-authentication-oauth2-jwt-security-scopes-production-guide)、[Pydantic v2 での境界バリデーションはこちら](/blog/pydantic-v2-production-validation-type-safety)）。

---

## 1. クエリパラメータと文字列検証：`Annotated` × `Query`

最も頻出するのがクエリパラメータです。`?q=...&limit=...` の検証を見ていきます。

### 1.1 まず素の宣言

関数引数に型ヒントを書くだけで、FastAPI はそれを**クエリパラメータ**として扱います。

```python
from fastapi import FastAPI

app = FastAPI()

@app.get("/items/")
async def read_items(q: str | None = None):
    # q はクエリパラメータ。デフォルトが None なので「任意」。
    # ?q=foo で渡せば文字列、無ければ None。
    results = {"items": [{"item_id": "Foo"}]}
    if q:
        results.update({"q": q})
    return results
```

これだけでも変換は効きます。が、「長さ」「形式」までは縛れません。ここで `Query` を足します。

### 1.2 制約を `Query` で宣言する（`Annotated` が現在の正）

公式が現在**推奨**するのは、デフォルト値に `Query()` を置く旧式ではなく、**`Annotated[T, Query(...)]`** です。

```python
from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/items/")
async def read_items(
    q: Annotated[str | None, Query(min_length=3, max_length=50)] = None,
):
    # 3〜50文字の文字列のみ受理。範囲外は 422 で自動的に弾かれる。
    # デフォルト値（= None）は関数引数側に置く。Query(default=...) は使わない。
    results = {"items": [{"item_id": "Foo"}]}
    if q:
        results.update({"q": q})
    return results
```

文字列に効く制約は3つです。

- **`min_length`** / **`max_length`**：文字数の下限・上限。
- **`pattern`**：正規表現。

```python
# pattern で「許される形」を正規表現で固定する（^...$ で全体一致を強制）。
q: Annotated[str | None, Query(min_length=3, max_length=50, pattern="^fixedquery$")] = None
```

> **`Annotated` を使うときの鉄則**：制約は `Query(...)` の中に、**デフォルト値は関数引数の `= None` 側**に書きます。`Query(default=...)` と関数引数のデフォルトを**二重に書かない**こと。公式も「`Annotated` を使うときは `Query` に `default` を渡さず、関数引数のデフォルトを使う」と明言しています。これが「単一の真実源」です。

### 1.3 必須・任意・「任意だが None 可」を型で区別する

ここは混同しやすいので3パターンに固定します。**デフォルト値の有無**が、必須／任意を決めます。

```python
# (1) 任意（省略可、既定 None）：来れば検証、無ければ None
q: Annotated[str | None, Query(min_length=3)] = None

# (2) 必須（省略不可）：デフォルトを書かない。省略すると 422
q: Annotated[str, Query(min_length=3)]

# (3) 必須だが None も明示で許す：デフォルトを書かない（＝必須）が、型は None も許容
q: Annotated[str | None, Query(min_length=3)]
```

要点は一行です——**デフォルト値を書けば「任意」、書かなければ「必須」**。`| None` は「`None` という値を許すか」を表すだけで、必須／任意とは独立です。

```python
# 必須クエリ：デフォルト値を書かない。省略すると 422。
@app.get("/items/")
async def read_items(q: Annotated[str, Query(min_length=3)]):
    return {"q": q}
```

「必須かどうか」を**型とデフォルトの有無だけ**で表せるのが FastAPI の美点です。`if q is None: raise ...` のような手書きの検証は要りません（その分岐がそもそも到達不能になる）。

---

## 2. 数値・パスパラメータの制約：`ge` / `gt` / `le` / `lt`

### 2.1 パスパラメータと `Path`

URL パスに埋め込まれた値は**パスパラメータ**で、`Path` で検証します。パスパラメータは URL の一部なので**常に必須**です（省略はパスが成立しない）。

```python
from typing import Annotated
from fastapi import FastAPI, Path, Query

app = FastAPI()

@app.get("/items/{item_id}")
async def read_items(
    item_id: Annotated[int, Path(title="取得するアイテムのID", ge=1)],
    q: Annotated[str | None, Query(alias="item-query")] = None,
):
    # item_id は 1 以上の整数のみ。0 や負数、文字列は 422。
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results
```

### 2.2 数値制約は4つ（int も float も同じ）

数値の範囲を縛るのは、頭文字が示すとおりの4つです。

- **`ge`**：greater than or equal（**≧**）
- **`gt`**：greater than（**>**）
- **`le`**：less than or equal（**≦**）
- **`lt`**：less than（**<**）

```python
# 1 ≦ item_id ≦ 1000 の整数。さらに 0 < size < 10.5 の float。
@app.get("/items/{item_id}")
async def read_items(
    item_id: Annotated[int, Path(title="アイテムID", ge=1, le=1000)],
    size: Annotated[float, Query(gt=0, lt=10.5)],
):
    return {"item_id": item_id, "size": size}
```

> **数値の境界は事故が多い**：`limit` に上限（`le=100`）を付けないと、悪意ある（あるいは不注意な）`?limit=999999999` が DB を殴ります。**ページングの `limit`・`offset`、ID の下限（`ge=1`）は、ほぼ必ず制約を付ける**のが本番の習慣です。境界は「攻撃面」だと考えてください。

### 2.3 `Annotated` なら引数の順序を気にしなくてよい

旧式（デフォルト値に `Path()`/`Query()` を置く書き方）では、「デフォルトのある引数はデフォルトの無い引数より後」という Python の制約に縛られ、`*,`（キーワード専用引数）の小細工が要りました。**`Annotated` を使えばこの問題は消えます**——どの順で並べても動きます。

```python
# q（デフォルトなし）の後に item_id を置いても、Annotated なら問題ない。
@app.get("/items/{item_id}")
async def read_items(
    q: str,
    item_id: Annotated[int, Path(title="アイテムID", ge=1)],
):
    return {"item_id": item_id, "q": q}
```

公式も「`*` のトリックは `Annotated` を使うなら、おそらく重要でも必要でもない」と述べています。**`Annotated` に統一すれば、覚えることが1つ減る**——これは可読性の地味な、しかし効く改善です。

---

## 3. 複数値・別名・非推奨：`list`・`alias`・`deprecated`

### 3.1 同じキーを複数回受ける（`list[str]`）

`?tags=a&tags=b` のように**同じキーが複数回**来るクエリは、`list` 型で受けます。

```python
from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[list[str] | None, Query()] = None):
    # ?q=foo&q=bar → q == ["foo", "bar"]。リストには Query() が必要。
    return {"q": q}
```

デフォルトを「空でないリスト」にもできます。

```python
# 何も来なければ ["foo", "bar"] を既定値として使う。
q: Annotated[list[str], Query()] = ["foo", "bar"]
```

> **リストには `Query()` が要る**：`list` 型は、`Query()` を明示しないと FastAPI はボディと解釈しかねません。**複数値クエリは `Annotated[list[str], Query()]` をセットで覚える**のが安全です。

### 3.2 URL 上の名前を変える（`alias`）

`item-query` のように**Python の識別子に使えない名前**（ハイフン入り）を URL で受けたいときは `alias` を使います。

```python
# URL は ?item-query=foo、Python 側の変数は q。
q: Annotated[str | None, Query(alias="item-query")] = None
```

### 3.3 非推奨にする・スキーマから隠す（`deprecated` / `include_in_schema`）

API を**壊さずに退役**させたいとき、`deprecated=True` で docs に「非推奨」を明示できます。内部用パラメータを OpenAPI から隠したいなら `include_in_schema=False`。

```python
@app.get("/items/")
async def read_items(
    q: Annotated[
        str | None,
        Query(
            title="検索クエリ",
            description="アイテムを検索するための文字列",
            alias="item-query",
            min_length=3,
            max_length=50,
            deprecated=True,            # /docs に「非推奨」と表示（まだ動く）
            include_in_schema=False,    # OpenAPI スキーマから除外（docs に出さない）
        ),
    ] = None,
):
    return {"q": q}
```

`title` / `description` は **docs の可読性**のためのメタデータです。公開 API なら、主要パラメータには付けておくと仕様書が育ちます。

---

## 4. クエリパラメータモデル：未知パラメータを拒否して契約を締める

クエリが増えてくると、`limit`・`offset`・`order_by`・`tags` …と引数が膨れます。これらを**Pydantic モデル1つ**に畳めるのが、FastAPI 0.115.0+ の**クエリパラメータモデル**です。

### 4.1 関連クエリを1モデルにまとめる

```python
from typing import Annotated, Literal
from fastapi import FastAPI, Query
from pydantic import BaseModel, Field

app = FastAPI()

class FilterParams(BaseModel):
    # Field でクエリごとの制約を宣言する。第8章で型エイリアスに畳んで再利用する。
    limit: int = Field(100, gt=0, le=100)        # 1〜100、既定 100
    offset: int = Field(0, ge=0)                 # 0 以上、既定 0
    order_by: Literal["created_at", "updated_at"] = "created_at"  # 列挙で固定
    tags: list[str] = []

@app.get("/items/")
async def read_items(filter_query: Annotated[FilterParams, Query()]):
    # filter_query.limit / .offset / .order_by / .tags が検証済みで使える。
    return filter_query
```

これで `?limit=10&offset=20&order_by=updated_at&tags=a&tags=b` が、**1つの型安全なオブジェクト**としてハンドラに届きます。引数の羅列が消え、制約はモデルに凝集します（SRP）。

### 4.2 未知のクエリを `extra: forbid` で拒否する（契約を締める）

ここが本番で効く一手です。既定では、モデルに無いクエリパラメータは**黙って無視**されます。`?admin=true` も `?limt=10`（`limit` のタイポ）も、エラーにならず素通りする——これは事故の温床です。

`model_config = {"extra": "forbid"}` を足すと、**未知のクエリは 422 で拒否**されます。

```python
class FilterParams(BaseModel):
    model_config = {"extra": "forbid"}           # 未知のクエリパラメータを禁止する
    limit: int = Field(100, gt=0, le=100)
    offset: int = Field(0, ge=0)
    order_by: Literal["created_at", "updated_at"] = "created_at"
    tags: list[str] = []
```

`?tool=plumbus` のような余計なパラメータを送ると、こう返ります。

```json
{
    "detail": [
        {
            "type": "extra_forbidden",
            "loc": ["query", "tool"],
            "msg": "Extra inputs are not permitted",
            "input": "plumbus"
        }
    ]
}
```

> **なぜ「拒否」が正しいのか**：未知パラメータを黙殺すると、**クライアント側のタイポ（`?limt=10`）がサーバーに気づかれず、フィルタが効いていないのに 200 が返る**——最悪のサイレント故障です。`extra: forbid` は「契約に無いものは受け取らない」という**明示的な拒絶**で、バグを境界で表面化させます。公開 API・社内 API ともに、検索系エンドポイントでは強く推奨します。放送事業者向けプラットフォームでも、外部から持ち込まれる入力に対しては「想定外は素通しさせず、明示的に弾く」を徹底しました（クリーン／隔離の二択に振り分ける入口設計と同じ思想です）。

---

## 5. ボディ：複数パラメータ・`embed`・`Field`

`POST`/`PUT` の JSON ボディは、**Pydantic モデル**で受けるのが基本です。ここではボディ特有の宣言を詰めます。

### 5.1 パス・クエリ・ボディを混在させる

1つのハンドラで3種を同時に宣言できます。FastAPI が**どれがどこから来るか**を型から判別します。

```python
from typing import Annotated
from fastapi import FastAPI, Path
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None

@app.put("/items/{item_id}")
async def update_item(
    item_id: Annotated[int, Path(title="アイテムID", ge=0, le=1000)],  # パス
    q: str | None = None,                                              # クエリ（単純型）
    item: Item | None = None,                                         # ボディ（モデル）
):
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    if item:
        results.update({"item": item})
    return results
```

判別ルールはシンプルです——**Pydantic モデルはボディ**、**単純型（`str`/`int` 等）はクエリ**、**パスに名前があればパス**。

### 5.2 複数のボディパラメータ

ボディに**複数のモデル**を取ると、FastAPI は**引数名をキーにした JSON** を期待します。

```python
class User(BaseModel):
    username: str
    full_name: str | None = None

@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item, user: User):
    return {"item_id": item_id, "item": item, "user": user}
```

期待されるボディは、引数名 `item` / `user` がトップレベルのキーになります。

```json
{
    "item": {
        "name": "Foo",
        "description": "The pretender",
        "price": 42.0,
        "tax": 3.2
    },
    "user": {
        "username": "dave",
        "full_name": "Dave Grohl"
    }
}
```

### 5.3 単一の値をボディに含める（`Body`）

`int` のような**単純型をクエリではなくボディ**に入れたいときは `Body()` を明示します。これが無いと FastAPI はクエリと解釈します。

```python
from fastapi import Body

@app.put("/items/{item_id}")
async def update_item(
    item_id: int,
    item: Item,
    user: User,
    importance: Annotated[int, Body(gt=0)],   # クエリではなくボディの値として、かつ > 0 を強制
):
    return {"item_id": item_id, "item": item, "user": user, "importance": importance}
```

`Body()` も `Query`/`Path` と同じく `gt`/`ge`/`lt`/`le` などの制約を持てます。

### 5.4 単一モデルをキーで包む（`Body(embed=True)`）

ボディが**モデル1つだけ**のとき、既定では FastAPI はモデルの中身を**そのまま（フラットに）**期待します。`embed=True` を付けると、引数名をキーにして**包んだ**形を期待します。

```python
@app.put("/items/{item_id}")
async def update_item(
    item_id: int,
    item: Annotated[Item, Body(embed=True)],   # item を "item" キーで包む
):
    return {"item_id": item_id, "item": item}
```

```json
{
    "item": {
        "name": "Foo",
        "description": "The pretender",
        "price": 42.0,
        "tax": 3.2
    }
}
```

> **`embed=True` は将来の拡張に効く**：今は単一モデルでも、後で `user` を足して複数ボディにする可能性があるなら、最初から `embed=True` でキー付きにしておくと**ボディの形が一貫**し、クライアントの破壊的変更を避けられます（ETC）。「単一だからフラット」を選ぶか「将来を見越して包む」かは、API の安定性ポリシー次第です。

### 5.5 モデル内部の検証は `Field`（Pydantic から import）

モデルの**各属性**に制約・メタデータを付けるのが `Field` です。**`Field` は `fastapi` ではなく `pydantic` から import**します。

```python
from typing import Annotated
from fastapi import Body, FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str | None = Field(
        default=None, title="アイテムの説明", max_length=300   # 最大300文字
    )
    price: float = Field(gt=0, description="価格はゼロより大きいこと")  # > 0 を強制
    tax: float | None = None

@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Annotated[Item, Body(embed=True)]):
    return {"item_id": item_id, "item": item}
```

`Field` は `Query`/`Path`/`Body` と**同じ仕組み・同じ制約**を持ちます（内部的には同じ `FieldInfo`）。つまり、**「パラメータの検証は `Query`/`Path`/`Body`、モデル内部の検証は `Field`」**と覚えれば、制約の語彙（`gt`/`max_length`/`title`/`description`…）はすべて共通です。検証ロジックを**境界（route）にもモデル（schema）にも一貫して**置けるのが FastAPI + Pydantic の強さです（[Pydantic v2 の検証設計はこちら](/blog/pydantic-v2-production-validation-type-safety)）。

---

## 6. 特殊型：UUID・datetime・Decimal・Enum・frozenset

基本型（`int`/`float`/`str`/`bool`）以外にも、FastAPI は**多くの標準型を型一本で検証・変換**します。文字列のパースを手書きする必要はありません。

| 型 | リクエスト/レスポンスの形 | 用途 |
| --- | --- | --- |
| `UUID` | 文字列（標準 UUID 形式） | リソースID |
| `datetime.datetime` | ISO 8601（例 `2008-09-15T15:53:00+05:00`） | 日時 |
| `datetime.date` | ISO 8601（例 `2008-09-15`） | 日付 |
| `datetime.time` | ISO 8601（例 `14:23:55.003`） | 時刻 |
| `datetime.timedelta` | `float`（合計秒） | 期間 |
| `frozenset` | `list`（重複は排除、`uniqueItems`） | 一意な集合 |
| `bytes` | `binary` 形式の文字列 | バイナリ |
| `Decimal` | `float` | 精度が要る数値（金額等） |

```python
from datetime import datetime, time, timedelta
from typing import Annotated
from uuid import UUID
from fastapi import Body, FastAPI

app = FastAPI()

@app.put("/items/{item_id}")
async def read_items(
    item_id: UUID,                                          # パスを UUID として検証・変換
    start_datetime: Annotated[datetime, Body()],           # ISO 8601 → datetime
    end_datetime: Annotated[datetime, Body()],
    process_after: Annotated[timedelta, Body()],           # 秒数 → timedelta
    repeat_at: Annotated[time | None, Body()] = None,
):
    # ここに来た時点で全部「検証済みの Python オブジェクト」。日時の演算がそのまま書ける。
    start_process = start_datetime + process_after
    duration = end_datetime - start_process
    return {
        "item_id": item_id,
        "start_process": start_process,
        "duration": duration,
    }
```

### 6.1 Enum で「許される値」を型にする

「ステータスは `draft`/`published`/`archived` のいずれか」のような**閉じた集合**は、`Enum`（または `Literal`）で表すのが定石です。範囲外は自動的に 422。

```python
from enum import Enum
from fastapi import FastAPI

app = FastAPI()

class Status(str, Enum):
    draft = "draft"
    published = "published"
    archived = "archived"

@app.get("/items/")
async def read_items(status: Status = Status.draft):
    # ?status=foo は 422。?status=published は Status.published に変換される。
    return {"status": status}
```

> **特殊型は「変換」まで面倒を見てくれる**：`Decimal` を使えば、金額を `float` の丸め誤差から守れます。`datetime` を使えば、`"2026-06-26T..."` を**自分で `strptime` する必要が消えます**。型を正しく選ぶだけで、パース・検証・ドキュメント・OpenAPI スキーマがすべて揃う——これが「型一本」の見返りです。

---

## 7. 応用：再利用する `Annotated` 型エイリアス・ページング・422 整形

ここからが公式チュートリアルの**その先**。検証を「散らかさず・再利用し・きれいに返す」ための実務です。

### 7.1 よく使う制約付きパラメータを型エイリアスに畳む（DRY）

`limit`・`offset`・検索語のような**繰り返し現れるパラメータ**を、毎回 `Annotated[int, Query(gt=0, le=100)]` と書くのは DRY 違反です。**`Annotated` 型を名前付きエイリアスに畳んで**、全エンドポイントで再利用します。

```python
from typing import Annotated
from fastapi import Query

# ページングと検索語の「契約」を1箇所に定義する（単一の真実源）。
Offset = Annotated[int, Query(ge=0, description="先頭から読み飛ばす件数")]
Limit = Annotated[int, Query(gt=0, le=100, description="取得する最大件数（上限100）")]
SearchQuery = Annotated[str | None, Query(min_length=2, max_length=50)]

@app.get("/items/")
async def list_items(q: SearchQuery = None, offset: Offset = 0, limit: Limit = 100):
    # 制約はエイリアスに集約。各ハンドラは名前を書くだけ。上限変更も1箇所で済む。
    return {"q": q, "offset": offset, "limit": limit}

@app.get("/users/")
async def list_users(offset: Offset = 0, limit: Limit = 100):
    # 同じページング契約を、users でもそのまま再利用する。
    return {"offset": offset, "limit": limit}
```

「`limit` の上限を 100→200 に変えたい」が**1箇所の修正**で全エンドポイントに波及します。これは認証ガイドで `CurrentUser = Annotated[User, Depends(...)]` を畳んだのと**同じパターン**です（→[認証の Annotated 型エイリアス](/blog/fastapi-authentication-oauth2-jwt-security-scopes-production-guide)）。大規模アプリでは、これらの共有型を `app/api/deps.py` 等に集約します（[プロジェクト構成はこちら](/blog/fastapi-project-structure-apirouter-dependencies-large-app-guide)）。

### 7.2 出力も型で固定する（`response_model`）

入力を境界で殺したら、**出力も契約で締めます**。`response_model` を指定すると、ハンドラの戻り値が**そのスキーマに整形・検証**され、宣言に無いフィールドは**レスポンスから除外**されます——内部用フィールドや PII の漏洩を型で防げます。

```python
from pydantic import BaseModel

class ItemPublic(BaseModel):
    name: str
    price: float
    # internal_cost はあえて含めない → レスポンスに絶対出ない（型による情報遮断）

@app.get("/items/{item_id}", response_model=ItemPublic)
async def read_item(item_id: int) -> ItemPublic:
    # DB から内部フィールド付きで取得しても、ItemPublic に無いものは返らない。
    ...
```

入力（`Query`/`Path`/`Body`/`Field`）と出力（`response_model`）の**両端を型で締める**——これで「受け取る形」と「返す形」が API の契約として明文化されます。

### 7.3 422 の本文を整形する（クライアントに優しいエラー）

FastAPI の既定の 422 は配列形式（`detail: [...]`）で、機械的には十分ですが、フロントで扱いやすい形に**統一**したいことがあります。`RequestValidationError` のハンドラを差し替えます。

```python
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    # 検証エラーを自社フロントの規約に合わせた一定の形へ整える。
    # exc.errors() に loc/msg/type が入る。生メッセージは出し過ぎない（情報漏洩を避ける）。
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={
            "error": "validation_error",
            "fields": [
                {"location": ".".join(str(p) for p in e["loc"]), "message": e["msg"]}
                for e in exc.errors()
            ],
        },
    )
```

> **エラー本文に内部情報を載せない**：`exc.errors()` には入力値そのもの（`input`）が含まれることがあります。**機密値が混ざるエンドポイントでは、`input` をそのまま返さない**配慮が要ります。エラー設計全体（例外ハンドラの一元化・ロギング・相関ID）は[本番運用ガイド](/blog/fastapi-production-async-pydantic-observability-guide)に従ってください。検証は「弾く」だけでなく「**何が悪いかを安全に伝える**」までが仕事です。

---

## 8. テスト：境界値と 422 を必ず検証する

検証パスのない本番投入はありません。入力検証は**境界値（boundary）と異常系（422）**を必ずテストします。ここを書かないと、「制約を付けたつもりで効いていない」事故を見逃します。

```python
# tests/test_validation.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

# 1) パスの数値制約：ge=1。0 や負数・文字列は 422、境界の 1 は 200。
def test_item_id_lower_bound():
    assert client.get("/items/1").status_code == 200       # 境界（OK）
    assert client.get("/items/0").status_code == 422       # 下限割れ
    assert client.get("/items/-3").status_code == 422
    assert client.get("/items/abc").status_code == 422     # 型変換失敗

# 2) クエリの文字列長：min_length=3。2文字は 422、3文字は 200。
def test_query_min_length():
    assert client.get("/items/?q=ab").status_code == 422   # 短すぎ
    assert client.get("/items/?q=abc").status_code == 200  # 境界（OK）

# 3) limit の上限：le=100。101 は 422、100 は 200。
def test_limit_upper_bound():
    assert client.get("/list/?limit=100").status_code == 200
    assert client.get("/list/?limit=101").status_code == 422

# 4) クエリモデルの extra=forbid：未知パラメータは 422 で拒否される。
def test_unknown_query_is_rejected():
    res = client.get("/items/?limit=10&unknown=x")
    assert res.status_code == 422
    assert res.json()["detail"][0]["type"] == "extra_forbidden"

# 5) 正常系：境界の内側はちゃんと通り、変換された値が返る。
def test_happy_path():
    res = client.get("/items/?q=hello&limit=10")
    assert res.status_code == 200
```

テストの勘所は**「境界の両側」**です。`ge=1` なら **0（落ちる）と 1（通る）**、`le=100` なら **100（通る）と 101（落ちる）**——制約の**ちょうど際**を突くことで、`<` と `<=` の取り違えのような典型バグを捕まえられます。`extra: forbid` のような「拒否されるべき入力」も、**拒否されることを明示的にテスト**します（黙殺されていないことの証明）。

> **検証の二段構え**：route handler レベルの検証（本記事）は Playwright/TestClient で 422 を確認し、`Field`/`field_validator` のような**モデル内部の純粋ロジック**は Pydantic モデル単体の vitest 的ユニットテストで検証する——**検証ロジックは置いた層でテストする**のが原則です（[Pydantic 単体の検証テスト](/blog/pydantic-v2-production-validation-type-safety)）。

---

## 9. まとめ：FastAPI 入力検証チートシート

迷ったときの早見表です。

- **原則**：外部入力は境界で殺す。FastAPI は**型ヒント＋`Annotated[T, Query/Path/Body(...)]`**で、検証・変換・ドキュメント化を同時に行う。不正な入力は自動的に **422**。
- **記法**：**`Annotated` が現在の正**。制約は `Query(...)` 等の中に、デフォルト値は関数引数側に置く（二重に書かない）。`Annotated` なら引数順序の小細工（`*`）は不要。
- **クエリ文字列**：`min_length` / `max_length` / `pattern`。必須はデフォルトを省く、任意は `= None`、複数値は `Annotated[list[str], Query()]`。
- **数値・パス**：`ge`/`gt`/`le`/`lt`（int も float も同じ）。**`limit` の上限・ID の下限は必ず縛る**（攻撃面）。
- **別名・退役**：`alias`（URL 上の名前）、`deprecated`（docs に非推奨表示）、`include_in_schema=False`（スキーマから除外）。
- **クエリモデル**：関連クエリは Pydantic モデルに畳む（`Annotated[FilterParams, Query()]`、0.115.0+）。**`model_config = {"extra": "forbid"}` で未知パラメータ・タイポを 422 拒否**し、契約を締める。
- **ボディ**：Pydantic モデルで受ける。複数ボディは引数名がキー、単一値は `Body()`、単一モデルを包むなら `Body(embed=True)`、モデル内部の検証は **`Field`（`pydantic` から import）**。
- **特殊型**：`UUID`/`datetime`/`date`/`time`/`timedelta`/`Decimal`/`frozenset`/`Enum` は型一本で検証・変換。`strptime` を手書きしない。
- **応用**：よく使う制約は **`Annotated` 型エイリアス**に畳んで再利用（DRY）。出力は `response_model` で締める。422 は例外ハンドラで整形（`input` を出し過ぎない）。
- **テスト**：**境界の両側**（0/1、100/101）と **422（含む `extra_forbidden`）** を必ず検証する。

---

FastAPI は「型ヒントを書くだけで検証が付く」フレームワークですが、本番品質は**境界の設計**で決まります。**入力の形を型に刻み、範囲を制約で縛り、未知のパラメータを拒否し、出力も契約で締め、境界値をテストする**——どれも派手ではありませんが、この積み重ねが「**汚れた値がビジネスロジックに一切届かない API**」を作ります。検証は後段の全コードを**安全な前提**の上に立たせる、最もコスパの高い投資です。

私は放送事業者向けの社内AIプラットフォームで、**複数の FastAPI サービスをモノレポで束ね、外部から持ち込まれる素材をゼロトラストに検証する入口**（クリーン／隔離に振り分ける設計）を初期から組み込みました。生成AI（Claude Code）を相棒に、**一人で速く・安く**作りつつ、境界バリデーションとテストで品質を担保するのが私の進め方です。

**「FastAPI のこの API、入力検証が手書きの `if` で散らかっている」「クエリの契約をどう締め、422 をどう設計すべきか」——その判断から実装・テスト・運用まで、一気通貫で伴走します。** 要件整理の段階からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [Query Parameters and String Validations（FastAPI）](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/) — `Annotated` × `Query`・`min_length`/`max_length`/`pattern`・`alias`/`deprecated`/`include_in_schema`・複数値
- [Path Parameters and Numeric Validations（FastAPI）](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/) — `Path`・`ge`/`gt`/`le`/`lt`・引数順序と `Annotated`
- [Query Parameter Models（FastAPI）](https://fastapi.tiangolo.com/tutorial/query-param-models/) — Pydantic モデルでクエリを受ける・`model_config={"extra": "forbid"}`
- [Body - Multiple Parameters（FastAPI）](https://fastapi.tiangolo.com/tutorial/body-multiple-params/) — 複数ボディ・`Body()`・`Body(embed=True)`・パス/クエリ/ボディの混在
- [Body - Fields（FastAPI）](https://fastapi.tiangolo.com/tutorial/body-fields/) — `Field`（`pydantic` から import）でモデル内部を検証
- [Extra Data Types（FastAPI）](https://fastapi.tiangolo.com/tutorial/extra-data-types/) — `UUID`/`datetime`/`Decimal`/`frozenset` 等の特殊型
- [Pydantic data types](https://docs.pydantic.dev/latest/concepts/types/) — Pydantic がサポートするデータ型の一覧
</content>
</invoke>
