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