Skip to content

Commit af369b4

Browse files
committed
Ex: add advanced example for customizing the business logic on top of apiexception
1 parent 8dfaaae commit af369b4

2 files changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Advanced Validation Handling Example
2+
3+
This example demonstrates how to extend **APIException** to expose richer validation
4+
and error details while still preserving the unified response contract provided by the library.
5+
6+
It is intentionally designed as an **opt-in, advanced use case**.
7+
The default behavior of APIException remains unchanged and secure.
8+
9+
---
10+
11+
## What this example shows
12+
13+
This example demonstrates how to:
14+
15+
- Extend `ResponseModel` with additional root-level fields (`request_id`, `error`)
16+
- Automatically expose raw Pydantic validation errors via `exc.errors()`
17+
- Preserve the unified response schema for both **422 validation errors** and **500 unhandled exceptions**
18+
- Fully customize validation and fallback behavior by disabling the library fallback middleware
19+
20+
So you can customize your business level logic while still benefiting from the library's
21+
unified response handling.
22+
---

examples/advanced_examples/app.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
import traceback
5+
from typing import Any, Callable, Generic, Optional, TypeVar, List
6+
7+
from fastapi import FastAPI, Request
8+
from fastapi.exceptions import RequestValidationError
9+
from fastapi.responses import JSONResponse
10+
from pydantic import BaseModel, Field
11+
12+
from api_exception import (
13+
register_exception_handlers,
14+
ResponseModel,
15+
ExceptionCode,
16+
ExceptionStatus, APIException,
17+
)
18+
19+
# -----------------------------------------------------------------------------
20+
# App setup
21+
# -----------------------------------------------------------------------------
22+
23+
app = FastAPI(title="APIException Advanced Validation Handling Example")
24+
25+
# Disable library fallback middleware/handlers so we can fully customize:
26+
# - validation (422) response body
27+
# - unhandled exceptions (500) response body
28+
register_exception_handlers(app,
29+
use_fallback_middleware=False,
30+
log=True)
31+
32+
DataT = TypeVar("DataT")
33+
34+
35+
# -----------------------------------------------------------------------------
36+
# Custom response model
37+
# -----------------------------------------------------------------------------
38+
39+
class CustomResponseModel(ResponseModel[DataT], Generic[DataT]):
40+
"""
41+
Extends the library's ResponseModel without breaking the unified response contract.
42+
43+
Adds:
44+
- request_id: infrastructure id
45+
- error: raw Pydantic validation errors (exc.errors())
46+
"""
47+
request_id: Optional[str] = Field(default=None, description="Infrastructure request id.")
48+
error: Optional[List[dict[str, Any]]] = Field(
49+
default=None,
50+
description="Raw Pydantic validation errors (exc.errors()).",
51+
)
52+
53+
54+
# -----------------------------------------------------------------------------
55+
# Middleware: ensure request_id
56+
# -----------------------------------------------------------------------------
57+
58+
@app.middleware("http")
59+
async def ensure_request_id(request: Request, call_next: Callable):
60+
rid = request.headers.get("x-request-id") or str(uuid.uuid4())
61+
request.state.request_id = rid
62+
63+
response = await call_next(request)
64+
65+
# Echo back for client correlation
66+
response.headers["x-request-id"] = rid
67+
return response
68+
69+
70+
# -----------------------------------------------------------------------------
71+
# Validation error handler: 422
72+
# -----------------------------------------------------------------------------
73+
74+
@app.exception_handler(RequestValidationError)
75+
async def custom_validation_handler(request: Request, exc: RequestValidationError):
76+
err = ExceptionCode.VALIDATION_ERROR
77+
78+
errors = exc.errors() or []
79+
first_msg = errors[0].get("msg", err.description) if errors else err.description
80+
81+
return JSONResponse(
82+
status_code=422,
83+
content=CustomResponseModel(
84+
request_id=getattr(request.state, "request_id", None),
85+
data=None,
86+
status=ExceptionStatus.FAIL,
87+
message=err.message,
88+
error_code=err.error_code,
89+
description=first_msg,
90+
error=errors, # automatic raw pydantic output
91+
).model_dump(exclude_none=False),
92+
)
93+
94+
95+
# -----------------------------------------------------------------------------
96+
# Fallback middleware: 500
97+
# -----------------------------------------------------------------------------
98+
99+
@app.middleware("http")
100+
async def custom_fallback_middleware(request: Request, call_next: Callable):
101+
try:
102+
return await call_next(request)
103+
except Exception:
104+
err = ExceptionCode.INTERNAL_SERVER_ERROR
105+
106+
# Recommended: log traceback server-side, do not expose in production responses.
107+
tb = traceback.format_exc()
108+
109+
return JSONResponse(
110+
status_code=500,
111+
content=CustomResponseModel(
112+
request_id=getattr(request.state, "request_id", None),
113+
data=None,
114+
status=ExceptionStatus.FAIL,
115+
message=err.message,
116+
error_code=err.error_code,
117+
description=err.description,
118+
# Keep it minimal for safety. If you want, you can include tb here,
119+
# but it is not recommended for production.
120+
error=[{"type": "unhandled_exception"}],
121+
).model_dump(exclude_none=False),
122+
)
123+
124+
125+
# -----------------------------------------------------------------------------
126+
# Demo endpoints
127+
# -----------------------------------------------------------------------------
128+
129+
class DemoQuery(BaseModel):
130+
limit: int
131+
itemsPerPage: int
132+
133+
134+
class DemoBody(BaseModel):
135+
limit: int
136+
itemsPerPage: int
137+
138+
139+
@app.get("/demo/validation", response_model=CustomResponseModel[DemoQuery])
140+
async def demo_validation(limit: int, itemsPerPage: int, request: Request):
141+
"""
142+
Example:
143+
GET /demo/validation?limit=dw&itemsPerPage=pops
144+
This will trigger RequestValidationError and return 422 with error=exc.errors()
145+
"""
146+
data = DemoQuery(limit=limit, itemsPerPage=itemsPerPage)
147+
return CustomResponseModel[DemoQuery](
148+
request_id=request.state.request_id,
149+
data=data,
150+
status=ExceptionStatus.SUCCESS,
151+
message="Everything's good!",
152+
description="Validation passed.",
153+
error=None,
154+
)
155+
156+
157+
@app.post("/demo/validation-body", response_model=CustomResponseModel[DemoBody])
158+
async def demo_validation_body(payload: DemoBody, request: Request):
159+
if payload.limit < 0:
160+
raise APIException(
161+
error_code=ExceptionCode.VALIDATION_ERROR,
162+
http_status_code=422,
163+
message="Limit must be non-negative.",
164+
description="The 'limit' field cannot be less than zero.",
165+
log_exception=True
166+
)
167+
168+
return CustomResponseModel[DemoBody](
169+
request_id=request.state.request_id,
170+
data=payload,
171+
status=ExceptionStatus.SUCCESS,
172+
message="Everything's good!",
173+
description="Validation passed.",
174+
error=None,
175+
)
176+
177+
178+
@app.get("/demo/crash", response_model=CustomResponseModel[None])
179+
async def demo_crash(request: Request):
180+
"""
181+
Example:
182+
GET /demo/crash
183+
This will raise an unhandled exception and return 500 with unified response schema.
184+
"""
185+
_ = 1 / 0
186+
return CustomResponseModel[None](
187+
request_id=request.state.request_id,
188+
data=None,
189+
status=ExceptionStatus.SUCCESS,
190+
message="Everything's good!",
191+
)
192+
193+
194+
195+
196+
197+
if __name__ == "__main__":
198+
import uvicorn
199+
200+
uvicorn.run(app, host="0.0.0.0", port=8000)

0 commit comments

Comments
 (0)