Coverage for src/lexigram/web/docs/generator.py: 7%

185 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""OpenAPI specification generator for lexigram-web 

2 

3This is a lightweight implementation that inspects controller route metadata 

4and constructs a minimal OpenAPI 3.0.3 specification including component 

5schemas for Pydantic response models (when available). 

6""" 

7 

8from __future__ import annotations 

9 

10import inspect 

11import re 

12from typing import Any 

13 

14from lexigram.logging import get_logger 

15 

16logger = get_logger(__name__) 

17 

18 

19class OpenAPIGenerator: 

20 """Lightweight OpenAPI 3.0.3 specification generator. 

21 

22 Inspects controller route metadata collected by the routing layer and 

23 produces a minimal OpenAPI spec dict that can be served as JSON at 

24 ``/openapi.json`` or rendered via Swagger/Redoc UI. 

25 

26 Supports Pydantic response models (``$ref`` component schemas), built-in 

27 Python types (inline schemas), and ``list[Model]`` generic aliases. 

28 

29 Register through the :class:`~lexigram.web.di.provider.WebProvider` so 

30 the spec is generated lazily from the final route table:: 

31 

32 # Via WebProvider (automatic) 

33 # The provider calls OpenAPIGenerator(title=config.title, ...) and 

34 # mounts /openapi.json and /docs routes automatically. 

35 

36 Args: 

37 title: API title displayed in generated documentation. 

38 version: API version string (e.g. ``"1.0.0"``). 

39 description: Optional long-form API description shown in the UI. 

40 """ 

41 

42 def __init__( 

43 self, 

44 title: str, 

45 version: str, 

46 description: str | None = None, 

47 ) -> None: 

48 """ 

49 Args: 

50 title: API title displayed in generated documentation. 

51 version: API version string (e.g. ``"1.0.0"``). 

52 description: Optional long-form API description shown in the UI. 

53 """ 

54 self.title = title 

55 self.version = version 

56 self.description = description or "" 

57 self.paths: dict[str, Any] = {} 

58 self.components: dict[str, Any] = {"schemas": {}} 

59 

60 def _get_schema_for_model(self, model: Any) -> dict[str, Any]: 

61 """Get an OpenAPI schema for a model or type. 

62 

63 Handles Pydantic models (with components/$ref), built-in types (inline), 

64 and GenericAliases (like list[Model]). 

65 """ 

66 if model is None: 

67 return {"type": "object"} 

68 

69 # Handle basic built-in types 

70 if model is str: 

71 return {"type": "string"} 

72 if model is int: 

73 return {"type": "integer"} 

74 if model is float: 

75 return {"type": "number"} 

76 if model is bool: 

77 return {"type": "boolean"} 

78 if model is list: 

79 return {"type": "array", "items": {"type": "object"}} 

80 if model is dict: 

81 return {"type": "object"} 

82 

83 # Handle GenericAlias (list[Breed], dict[str, Any]) 

84 origin = getattr(model, "__origin__", None) 

85 if origin is list: 

86 args = getattr(model, "__args__", []) 

87 items = self._get_schema_for_model(args[0]) if args else {"type": "object"} 

88 return {"type": "array", "items": items} 

89 if origin is dict: 

90 return {"type": "object"} 

91 

92 # Handle Pydantic models (V2) 

93 if hasattr(model, "model_json_schema"): 

94 name = getattr(model, "__name__", "AnonymousModel") 

95 if name not in self.components["schemas"]: 

96 try: 

97 schema = model.model_json_schema() 

98 # If there is a $defs section, merge it into components 

99 if "$defs" in schema: 

100 defs = schema.pop("$defs") 

101 for def_name, def_schema in defs.items(): 

102 self.components["schemas"][def_name] = def_schema 

103 self.components["schemas"][name] = schema 

104 except (TypeError, ValueError, AttributeError) as e: 

105 logger.warning("Failed to generate JSON schema for %s: %s", name, e) 

106 self.components["schemas"][name] = {"type": "object"} 

107 return {"$ref": f"#/components/schemas/{name}"} 

108 

109 # Fallback for other classes with __name__ 

110 name = getattr(model, "__name__", None) 

111 if name: 

112 if name not in self.components["schemas"]: 

113 self.components["schemas"][name] = {"type": "object"} 

114 return {"$ref": f"#/components/schemas/{name}"} 

115 

116 return {"type": "object"} 

117 

118 def _normalize_path(self, path: Any) -> str: 

119 """Ensures path is a string and replaces path parameters with OpenAPI format.""" 

120 if not isinstance(path, (str, bytes)): 

121 path = str(path) 

122 # Strip Starlette-style type hints from path parameters: {param:type} -> {param} 

123 return re.sub(r"{([^}:]+):[^}]+}", r"{\1}", path) 

124 

125 def _extract_path_parameters(self, path: Any) -> list[dict[str, Any]]: 

126 path = str(path) 

127 params = [] 

128 for match in re.finditer(r"{([^}]+)}", path): 

129 name = match.group(1).split(":")[0] 

130 params.append( 

131 { 

132 "name": name, 

133 "in": "path", 

134 "required": True, 

135 "schema": {"type": "string"}, 

136 }, 

137 ) 

138 return params 

139 

140 def _extract_operation_parameters( 

141 self, 

142 handler: Any, 

143 path_params: list[dict[str, Any]], 

144 ) -> list[dict[str, Any]]: 

145 """Extract parameters from handler metadata and signature.""" 

146 params = list(path_params) 

147 

148 # 1. From _param_metadata (decorators like @query(func)) 

149 metadata = list(getattr(handler, "_param_metadata", [])) 

150 

151 # 2. From signature defaults (decorators like p: str = header(...)) 

152 try: 

153 sig = inspect.signature(handler) 

154 for name, param in sig.parameters.items(): 

155 info = getattr(param.default, "_lexigram_param_info", None) 

156 if info: 

157 # Avoid duplicates if name already in metadata 

158 if not any(m.get("name") == name for m in metadata): 

159 meta = info.copy() 

160 if not meta.get("name"): 

161 meta["name"] = name 

162 # Try to get annotation from type hints 

163 try: 

164 from typing import get_type_hints 

165 

166 hints = get_type_hints(handler) 

167 meta["annotation"] = hints.get(name) 

168 except (TypeError, NameError, AttributeError): 

169 meta["annotation"] = param.annotation 

170 metadata.append(meta) 

171 except (ValueError, TypeError): 

172 pass 

173 

174 for meta in metadata: 

175 p_type = meta.get("type") 

176 if p_type in ("query", "header", "cookie", "form"): 

177 name = meta.get("alias") or meta.get("name") 

178 if not name: 

179 continue 

180 

181 # Check if already added 

182 if any(p["name"] == name and p.get("in") == p_type for p in params): 

183 continue 

184 

185 if p_type == "form": 

186 p = { 

187 "name": name, 

188 "in": "form", 

189 "required": meta.get("default") is ..., 

190 "schema": {"type": "string"}, 

191 } 

192 else: 

193 p = { 

194 "name": name, 

195 "in": p_type, 

196 "required": meta.get("default") is ..., 

197 "schema": {"type": "string"}, # Default to string 

198 } 

199 

200 # Try to refine schema from annotation if available 

201 annotation = meta.get("annotation") 

202 if annotation and annotation is not inspect.Parameter.empty: 

203 from lexigram.web.docs.type_registry import ( 

204 _type_documenter_registry, 

205 ) 

206 

207 documenter = _type_documenter_registry.get_documenter(annotation) 

208 if documenter: 

209 documenter.document(p) 

210 

211 params.append(p) 

212 elif p_type == "path": 

213 # Path params are already extracted from the URL, but we can refine them 

214 name = meta.get("alias") or meta.get("name") 

215 if name: 

216 for p in params: 

217 if p["name"] == name: 

218 annotation = meta.get("annotation") 

219 if annotation and annotation is not inspect.Parameter.empty: 

220 from lexigram.web.docs.type_registry import ( 

221 _type_documenter_registry, 

222 ) 

223 

224 documenter = _type_documenter_registry.get_documenter( 

225 annotation, 

226 ) 

227 if documenter: 

228 documenter.document(p) 

229 break 

230 

231 return params 

232 

233 def generate_spec(self, controllers: list[type]) -> dict[str, Any]: 

234 """Generate an OpenAPI 3.0.3 specification for the provided controllers.""" 

235 for controller in controllers or []: 

236 try: 

237 from typing import cast 

238 routes = cast("Any", controller).collect_routes() 

239 except (AttributeError, TypeError) as e: 

240 logger.warning( 

241 "Controller %s collect_routes failed: %s", 

242 getattr(controller, "__name__", repr(controller)), 

243 e, 

244 ) 

245 routes = [] 

246 

247 tag = getattr(controller, "__name__", "default") 

248 prefix = getattr(controller, "prefix", "") or "" 

249 

250 for route in routes: 

251 raw_path = route.get("path") 

252 if raw_path is None: 

253 continue 

254 

255 # Harden path and method against Mocks 

256 raw_path = str(raw_path) 

257 # Ensure path starts with / and combines prefix correctly 

258 path = prefix.rstrip("/") + raw_path 

259 if not path.startswith("/"): 

260 path = "/" + path 

261 method = str(route.get("method") or "GET").lower() 

262 

263 handler_name = route.get("handler_name") 

264 handler = ( 

265 getattr(controller, handler_name, None) if handler_name else None 

266 ) 

267 

268 # Extract summary and description from docstring if not provided 

269 doc_summary = None 

270 doc_description = None 

271 if handler and handler.__doc__: 

272 doc_lines = handler.__doc__.strip().split("\n") 

273 doc_summary = doc_lines[0].strip() 

274 if len(doc_lines) > 1: 

275 doc_description = "\n".join(doc_lines[1:]).strip() 

276 

277 summary = route.get("summary") or doc_summary or handler_name 

278 description = route.get("description") or doc_description or "" 

279 

280 op_id = route.get("operation_id") or handler_name 

281 operation: dict[str, Any] = { 

282 "operationId": f"{tag}_{op_id}" if op_id else None, 

283 "summary": summary, 

284 "description": description, 

285 "tags": route.get("tags") or [tag], 

286 "deprecated": route.get("deprecated", False), 

287 "responses": {}, 

288 } 

289 

290 # Handle path parameters 

291 raw_path_params = self._extract_path_parameters(path) 

292 # Separate OpenAPI parameters from form-based body parameters 

293 all_params = self._extract_operation_parameters( 

294 handler, 

295 raw_path_params, 

296 ) 

297 operation["parameters"] = [p for p in all_params if p["in"] != "form"] 

298 form_params = [p for p in all_params if p["in"] == "form"] 

299 

300 # Handle responses 

301 status_code = str(route.get("status_code", 200)) 

302 custom_responses = route.get("responses") 

303 

304 if custom_responses: 

305 for code, resp_data in custom_responses.items(): 

306 operation["responses"][str(code)] = resp_data 

307 else: 

308 operation["responses"][status_code] = {"description": "Success"} 

309 

310 # Request body (if a request model is provided or we have form params) 

311 req_model = route.get("request_model") 

312 if req_model is not None: 

313 schema = self._get_schema_for_model(req_model) 

314 operation["requestBody"] = { 

315 "required": True, 

316 "content": { 

317 "application/json": { 

318 "schema": schema, 

319 }, 

320 }, 

321 } 

322 elif form_params: 

323 # Collect form params into a schema 

324 form_properties = {} 

325 required_form_params = [] 

326 for f_p in form_params: 

327 form_properties[f_p["name"]] = f_p.get( 

328 "schema", {"type": "string"}, 

329 ) 

330 if f_p.get("required"): 

331 required_form_params.append(f_p["name"]) 

332 

333 operation["requestBody"] = { 

334 "content": { 

335 "application/x-www-form-urlencoded": { 

336 "schema": { 

337 "type": "object", 

338 "properties": form_properties, 

339 }, 

340 }, 

341 }, 

342 } 

343 if required_form_params: 

344 operation["requestBody"]["content"][ 

345 "application/x-www-form-urlencoded" 

346 ]["schema"]["required"] = required_form_params 

347 

348 resp_model = route.get("response_model") 

349 if resp_model is not None: 

350 schema = self._get_schema_for_model(resp_model) 

351 # Merge with existing response if already defined via custom_responses 

352 if status_code not in operation["responses"]: 

353 operation["responses"][status_code] = {"description": "Success"} 

354 

355 operation["responses"][status_code]["content"] = { 

356 "application/json": { 

357 "schema": schema, 

358 }, 

359 } 

360 

361 # Add path/method 

362 path = self._normalize_path(path) 

363 method = str(method).lower() 

364 self.paths.setdefault(path, {})[method] = operation 

365 

366 spec: dict[str, Any] = { 

367 "openapi": "3.0.3", 

368 "info": { 

369 "title": self.title, 

370 "version": self.version, 

371 "description": self.description, 

372 }, 

373 "paths": self.paths, 

374 "components": self.components, 

375 } 

376 return spec