Coverage for utils/response.py: 100.00%
128 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
1from datetime import datetime
2from typing import TypeVar
4from pydantic import BaseModel, RootModel
6T = TypeVar("T")
9class APIResponse[T](BaseModel):
10 """Generic API response wrapper that maintains consistent response structure"""
12 code: int
13 message: str
14 data: T | None = None
17class ValidationErrorData(RootModel[dict[str, str]]):
18 """Model for validation error data structure"""
20 pass
23def is_openapi_examples(example: dict) -> bool:
24 """
25 Detect OpenAPI Media Type ``examples`` (named map with dropdown in Swagger UI).
27 Expected shape:
28 {
29 "caseA": {"summary": "...", "value": {...}},
30 "caseB": {"value": {...}},
31 }
32 """
33 if not isinstance(example, dict) or not example:
34 return False
35 if "code" in example or "message" in example or "data" in example:
36 return False
37 return all(isinstance(item, dict) and "value" in item for item in example.values())
40def make_response_doc(
41 description: str, model: type | None = None, example: dict | None = None
42) -> dict:
43 """Create OpenAPI response documentation with model and example(s)"""
44 doc = {"description": description}
45 if model:
46 doc["model"] = APIResponse[model]
47 if example:
48 media = {"examples": example} if is_openapi_examples(example) else {"example": example}
49 doc["content"] = {"application/json": media}
50 return doc
53def make_error_examples(code: int, cases: dict[str, str]) -> dict:
54 """
55 Build OpenAPI named ``examples`` for simple error responses.
57 Example:
58 make_error_examples(401, {
59 "invalidSession": "Invalid or expired session",
60 "invalidCsrf": "Invalid or expired CSRF token",
61 })
62 """
63 return {
64 key: {
65 "summary": message,
66 "value": {"code": code, "message": message, "data": None},
67 }
68 for key, message in cases.items()
69 }
72def parse_responses(custom: dict, default: dict = None) -> dict:
73 """
74 Parse and merge responses. Supports:
75 - 2-tuple: (description, model) - auto-generates example
76 - 3-tuple: (description, model, example) - single example dict,
77 or OpenAPI named ``examples`` map (Swagger UI dropdown)
78 - string: description only - creates simple error response
79 """
80 # Merge default responses first, then override with custom ones
81 merged = {}
82 if default:
83 merged.update(default)
84 if custom:
85 merged.update(custom)
87 result = {}
88 for code, val in merged.items():
89 if isinstance(val, tuple):
90 if len(val) == 2:
91 desc, model = val
92 if model is None:
93 data_example = None
94 else:
95 try:
96 schema = model.model_json_schema()
97 data_example = generate_example_from_schema(schema)
98 except Exception:
99 data_example = None
101 example = {"code": code, "message": desc, "data": data_example}
102 result[code] = make_response_doc(desc, model, example)
103 elif len(val) == 3:
104 desc, model, example = val
105 # Auto-fill only for a single response body example
106 if not is_openapi_examples(example):
107 if "code" not in example:
108 example["code"] = code
109 if "message" not in example:
110 example["message"] = desc
111 result[code] = make_response_doc(desc, model, example)
112 elif isinstance(val, str):
113 example = {"code": code, "message": val, "data": None}
114 result[code] = make_response_doc(val, None, example)
115 else:
116 result[code] = val
117 return result
120def generate_example_from_schema(schema: dict) -> dict:
121 """Generate example data from JSON schema object properties"""
122 if schema.get("type") == "object":
123 properties = schema.get("properties", {})
124 example = {}
125 for key, prop in properties.items():
126 example[key] = generate_property_example(prop, key, schema)
127 return example
128 return None
131def generate_property_example(prop: dict, key: str = "", full_schema: dict = None):
132 """Generate example value for a single property based on its type and field name"""
133 # Handle $ref references first (for nested objects)
134 if prop.get("$ref"):
135 referenced_schema = resolve_ref(prop["$ref"], full_schema)
136 if referenced_schema:
137 return generate_example_from_schema(referenced_schema)
138 return None
140 prop_type = prop.get("type")
142 if prop_type == "string":
143 if key == "id":
144 return "123e4567-e89b-12d3-a456-426614174000"
145 elif "email" in key.lower():
146 return "user@example.com"
147 elif key == "phone":
148 return "123456789"
149 elif key == "created_at":
150 return datetime.now().astimezone().isoformat()
151 elif key == "updated_at":
152 return datetime.now().astimezone().isoformat()
153 elif key == "expires_at":
154 return datetime.now().astimezone().isoformat()
155 else:
156 return f"Example {key.replace('_', ' ').title()}"
157 elif prop_type == "integer":
158 if key in ["per_page", "total_pages"]:
159 return 10
160 elif key == "page":
161 return 1
162 else:
163 return 100
164 elif prop_type == "number":
165 return 123.45
166 elif prop_type == "boolean":
167 return True
168 elif prop_type == "array":
169 items_schema = prop.get("items", {})
170 # Handle $ref references in array items (e.g., List[UserRead])
171 if items_schema.get("$ref"):
172 referenced_schema = resolve_ref(items_schema["$ref"], full_schema)
173 if referenced_schema:
174 item_example = generate_example_from_schema(referenced_schema)
175 return [item_example] if item_example else []
176 return []
177 elif prop_type == "object":
178 return generate_example_from_schema(prop)
179 elif prop.get("format") == "date-time":
180 return datetime.now().astimezone().isoformat()
181 elif prop.get("anyOf"):
182 options = prop.get("anyOf", [])
183 for option in options:
184 if option.get("type") != "null":
185 return generate_property_example(option, key, full_schema)
186 return None
187 else:
188 return None
191def resolve_ref(ref_path: str, schema: dict) -> dict:
192 """
193 Resolve JSON Schema $ref references to actual schema definitions
194 """
195 if not ref_path.startswith("#/"):
196 return None
198 # Parse reference path: "#/$defs/UserRead" -> ["$defs", "UserRead"]
199 path_parts = ref_path[2:].split("/")
200 current = schema
202 # Navigate through nested dict structure following the path
203 for part in path_parts:
204 if isinstance(current, dict) and part in current:
205 current = current[part]
206 else:
207 # Reference not found
208 return None
210 if isinstance(current, dict):
211 return current
212 else:
213 return None
216common_responses = {
217 401: (
218 "Invalid or expired token",
219 APIResponse[None],
220 {"code": 401, "message": "Invalid or expired token", "data": None},
221 ),
222 403: (
223 "Permission denied",
224 APIResponse[None],
225 {"code": 403, "message": "Permission denied", "data": None},
226 ),
227 422: (
228 "Validation Error",
229 APIResponse[ValidationErrorData],
230 {"code": 422, "message": "Validation Error", "data": {"body.params": "field required"}},
231 ),
232 429: (
233 "Too many requests. Try again later.",
234 APIResponse[None],
235 {"code": 429, "message": "Too many requests. Try again later.", "data": None},
236 ),
237 500: (
238 "Internal Server Error",
239 APIResponse[None],
240 {"code": 500, "message": "Internal Server Error", "data": None},
241 ),
242}