メインコンテンツへスキップ
友田 陽大
Pythonバックエンド
Python
FastAPI
バリデーション
型安全
Pydantic
セキュリティ

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

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

公開日
読了時間
23分
著者
友田 陽大
シェア
目次

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

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

この記事のルール: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"int42 に、"2026-06-26"date に変える。
  • ドキュメント化(document):制約とメタデータが、そのまま OpenAPI(/docs)に反映される。
パラメータの種類値はどこから来るかFastAPI の道具
パスパラメータURL パスの一部Path/items/{item_id}item_id
クエリパラメータ?key=valueQuery/items/?q=foo&limit=10
ボディリクエストボディ(JSON)Pydantic モデル / BodyPUT の JSON ペイロード
フォームapplication/x-www-form-urlencodedFormログインフォーム等

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

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


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

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

1.1 まず素の宣言

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

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(...)] です。

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

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

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

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

# (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 という値を許すか」を表すだけで、必須/任意とは独立です。

# 必須クエリ:デフォルト値を書かない。省略すると 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 の一部なので常に必須です(省略はパスが成立しない)。

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(<
# 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 を殴ります。ページングの limitoffset、ID の下限(ge=1)は、ほぼ必ず制約を付けるのが本番の習慣です。境界は「攻撃面」だと考えてください。

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

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

# 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. 複数値・別名・非推奨:listaliasdeprecated

3.1 同じキーを複数回受ける(list[str]

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

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}

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

# 何も来なければ ["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 を使います。

# 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

@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 / descriptiondocs の可読性のためのメタデータです。公開 API なら、主要パラメータには付けておくと仕様書が育ちます。


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

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

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

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=10limit のタイポ)も、エラーにならず素通りする——これは事故の温床です。

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

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 のような余計なパラメータを送ると、こう返ります。

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

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


5. ボディ:複数パラメータ・embedField

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

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

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

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 を期待します。

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 がトップレベルのキーになります。

{
    "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 はクエリと解釈します。

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 を付けると、引数名をキーにして包んだ形を期待します。

@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}
{
    "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 です。Fieldfastapi ではなく pydantic から importします。

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}

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


6. 特殊型:UUID・datetime・Decimal・Enum・frozenset

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

リクエスト/レスポンスの形用途
UUID文字列(標準 UUID 形式)リソースID
datetime.datetimeISO 8601(例 2008-09-15T15:53:00+05:00日時
datetime.dateISO 8601(例 2008-09-15日付
datetime.timeISO 8601(例 14:23:55.003時刻
datetime.timedeltafloat(合計秒)期間
frozensetlist(重複は排除、uniqueItems一意な集合
bytesbinary 形式の文字列バイナリ
Decimalfloat精度が要る数値(金額等)
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。

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)

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

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 型エイリアス)。大規模アプリでは、これらの共有型を app/api/deps.py 等に集約します(プロジェクト構成はこちら)。

7.2 出力も型で固定する(response_model

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

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 のハンドラを差し替えます。

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)は本番運用ガイドに従ってください。検証は「弾く」だけでなく「何が悪いかを安全に伝える」までが仕事です。


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

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

# 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 単体の検証テスト)。


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)、モデル内部の検証は Fieldpydantic から 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 をどう設計すべきか」——その判断から実装・テスト・運用まで、一気通貫で伴走します。 要件整理の段階からでも、お気軽にご相談ください。


参考(公式ドキュメント)

</invoke>
友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

国内大手放送事業者向け社内AIプラットフォーム(FastAPI製パイプラインで外部入力をゼロトラストに検証)

ケーススタディを見る