「とりあえず受け取って、あとで if で弾けばいい」——APIの入力検証を、そう後回しにした経験はありませんか。item_id が負の数で来た、limit に 99999 が入って 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"を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 の認証・認可はこちら、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を使うときはQueryにdefaultを渡さず、関数引数のデフォルトを使う」と明言しています。これが「単一の真実源」です。
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 を殴ります。ページングのlimit・offset、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. 複数値・別名・非推奨:list・alias・deprecated
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 / description は docs の可読性のためのメタデータです。公開 API なら、主要パラメータには付けておくと仕様書が育ちます。
4. クエリパラメータモデル:未知パラメータを拒否して契約を締める
クエリが増えてくると、limit・offset・order_by・tags …と引数が膨れます。これらを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=10(limit のタイポ)も、エラーにならず素通りする——これは事故の温床です。
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. ボディ:複数パラメータ・embed・Field
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 です。Field は fastapi ではなく 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}
Field は Query/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.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 | 精度が要る数値(金額等) |
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)
limit・offset・検索語のような繰り返し現れるパラメータを、毎回 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)、モデル内部の検証は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) —
Annotated×Query・min_length/max_length/pattern・alias/deprecated/include_in_schema・複数値 - Path Parameters and Numeric Validations(FastAPI) —
Path・ge/gt/le/lt・引数順序とAnnotated - Query Parameter Models(FastAPI) — Pydantic モデルでクエリを受ける・
model_config={"extra": "forbid"} - Body - Multiple Parameters(FastAPI) — 複数ボディ・
Body()・Body(embed=True)・パス/クエリ/ボディの混在 - Body - Fields(FastAPI) —
Field(pydanticから import)でモデル内部を検証 - Extra Data Types(FastAPI) —
UUID/datetime/Decimal/frozenset等の特殊型 - Pydantic data types — Pydantic がサポートするデータ型の一覧 </content>