Coverage for nexios\openapi\_builder.py: 31%

89 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-21 20:31 +0100

1from functools import wraps 

2from typing import Optional, Dict, List, Type, Union 

3from uuid import uuid4 

4from pydantic import BaseModel 

5from nexios.http import Request, Response 

6from .config import OpenAPIConfig 

7from .models import ( 

8 Components, 

9 Parameter, 

10 ExternalDocumentation, 

11 RequestBody, 

12 MediaType, 

13 Response as OpenAPIResponse, 

14 Operation, 

15 Schema, 

16 PathItem, 

17) 

18 

19 

20class APIDocumentation: 

21 _instance = None 

22 

23 def __new__(cls, *args, **kwargs): 

24 if not cls._instance: 

25 cls._instance = super().__new__(cls) 

26 return cls._instance 

27 

28 def __init__( 

29 self, app: Optional["NexiosApp"] = None, config: Optional[OpenAPIConfig] = None 

30 ): 

31 self.app = app 

32 self.config = config or OpenAPIConfig() 

33 if app: 

34 self._setup_doc_routes() 

35 

36 def _setup_doc_routes(self): 

37 """Set up routes for serving OpenAPI specification""" 

38 

39 @self.app.get("/openapi.json", exclude_from_schema=True) # type:ignore 

40 async def serve_openapi(request: Request, response: Response): 

41 openapi_json = self.config.openapi_spec.model_dump( 

42 by_alias=True, exclude_none=True 

43 ) 

44 return response.json(openapi_json) 

45 

46 @self.app.get("/docs", exclude_from_schema=True) # type:ignore 

47 async def swagger_ui(request: Request, response: Response): 

48 return response.html(self._generate_swagger_ui()) 

49 

50 @classmethod 

51 def get_instance(cls): 

52 return cls._instance 

53 

54 def _generate_swagger_ui(self) -> str: 

55 """Generate Swagger UI HTML""" 

56 return f""" 

57 <!DOCTYPE html> 

58 <html> 

59 <head> 

60 <title>{self.config.openapi_spec.info.title} - Docs</title> 

61 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4.18.3/swagger-ui.css"> 

62 </head> 

63 <body> 

64 <div id="swagger-ui"></div> 

65 <script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-bundle.js"></script> 

66 <script> 

67 window.onload = function() { 

68 SwaggerUIBundle({ 

69 url: '/openapi.json', 

70 dom_id: '#swagger-ui', 

71 presets: [ 

72 SwaggerUIBundle.presets.apis, 

73 SwaggerUIBundle.SwaggerUIStandalonePreset 

74 ], 

75 layout: "BaseLayout" 

76 } ); 

77 } 

78 </script> 

79 </body> 

80 </html> 

81 """ 

82 

83 def document_endpoint( 

84 self, 

85 path: str, 

86 method: str, 

87 summary: str = "", 

88 description: Optional[str] = None, 

89 parameters: Optional[List[Parameter]] = None, 

90 request_body: Optional[Type[BaseModel]] = None, 

91 responses: Optional[Union[BaseModel, Dict[int, BaseModel]]] = None, 

92 tags: Optional[List[str]] = None, 

93 security: Optional[List[Dict[str, List[str]]]] = None, 

94 operation_id: Optional[str] = None, 

95 deprecated: bool = False, 

96 external_docs: Optional[ExternalDocumentation] = None, 

97 ): 

98 """ 

99 Decorator to document API endpoints with OpenAPI specification 

100 

101 :param path: URL path of the endpoint 

102 :param method: HTTP method (get, post, put, delete, etc.) 

103 :param summary: Short summary of the endpoint 

104 :param description: Detailed description of the endpoint 

105 :param parameters: List of parameters 

106 :param request_body: Pydantic model for request body 

107 :param responses: Response model(s) 

108 :param tags: Categorization tags for the endpoint 

109 :param security: Security requirements 

110 :param operation_id: Unique identifier for the operation 

111 :param deprecated: Whether the endpoint is deprecated 

112 :param external_docs: External documentation reference 

113 """ 

114 

115 def decorator(func): 

116 # Prepare request body specification 

117 request_body_spec = None 

118 

119 if request_body: 

120 self.add_schema(request_body) 

121 if request_body: 

122 request_body_spec = RequestBody( 

123 content={ 

124 "application/json": MediaType( # type:ignore 

125 schema=Schema( # type:ignore 

126 **request_body.model_json_schema() 

127 ) 

128 ) 

129 } 

130 ) 

131 else: 

132 if method.upper() not in ["GET", "DELETE"]: 

133 request_body_spec = RequestBody( 

134 content={ 

135 "application/json": MediaType( # type:ignore 

136 schema=Schema( 

137 example={ 

138 "example": "This is an example request body" 

139 } 

140 ) # type:ignore 

141 ) 

142 } 

143 ) 

144 

145 # self.config.openapi_spec.components.schemas[request_body.__name__] = request_body 

146 # Prepare responses specification 

147 responses_spec = {} 

148 if responses: 

149 if isinstance(responses, type) and issubclass(responses, BaseModel): 

150 responses_spec["200"] = OpenAPIResponse( 

151 description="Successful Response", 

152 content={ 

153 "application/json": MediaType( # type:ignore 

154 schema=Schema( # type:ignore 

155 **responses.model_json_schema(), 

156 ) 

157 ) 

158 }, 

159 ) 

160 # Handle List[Model] case 

161 elif hasattr(responses, "__origin__") and responses.__origin__ is list: 

162 item_model = responses.__args__[0] 

163 if issubclass(item_model, BaseModel): 

164 responses_spec["200"] = OpenAPIResponse( 

165 description="Successful Response", 

166 content={ 

167 "application/json": MediaType( # type:ignore 

168 schema=Schema( # type:ignore 

169 type="array", 

170 items=Schema(**item_model.model_json_schema()), 

171 ) 

172 ) 

173 }, 

174 ) 

175 # Multiple responses case 

176 elif isinstance(responses, dict): 

177 for status_code, model in responses.items(): 

178 # Handle direct model 

179 if isinstance(model, type) and issubclass(model, BaseModel): 

180 responses_spec[str(status_code)] = OpenAPIResponse( 

181 description=f"Response for status code {status_code}", 

182 content={ 

183 "application/json": MediaType( # type:ignore 

184 schema=Schema( # type:ignore 

185 **model.model_json_schema() 

186 ) 

187 ) 

188 }, 

189 ) 

190 # Handle List[Model] 

191 elif hasattr(model, "__origin__") and model.__origin__ is list: 

192 item_model = model.__args__[0] 

193 if issubclass(item_model, BaseModel): 

194 responses_spec[str(status_code)] = OpenAPIResponse( 

195 description=f"Response for status code {status_code}", 

196 content={ 

197 "application/json": MediaType( # type:ignore 

198 schema=Schema( # type:ignore 

199 type="array", 

200 items=Schema( 

201 **item_model.model_json_schema() 

202 ), 

203 ) 

204 ) 

205 }, 

206 ) 

207 else: 

208 # Default response if no responses specified 

209 responses_spec["200"] = OpenAPIResponse( 

210 description="Successful Response", 

211 content={ 

212 "application/json": MediaType( # type:ignore 

213 schema=Schema( 

214 example={"example": "This is an example response"}, 

215 type="object", 

216 ) # type:ignore 

217 ) 

218 }, 

219 ) 

220 

221 if not responses_spec: 

222 responses_spec["200"] = OpenAPIResponse( 

223 description="Successful Response", 

224 content={ 

225 "application/json": MediaType( # type:ignore 

226 schema=Schema( 

227 example={"example": "This is an example response"}, 

228 type="object", 

229 ) # type:ignore 

230 ) 

231 }, 

232 ) 

233 

234 if responses: 

235 if isinstance(responses, type) and issubclass(responses, BaseModel): 

236 self.add_schema(responses) 

237 elif hasattr(responses, "__origin__") and responses.__origin__ is list: 

238 item_model = responses.__args__[0] 

239 if issubclass(item_model, BaseModel): 

240 self.add_schema(item_model) 

241 elif isinstance(responses, dict): 

242 for model in responses.values(): 

243 if isinstance(model, type) and issubclass(model, BaseModel): 

244 self.add_schema(model) 

245 elif hasattr(model, "__origin__") and model.__origin__ is list: 

246 item_model = model.__args__[0] 

247 if issubclass(item_model, BaseModel): 

248 self.add_schema(item_model) 

249 

250 operation = Operation( 

251 summary=summary, 

252 description=description, 

253 responses=responses_spec, 

254 tags=tags, 

255 parameters=parameters or [], # type:ignore 

256 requestBody=request_body_spec, 

257 security=security, 

258 operationId=operation_id or str(uuid4()), 

259 deprecated=deprecated, 

260 externalDocs=external_docs, 

261 ) 

262 

263 # Add operation to the OpenAPI specification 

264 if path not in self.config.openapi_spec.paths: 

265 self.config.openapi_spec.paths[path] = PathItem() 

266 

267 setattr(self.config.openapi_spec.paths[path], method.lower(), operation) 

268 

269 @wraps(func) 

270 async def wrapper(*args, **kwargs): 

271 return await func(*args, **kwargs) 

272 

273 return wrapper 

274 

275 return decorator 

276 

277 def add_schema(self, schema: Type[BaseModel]) -> None: 

278 """Add a schema to the OpenAPI components section 

279 

280 Args: 

281 schema: A Pydantic model to be added to the components/schemas section 

282 """ 

283 if not self.config.openapi_spec.components: 

284 self.config.openapi_spec.components = Components() 

285 

286 if not self.config.openapi_spec.components.schemas: 

287 self.config.openapi_spec.components.schemas = {} 

288 

289 self.config.openapi_spec.components.schemas[schema.__name__] = Schema( 

290 **schema.model_json_schema() 

291 )