Chuyển đến nội dung chính
友田 陽大
Python backend
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の整形、境界バリデーションのテストまで実コードで解説します。

Published
Reading time
23 min read
Author
友田 陽大
Chia sẻ
Mục lục

「とりあえず受け取って、あとで 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 をどう設計すべきか」——その判断から実装・テスト・運用まで、一気通貫で伴走します。 要件整理の段階からでも、お気軽にご相談ください。


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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc