安定したレスポンスエンベロープの設計
メタデータ、リンク、エラー形式を標準化し、API利用者が予測可能で後方互換性のあるペイロードを受け取れるようにします。
「安定したレスポンスエンベロープの設計」はCoddyKit上の無料FastAPI Backend Development Bootcampレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはFastAPI Backend Development Bootcamp学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 FastAPI Backend Development Bootcampコースには全4レッスンが含まれています。
このレッスンの一部はまだ翻訳されておらず、英語で表示されています。
Why Response Envelopes Matter
A response envelope is a consistent outer shape that wraps every payload your API returns. Instead of returning bare data on one route and a different ad-hoc structure on another, you commit to ONE predictable skeleton.
Consumers benefit because they can:
- Write one parser that works across all endpoints
- Always know where to find
data,meta, anderrors - Rely on the shape staying backward-compatible as you add fields
In this lesson you will design stable envelopes for success, pagination, and errors in FastAPI.
The Bare-Data Anti-Pattern
The most common mistake is returning naked data and changing its shape per route. Here a list endpoint returns an array while a detail endpoint returns an object, and there is nowhere to attach pagination or warnings.
When you later need to add a total count, you must break the array contract or bolt on inconsistent fields. A pure dict has no room to grow safely.
# Anti-pattern: inconsistent, no room to grow
def list_users():
return [{"id": 1, "name": "Ada"}] # bare array
def get_user(user_id):
return {"id": user_id, "name": "Ada"} # bare object
# Later you need pagination... where does total go?
print(list_users())
print(get_user(1))A Minimal Success Envelope
Start with a generic envelope that carries the real payload under data and leaves slots for meta and errors. Using a generic type keeps it reusable for any payload.
Pydantic generics let one model serve every endpoint while preserving the inner schema for OpenAPI docs.
from typing import Generic, TypeVar, Optional, Any
from pydantic import BaseModel
T = TypeVar("T")
class Envelope(BaseModel, Generic[T]):
data: Optional[T] = None
meta: dict[str, Any] = {}
errors: list[dict[str, Any]] = []
class User(BaseModel):
id: int
name: str
env = Envelope[User](data=User(id=1, name="Ada"))
print(env.model_dump_json())Wiring the Envelope into FastAPI
Declare the response model as Envelope[User] so FastAPI documents the exact nested shape in OpenAPI and validates your output.
Notice the route handler never returns bare data — it always wraps the result. That single discipline is what makes the contract stable.
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}", response_model=Envelope[User])
async def read_user(user_id: int):
user = User(id=user_id, name="Ada")
return Envelope[User](data=user, meta={"request_id": "abc-123"})
@app.get("/users", response_model=Envelope[list[User]])
async def list_users():
users = [User(id=1, name="Ada"), User(id=2, name="Linus")]
return Envelope[list[User]](data=users, meta={"count": len(users)})Designing the Meta Block
The meta block holds information about the response that is not the resource itself. Keep it predictable so clients can rely on key names.
Good things to standardize in meta:
request_idfor tracing and support ticketspaginationfor list endpointsdeprecationwarnings without breaking the payload
Model it explicitly instead of using a loose dict so the keys are documented and stable.
from pydantic import BaseModel
from typing import Optional
class PaginationMeta(BaseModel):
page: int
page_size: int
total_items: int
total_pages: int
class Meta(BaseModel):
request_id: Optional[str] = None
pagination: Optional[PaginationMeta] = None
m = Meta(request_id="r-1", pagination=PaginationMeta(
page=1, page_size=20, total_items=57, total_pages=3))
print(m.model_dump_json(indent=2))Pagination Inside the Envelope
For list endpoints, put the page of records under data and the counts under meta.pagination. The data shape (an array) never changes, even when you add new pagination fields later.
Compute total_pages with a ceiling division so the last partial page is counted correctly.
import math
def paginate(items, page, page_size, total_items):
total_pages = math.ceil(total_items / page_size) if page_size else 0
return {
"data": items,
"meta": {
"pagination": {
"page": page,
"page_size": page_size,
"total_items": total_items,
"total_pages": total_pages,
}
},
"errors": [],
}
result = paginate([{"id": 1}], page=3, page_size=20, total_items=57)
print(result["meta"]["pagination"])Adding HATEOAS-Style Links
Hypermedia links let clients navigate without hard-coding URL templates. For pagination, expose self, next, and prev links. When a link does not apply (page 1 has no prev), return null rather than omitting the key — predictable presence beats conditional absence.
def build_page_links(base_url, page, total_pages, page_size):
def url(p):
return f"{base_url}?page={p}&page_size={page_size}"
return {
"self": url(page),
"next": url(page + 1) if page < total_pages else None,
"prev": url(page - 1) if page > 1 else None,
"first": url(1),
"last": url(total_pages),
}
links = build_page_links("/users", page=1, total_pages=3, page_size=20)
for k, v in links.items():
print(k, "->", v)A Standard Error Shape
Errors deserve the same discipline as success. Standardize each error object so clients can branch on a stable machine-readable code instead of fragile string matching on messages.
Recommended fields per error:
code— stable identifier likeUSER_NOT_FOUNDmessage— human-readable, may change freelyfield— which input caused it, for validation errors
from pydantic import BaseModel
from typing import Optional
class ApiError(BaseModel):
code: str
message: str
field: Optional[str] = None
errors = [
ApiError(code="VALIDATION_ERROR", message="must be a valid email", field="email"),
ApiError(code="VALIDATION_ERROR", message="required", field="name"),
]
for e in errors:
print(e.model_dump())Centralizing Error Envelopes
If each route builds its own error dict, the shapes drift apart. Centralize error formatting in an exception handler so every failure exits through the same envelope.
Here a custom exception carries a stable code; a single handler wraps it into the envelope. This guarantees consumers see one error shape across the whole API.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class DomainError(Exception):
def __init__(self, code: str, message: str, status: int = 400):
self.code = code
self.message = message
self.status = status
@app.exception_handler(DomainError)
async def handle_domain_error(request: Request, exc: DomainError):
return JSONResponse(
status_code=exc.status,
content={"data": None, "meta": {}, "errors": [
{"code": exc.code, "message": exc.message}
]},
)
@app.get("/users/{user_id}")
async def read_user(user_id: int):
raise DomainError("USER_NOT_FOUND", "No user with that id", status=404)Backward-Compatible Evolution
A stable envelope is only valuable if it can grow without breaking clients. Follow additive-change rules:
- Safe: add new optional fields to
metaordata - Safe: add a new error
codevalue - Breaking: rename or remove an existing field
- Breaking: change a field's type (string to object)
When you must break the contract, do it behind a new API version (for example /v2), never silently inside /v1.
Tying It Together
A complete list endpoint combines all four pillars: data for the page, meta for pagination, links for navigation, and errors as an always-present array. Because the skeleton never changes, clients written today keep working tomorrow.
This pure-Python builder shows the final assembled shape an endpoint would serialize.
import math
def build_envelope(items, page, page_size, total_items, base_url):
total_pages = math.ceil(total_items / page_size) if page_size else 0
def url(p):
return f"{base_url}?page={p}&page_size={page_size}"
return {
"data": items,
"meta": {
"pagination": {
"page": page, "page_size": page_size,
"total_items": total_items, "total_pages": total_pages,
}
},
"links": {
"self": url(page),
"next": url(page + 1) if page < total_pages else None,
"prev": url(page - 1) if page > 1 else None,
},
"errors": [],
}
env = build_envelope([{"id": 1}, {"id": 2}], 1, 20, 42, "/users")
print(env["meta"]["pagination"]["total_pages"], env["links"]["next"])Quick Check: Backward Compatibility
Your /v1/users endpoint returns an envelope with data, meta, and errors. You now need to expose a new last_login timestamp on each user, and existing mobile clients must keep working unchanged.
Recap: Stable Response Envelopes
You designed a predictable, backward-compatible response contract for FastAPI:
- One envelope wrapping every response in
data,meta, anderrors - Generic Pydantic models so a single envelope works for any payload while OpenAPI still documents the inner shape
- Pagination under
meta.paginationwith ceiling-division page counts - Hypermedia links that return
nullrather than disappearing - Standard errors with stable machine-readable
codevalues, centralized in one exception handler - Additive evolution: add optional fields freely, but put breaking changes behind a new version
Consumers can now write one parser that survives years of API growth.
よくある質問
「安定したレスポンスエンベロープの設計」レッスンは無料ですか?
はい。「安定したレスポンスエンベロープの設計」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、FastAPI Backend Development Bootcampコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 FastAPI Backend Development Bootcampコースには全4レッスンが含まれています。
「安定したレスポンスエンベロープの設計」で何を学びますか?
メタデータ、リンク、エラー形式を標準化し、API利用者が予測可能で後方互換性のあるペイロードを受け取れるようにします。 ブラウザで直接実行するハンズオンコードでFastAPI Backend Development Bootcampを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
FastAPI Backend Development Bootcampを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのFastAPI Backend Development Bootcampは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。
「安定したレスポンスエンベロープの設計」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このFastAPI Backend Development Bootcampレッスンでコードを書いて実行できますか?
はい。すべてのFastAPI Backend Development Bootcampレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。