Phase 1 Comprehensive Exercise — Building the PriceTracker Basic API
フェーズ1の5つのレッスンで扱った概念は5つのパズルのピースのようなものです。今こそそれらを組み合わせて1つの完全な絵 — PriceTracker 基本 API — を完成させ, Bob のフロントエンドが実際にデータを利用できるようにしましょう。
1. 学ぶ内容
- 3つの主要エンドポイント群の設計と実装:
/products,/products/{id},/prices - 完全な Pydantic V2 モデルシステム:
ProductCreate/ProductResponse/PriceCreate/PriceResponse - Path Parameters, Query Parameters, Request Body の組み合わせの実践例
response_modelを使用した「公開クエリで卸売価格を非表示」のビジネスロジックの実装- Bob (フロントエンド統合):OpenAPI ドキュメントがフロントエンド SDK で自動利用可能であることを確認
2. Alice のリアルストーリー
(1) ペインポイント:断片的な知識は製品に組み上げられない
Alice は最初の5つのレッスンを終えましたが, 各レッスンのサンプルは独立したスニペットであり, Path Parameters と Pydantic のサンプルは繋がっていません。Bob は待ちきれずに言いました。「必要なのは動く API であって, 散らかったデモの山じゃない!」Alice はルーティング設計, パラメータバリデーション, データバリデーション, レスポンスフィルタリングを1つの機能する API サービスに統合する必要があります。
(2) 実際の問題に対する総合的な解決策
このレッスンでは, 最初の5つのレッスンで扱ったすべての概念を PriceTracker 基本 API に統合します。これには完全な CRUD エンドポイント群, Pydantic モデルシステム, パラメータバリデーションチェーン, レスポンスフィルタリングロジックが含まれます。
PYTHON
# 完全な PriceTracker 基本 API 構造
from fastapi import FastAPI, Path, Query, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="PriceTracker API", version="0.1.0")
# モデル, ルート, バリデーション — すべて統合済み
(3) 成果
Alice は動く API サービスを持ち, Bob は Swagger UI ですべてのエンドポイントをテストできます。自動生成された OpenAPI ドキュメントにより, フロントエンド SDK が API を直接利用可能です。
3. API エンドポイントの総合設計
(1) フェーズ1 エンドポイント計画
flowchart LR
Client[Client] -->|GET /products| List[List Products]
Client -->|POST /products| Create[Create Product]
Client -->|GET /products/id| Detail[Product Detail]
Client -->|PUT /products/id| Update[Update Product]
Client -->|DELETE /products/id| Delete[Delete Product]
Client -->|GET /prices| Search[Search Prices]
Client -->|POST /prices| AddPrice[Add Price]
List --> PM[ProductResponse]
Create --> PM
Detail --> PDP[ProductDetailResponse]
Search --> PRM[PriceResponse]
AddPrice --> PRM
| エンドポイント | メソッド | Path Parameters | Query Parameters | Request Body | レスポンスモデル |
|---|---|---|---|---|---|
| 商品一覧 | GET | - | category, sort, limit, offset | - | list[ProductResponse] |
| 商品作成 | POST | - | - | ProductCreate |
ProductResponse |
| 商品詳細 | GET | product_id | - | - | ProductDetailResponse |
| 商品更新 | PUT | product_id | - | ProductUpdate |
ProductResponse |
| 商品削除 | DELETE | product_id | - | - | dict |
| 価格検索 | GET | - | product_id, min_price, max_price | - | list[PriceResponse] |
| 価格追加 | POST | - | - | PriceCreate |
PriceResponse |
(1) ▶ サンプル:完全な Pydantic モデルシステム
PYTHON
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Optional
from enum import Enum
from datetime import datetime
class Category(str, Enum):
electronics = "electronics"
clothing = "clothing"
food = "food"
books = "books"
class PriceInfo(BaseModel):
amount: float = Field(gt=0, description="価格金額 (USD)")
currency: str = Field(default="USD", pattern=r"^[A-Z]{3}$")
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
category: Category
base_price: float = Field(gt=0, description="Base price in USD")
description: Optional[str] = Field(None, max_length=2000)
class ProductUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
category: Optional[Category] = None
base_price: Optional[float] = Field(None, gt=0)
description: Optional[str] = Field(None, max_length=2000)
class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
category: str
base_price: float
class ProductDetailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
category: str
base_price: float
description: Optional[str] = None
created_at: datetime
class PriceCreate(BaseModel):
product_id: int = Field(gt=0)
price: float = Field(gt=0, description="Price in USD")
currency: str = Field(default="USD", pattern=r"^[A-Z]{3}$")
source: str = Field(max_length=100)
@field_validator("price")
@classmethod
def round_price(cls, v: float) -> float:
return round(v, 2)
class PriceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
price: float
currency: str
source: str
recorded_at: datetime
出力:
TEXT
# 関数定義成功
4. リクエスト・レスポンスライフサイクル
(1) 完全なワークフロー
sequenceDiagram
participant Client as Bob Frontend
participant FastAPI as FastAPI Router
participant Pydantic as Pydantic Validator
participant Handler as View Function
participant DB as In-Memory DB
Client->>FastAPI: POST /products (JSON body)
FastAPI->>Pydantic: Validate with ProductCreate
Pydantic-->>FastAPI: Validated model instance
FastAPI->>Handler: create_product(data: ProductCreate)
Handler->>DB: Store product
DB-->>Handler: Stored record
Handler-->>FastAPI: Return full dict
FastAPI->>Pydantic: Filter with ProductResponse
Pydantic-->>Client: JSON response (filtered)
(1) ▶ サンプル:商品 CRUD エンドポイントの実装
PYTHON
from fastapi import FastAPI, Path, Query, HTTPException
from datetime import datetime
app = FastAPI(title="PriceTracker API", version="0.1.0")
# インメモリストレージ (フェーズ2で DB に置き換え)
products_db: dict[int, dict] = {}
prices_db: list[dict] = []
_product_counter = 0
_price_counter = 0
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: ProductCreate):
global _product_counter
_product_counter += 1
record = {
"id": _product_counter,
"name": product.name,
"category": product.category.value,
"base_price": product.base_price,
"description": product.description,
"created_at": datetime.utcnow(),
}
products_db[_product_counter] = record
return record
@app.get("/products", response_model=list[ProductResponse])
async def list_products(
category: Optional[Category] = Query(None),
sort: str = Query("name", pattern="^(name|base_price)$"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
items = list(products_db.values())
if category:
items = [p for p in items if p["category"] == category.value]
items.sort(key=lambda p: p.get(sort, ""))
return items[offset : offset + limit]
@app.get("/products/{product_id}", response_model=ProductDetailResponse)
async def get_product(
product_id: int = Path(gt=0, description="商品 ID"),
):
if product_id not in products_db:
raise HTTPException(status_code=404, detail="Product not found")
return products_db[product_id]
出力:
TEXT
# 関数定義成功
(2) ▶ サンプル:更新と削除エンドポイント
PYTHON
@app.put("/products/{product_id}", response_model=ProductResponse)
async def update_product(
product_id: int = Path(gt=0),
update: ProductUpdate = ...,
):
if product_id not in products_db:
raise HTTPException(status_code=404, detail="Product not found")
record = products_db[product_id]
update_data = update.model_dump(exclude_unset=True)
record.update(update_data)
return record
@app.delete("/products/{product_id}")
async def delete_product(product_id: int = Path(gt=0)):
if product_id not in products_db:
raise HTTPException(status_code=404, detail="Product not found")
del products_db[product_id]
return {"message": "Product deleted"}
出力:
TEXT
# 関数定義成功
5. 価格エンドポイントと実践的な組み合わせ戦略
(1) Query Parameters と Request Body の組み合わせ
(1) ▶ サンプル:価格検索と作成エンドポイント
PYTHON
@app.get("/prices", response_model=list[PriceResponse])
async def search_prices(
product_id: Optional[int] = Query(None, gt=0),
min_price: float = Query(0, ge=0, description="最低価格 (USD)"),
max_price: float = Query(999999, ge=0, description="最高価格 (USD)"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
results = prices_db
if product_id:
results = [p for p in results if p["product_id"] == product_id]
results = [p for p in results if min_price <= p["price"] <= max_price]
return results[offset : offset + limit]
@app.post("/prices", response_model=PriceResponse, status_code=201)
async def create_price(price: PriceCreate):
global _price_counter
if price.product_id not in products_db:
raise HTTPException(status_code=404, detail="Product not found")
_price_counter += 1
record = {
"id": _price_counter,
"product_id": price.product_id,
"price": price.price,
"currency": price.currency,
"source": price.source,
"recorded_at": datetime.utcnow(),
}
prices_db.append(record)
return record
出力:
TEXT
# 関数定義成功
6. レスポンスフィルタリングの実践ガイド:公開版 vs 管理者版
(1) ビジネスシナリオ:卸売価格の非表示
PriceTracker の小売ユーザーは小売価格のみ閲覧でき, 管理者は卸売価格と原価を閲覧できます。
(1) ▶ サンプル:マルチロール レスポンスモデル
PYTHON
class ProductPublicResponse(BaseModel):
"""公開ビュー - 卸売価格と原価を非表示"""
id: int
name: str
category: str
retail_price: float
class ProductAdminResponse(BaseModel):
"""管理者ビュー - 完全な価格チェーンを表示"""
id: int
name: str
category: str
retail_price: float
wholesale_price: float
cost_price: float
margin_pct: float # 利益率パーセント
# 価格階層を含む拡張インメモリストレージ
admin_products_db: dict[int, dict] = {}
@app.get("/products/{product_id}/public", response_model=ProductPublicResponse)
async def get_product_public(product_id: int = Path(gt=0)):
if product_id not in admin_products_db:
raise HTTPException(status_code=404, detail="Product not found")
return admin_products_db[product_id]
@app.get("/products/{product_id}/admin", response_model=ProductAdminResponse)
async def get_product_admin(product_id: int = Path(gt=0)):
if product_id not in admin_products_db:
raise HTTPException(status_code=404, detail="Product not found")
return admin_products_db[product_id]
出力:
TEXT
# 関数定義成功
❓ よくある質問
Q 再起動後, インメモリストレージのデータはどうなりますか?
A フェーズ1では学習を簡素化するためにインメモリストレージを使用しています。フェーズ2で PostgreSQL + SQLAlchemy に置き換わります。これは段階的な学習戦略です。
Q
ProductCreate と ProductResponse はなぜ分離されているのですか?A 関心の分離のためです。
Create モデルには ID が含まれず (サーバーで生成される), Response モデルには ID が含まれます。これはセキュリティのベストプラクティスでもあります — クライアントが ID を指定すべきではありません。Q PUT エンドポイントの
exclude_unset=True の目的は何ですか?A クライアントが変更したいフィールドのみ更新できるようにします。提供されなかったフィールドは上書きされず,
None のままです。ProductUpdate のオプショナルフィールドと組み合わせることで, 部分更新が可能になります。Q OpenAPI ドキュメントがフロントエンドアプリケーションで利用可能かどうかをテストするにはどうすればよいですか?
A
/openapi.json にアクセスし, npx openapi-typescript で TypeScript 型を生成するか, Swagger Codegen で SDK を生成してください。Q Enum パラメータの値はドキュメントにどのように表示されますか?
A FastAPI は Swagger UI のドロップダウンメニューにすべての Enum 値を自動表示するため, フロントエンド開発者の Bob にもすぐに分かります。
Q 複数のエンドポイントがモデルを共有する場合, 重複を避けるにはどうすればよいですか?
A モデル継承を使用してください。
ProductBase(BaseModel) に共通フィールドを定義し, ProductCreate(ProductBase) と ProductResponse(ProductBase) に継承・拡張させます。📖 まとめ
- フェーズ1:PriceTracker の3つの主要エンドポイント群を完成:商品 CRUD, 価格検索/作成, 公開/管理ビュー
- Pydantic モデルアーキテクチャ:3つの異なるレイヤー — Create (入力バリデーション), Update (部分更新), Response (出力フィルタリング)
- Path Parameters, Query Parameters, Request Body は自然に組み合わされ, FastAPI が型に基づいて自動的に区別します
response_modelで「同じデータ, 異なるビュー」を実現 — 公開版は卸売価格を非表示, 管理者版は完全な価格チェーンを表示- 自動 OpenAPI ドキュメント生成により, フロントエンド開発者は Swagger UI でテストしたり SDK を直接生成したりできます
📝 練習問題
- 基本問題 (難易度 ⭐):このレッスンのすべてのコードを1つの
app/main.pyにまとめ, サービスを起動して Swagger UI で3つの商品を作成し一覧を確認してください。ヒント:uvicorn app.main:app --reload - 応用問題 (難易度 ⭐⭐):
/products/{id}/pricesエンドポイントを追加し, 指定された商品のすべての価格レコードを検索してください。sort(date/price)とlimitの Query Parameters をサポートしてください。ヒント:product_idPath Parameter +sort/limitQuery Parameters - チャレンジ問題 (難易度 ⭐⭐⭐):
ProductAdminとProductPublicを使用してデュアルビューシステムを実装してください。完全な価格チェーンを含む商品データを作成し, 公開エンドポイントは小売価格のみを返し, 管理エンドポイントは小売価格, 卸売価格, 利益率を返すようにしてください。ヒント:2つのresponse_modelインスタンス + 同じデータソース
---|



