bustapi 0.1.0__cp311-cp311-win_amd64.whl → 0.1.5__cp311-cp311-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bustapi might be problematic. Click here for more details.
- bustapi/__init__.py +10 -5
- bustapi/app.py +338 -41
- bustapi/bustapi_core.cp311-win_amd64.pyd +0 -0
- bustapi/helpers.py +28 -8
- bustapi/logging.py +468 -0
- bustapi/openapi/__init__.py +33 -0
- bustapi/openapi/const.py +3 -0
- bustapi/openapi/docs.py +269 -0
- bustapi/openapi/models.py +128 -0
- bustapi/openapi/utils.py +158 -0
- bustapi/templating.py +30 -0
- bustapi/testing.py +2 -1
- bustapi-0.1.5.dist-info/METADATA +324 -0
- bustapi-0.1.5.dist-info/RECORD +23 -0
- {bustapi-0.1.0.dist-info → bustapi-0.1.5.dist-info}/licenses/LICENSE +1 -1
- bustapi-0.1.0.dist-info/METADATA +0 -233
- bustapi-0.1.0.dist-info/RECORD +0 -16
- {bustapi-0.1.0.dist-info → bustapi-0.1.5.dist-info}/WHEEL +0 -0
- {bustapi-0.1.0.dist-info → bustapi-0.1.5.dist-info}/entry_points.txt +0 -0
bustapi/openapi/docs.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BustAPI OpenAPI Documentation UI
|
|
3
|
+
|
|
4
|
+
Custom implementation for Swagger UI and ReDoc documentation interfaces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from starlette.responses import HTMLResponse
|
|
12
|
+
except ImportError:
|
|
13
|
+
# Fallback if Starlette is not available
|
|
14
|
+
class HTMLResponse:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
content: str,
|
|
18
|
+
status_code: int = 200,
|
|
19
|
+
headers: Optional[Dict[str, str]] = None,
|
|
20
|
+
):
|
|
21
|
+
self.content = content
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.headers = headers or {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def jsonable_encoder(obj: Any) -> Any:
|
|
27
|
+
"""Simple JSON encoder fallback."""
|
|
28
|
+
if hasattr(obj, "dict"):
|
|
29
|
+
return obj.dict()
|
|
30
|
+
elif hasattr(obj, "__dict__"):
|
|
31
|
+
return obj.__dict__
|
|
32
|
+
else:
|
|
33
|
+
return obj
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# BustAPI Swagger UI default parameters
|
|
37
|
+
swagger_ui_default_parameters: Dict[str, Any] = {
|
|
38
|
+
"dom_id": "#swagger-ui",
|
|
39
|
+
"layout": "BaseLayout",
|
|
40
|
+
"deepLinking": True,
|
|
41
|
+
"showExtensions": True,
|
|
42
|
+
"showCommonExtensions": True,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_swagger_ui_html(
|
|
47
|
+
*,
|
|
48
|
+
openapi_url: str,
|
|
49
|
+
title: str,
|
|
50
|
+
swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js",
|
|
51
|
+
swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css",
|
|
52
|
+
swagger_favicon_url: str = (
|
|
53
|
+
"data:image/svg+xml;base64,"
|
|
54
|
+
"PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iOCIgZmlsbD0iIzAwN0FGRiIvPgo8cGF0aCBkPSJNOCAxNkMxMiAxNiAxNiAxMiAxNiA4QzE2IDEyIDIwIDE2IDI0IDE2QzIwIDE2IDE2IDIwIDE2IDI0QzE2IDIwIDEyIDE2IDggMTZaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K"
|
|
55
|
+
),
|
|
56
|
+
oauth2_redirect_url: Optional[str] = None,
|
|
57
|
+
init_oauth: Optional[Dict[str, Any]] = None,
|
|
58
|
+
swagger_ui_parameters: Optional[Dict[str, Any]] = None,
|
|
59
|
+
) -> HTMLResponse:
|
|
60
|
+
"""
|
|
61
|
+
Generate and return the HTML that loads Swagger UI for the interactive
|
|
62
|
+
API docs (normally served at `/docs`).
|
|
63
|
+
|
|
64
|
+
This is BustAPI's custom implementation with BustAPI branding and optimizations.
|
|
65
|
+
"""
|
|
66
|
+
current_swagger_ui_parameters = swagger_ui_default_parameters.copy()
|
|
67
|
+
if swagger_ui_parameters:
|
|
68
|
+
current_swagger_ui_parameters.update(swagger_ui_parameters)
|
|
69
|
+
|
|
70
|
+
html = f"""
|
|
71
|
+
<!DOCTYPE html>
|
|
72
|
+
<html>
|
|
73
|
+
<head>
|
|
74
|
+
<meta charset="utf-8">
|
|
75
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
|
+
<link type="text/css" rel="stylesheet" href="{swagger_css_url}">
|
|
77
|
+
<link rel="shortcut icon" href="{swagger_favicon_url}">
|
|
78
|
+
<title>{title}</title>
|
|
79
|
+
<style>
|
|
80
|
+
.swagger-ui .topbar {{ display: none; }}
|
|
81
|
+
.swagger-ui .info .title {{ color: #007AFF; }}
|
|
82
|
+
.swagger-ui .info .title:after {{
|
|
83
|
+
content: " - Powered by BustAPI";
|
|
84
|
+
font-size: 14px;
|
|
85
|
+
color: #666;
|
|
86
|
+
font-weight: normal;
|
|
87
|
+
}}
|
|
88
|
+
</style>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<div id="swagger-ui"></div>
|
|
92
|
+
<script src="{swagger_js_url}"></script>
|
|
93
|
+
<script>
|
|
94
|
+
const ui = SwaggerUIBundle({{
|
|
95
|
+
url: '{openapi_url}',
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
for key, value in current_swagger_ui_parameters.items():
|
|
99
|
+
html += f"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\n"
|
|
100
|
+
|
|
101
|
+
if oauth2_redirect_url:
|
|
102
|
+
html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
|
|
103
|
+
|
|
104
|
+
html += """
|
|
105
|
+
presets: [
|
|
106
|
+
SwaggerUIBundle.presets.apis,
|
|
107
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
108
|
+
],
|
|
109
|
+
plugins: [
|
|
110
|
+
SwaggerUIBundle.plugins.DownloadUrl
|
|
111
|
+
]
|
|
112
|
+
}});"""
|
|
113
|
+
|
|
114
|
+
if init_oauth:
|
|
115
|
+
html += f"""
|
|
116
|
+
ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))});
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
html += """
|
|
120
|
+
</script>
|
|
121
|
+
</body>
|
|
122
|
+
</html>
|
|
123
|
+
"""
|
|
124
|
+
return HTMLResponse(html)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_redoc_html(
|
|
128
|
+
*,
|
|
129
|
+
openapi_url: str,
|
|
130
|
+
title: str,
|
|
131
|
+
redoc_js_url: str = "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js",
|
|
132
|
+
redoc_favicon_url: str = (
|
|
133
|
+
"data:image/svg+xml;base64,"
|
|
134
|
+
"PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iOCIgZmlsbD0iIzAwN0FGRiIvPgo8cGF0aCBkPSJNOCAxNkMxMiAxNiAxNiAxMiAxNiA4QzE2IDEyIDIwIDE2IDI0IDE2QzIwIDE2IDE2IDIwIDE2IDI0QzE2IDIwIDEyIDE2IDggMTZaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K"
|
|
135
|
+
),
|
|
136
|
+
with_google_fonts: bool = True,
|
|
137
|
+
) -> HTMLResponse:
|
|
138
|
+
"""
|
|
139
|
+
Generate and return the HTML that loads ReDoc for the alternative
|
|
140
|
+
automatic interactive documentation.
|
|
141
|
+
|
|
142
|
+
This is BustAPI's custom implementation with BustAPI branding.
|
|
143
|
+
"""
|
|
144
|
+
html = f"""
|
|
145
|
+
<!DOCTYPE html>
|
|
146
|
+
<html>
|
|
147
|
+
<head>
|
|
148
|
+
<title>{title}</title>
|
|
149
|
+
<!-- needed for adaptive design -->
|
|
150
|
+
<meta charset="utf-8"/>
|
|
151
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
152
|
+
"""
|
|
153
|
+
if with_google_fonts:
|
|
154
|
+
html += """
|
|
155
|
+
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
|
156
|
+
"""
|
|
157
|
+
html += f"""
|
|
158
|
+
<link rel="shortcut icon" href="{redoc_favicon_url}">
|
|
159
|
+
<!--
|
|
160
|
+
ReDoc doesn't change outer page styles
|
|
161
|
+
-->
|
|
162
|
+
<style>
|
|
163
|
+
body {{
|
|
164
|
+
margin: 0;
|
|
165
|
+
padding: 0;
|
|
166
|
+
}}
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<noscript>
|
|
171
|
+
ReDoc requires Javascript to function. Please enable it to browse the documentation.
|
|
172
|
+
</noscript>
|
|
173
|
+
<redoc spec-url="{openapi_url}"></redoc>
|
|
174
|
+
<script src="{redoc_js_url}"> </script>
|
|
175
|
+
</body>
|
|
176
|
+
</html>
|
|
177
|
+
"""
|
|
178
|
+
return HTMLResponse(html)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_swagger_ui_oauth2_redirect_html() -> HTMLResponse:
|
|
182
|
+
"""
|
|
183
|
+
Generate the HTML response with the OAuth2 redirection for Swagger UI.
|
|
184
|
+
|
|
185
|
+
You normally don't need to use or change this.
|
|
186
|
+
"""
|
|
187
|
+
# copied from https://github.com/swagger-api/swagger-ui/blob/v4.14.0/dist/oauth2-redirect.html
|
|
188
|
+
html = """
|
|
189
|
+
<!doctype html>
|
|
190
|
+
<html lang="en-US">
|
|
191
|
+
<head>
|
|
192
|
+
<title>Swagger UI: OAuth2 Redirect</title>
|
|
193
|
+
</head>
|
|
194
|
+
<body>
|
|
195
|
+
<script>
|
|
196
|
+
'use strict';
|
|
197
|
+
function run () {
|
|
198
|
+
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
|
199
|
+
var sentState = oauth2.state;
|
|
200
|
+
var redirectUrl = oauth2.redirectUrl;
|
|
201
|
+
var isValid, qp, arr;
|
|
202
|
+
|
|
203
|
+
if (/code|token|error/.test(window.location.hash)) {
|
|
204
|
+
qp = window.location.hash.substring(1).replace('?', '&');
|
|
205
|
+
} else {
|
|
206
|
+
qp = location.search.substring(1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
arr = qp.split("&");
|
|
210
|
+
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
|
|
211
|
+
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
|
212
|
+
function (key, value) {
|
|
213
|
+
return key === "" ? value : decodeURIComponent(value);
|
|
214
|
+
}
|
|
215
|
+
) : {};
|
|
216
|
+
|
|
217
|
+
isValid = qp.state === sentState;
|
|
218
|
+
|
|
219
|
+
if ((
|
|
220
|
+
oauth2.auth.schema.get("flow") === "accessCode" ||
|
|
221
|
+
oauth2.auth.schema.get("flow") === "authorizationCode" ||
|
|
222
|
+
oauth2.auth.schema.get("flow") === "authorization_code"
|
|
223
|
+
) && !oauth2.auth.code) {
|
|
224
|
+
if (!isValid) {
|
|
225
|
+
oauth2.errCb({
|
|
226
|
+
authId: oauth2.auth.name,
|
|
227
|
+
source: "auth",
|
|
228
|
+
level: "warning",
|
|
229
|
+
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (qp.code) {
|
|
234
|
+
delete oauth2.state;
|
|
235
|
+
oauth2.auth.code = qp.code;
|
|
236
|
+
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
|
237
|
+
} else {
|
|
238
|
+
let oauthErrorMsg;
|
|
239
|
+
if (qp.error) {
|
|
240
|
+
oauthErrorMsg = "["+qp.error+"]: " +
|
|
241
|
+
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
|
242
|
+
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
oauth2.errCb({
|
|
246
|
+
authId: oauth2.auth.name,
|
|
247
|
+
source: "auth",
|
|
248
|
+
level: "error",
|
|
249
|
+
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
|
254
|
+
}
|
|
255
|
+
window.close();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (document.readyState !== 'loading') {
|
|
259
|
+
run();
|
|
260
|
+
} else {
|
|
261
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
262
|
+
run();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
</script>
|
|
266
|
+
</body>
|
|
267
|
+
</html>
|
|
268
|
+
"""
|
|
269
|
+
return HTMLResponse(content=html)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BustAPI OpenAPI Models
|
|
3
|
+
|
|
4
|
+
Simplified OpenAPI 3.1.0 specification models for BustAPI.
|
|
5
|
+
Custom implementation optimized for BustAPI's needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAPIInfo:
|
|
12
|
+
"""API information for OpenAPI spec."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, title: str, version: str, description: Optional[str] = None):
|
|
15
|
+
self.title = title
|
|
16
|
+
self.version = version
|
|
17
|
+
self.description = description
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenAPIServer:
|
|
21
|
+
"""Server information for OpenAPI spec."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, url: str, description: Optional[str] = None):
|
|
24
|
+
self.url = url
|
|
25
|
+
self.description = description
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenAPIResponse:
|
|
29
|
+
"""Response information for OpenAPI spec."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, description: str, content: Optional[Dict[str, Any]] = None):
|
|
32
|
+
self.description = description
|
|
33
|
+
self.content = content or {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenAPIOperation:
|
|
37
|
+
"""Operation information for OpenAPI spec."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
summary: Optional[str] = None,
|
|
42
|
+
description: Optional[str] = None,
|
|
43
|
+
operation_id: Optional[str] = None,
|
|
44
|
+
responses: Optional[Dict[str, OpenAPIResponse]] = None,
|
|
45
|
+
tags: Optional[List[str]] = None,
|
|
46
|
+
):
|
|
47
|
+
self.summary = summary
|
|
48
|
+
self.description = description
|
|
49
|
+
self.operationId = operation_id
|
|
50
|
+
self.responses = responses or {"200": OpenAPIResponse("Successful response")}
|
|
51
|
+
self.tags = tags or []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OpenAPIPathItem:
|
|
55
|
+
"""Path item for OpenAPI spec."""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.get: Optional[OpenAPIOperation] = None
|
|
59
|
+
self.post: Optional[OpenAPIOperation] = None
|
|
60
|
+
self.put: Optional[OpenAPIOperation] = None
|
|
61
|
+
self.delete: Optional[OpenAPIOperation] = None
|
|
62
|
+
self.patch: Optional[OpenAPIOperation] = None
|
|
63
|
+
self.head: Optional[OpenAPIOperation] = None
|
|
64
|
+
self.options: Optional[OpenAPIOperation] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OpenAPISpec:
|
|
68
|
+
"""Complete OpenAPI specification."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
info: OpenAPIInfo,
|
|
73
|
+
servers: Optional[List[OpenAPIServer]] = None,
|
|
74
|
+
paths: Optional[Dict[str, OpenAPIPathItem]] = None,
|
|
75
|
+
):
|
|
76
|
+
self.openapi = "3.1.0"
|
|
77
|
+
self.info = info
|
|
78
|
+
self.servers = servers or []
|
|
79
|
+
self.paths = paths or {}
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
82
|
+
"""Convert to dictionary for JSON serialization."""
|
|
83
|
+
result = {
|
|
84
|
+
"openapi": self.openapi,
|
|
85
|
+
"info": {
|
|
86
|
+
"title": self.info.title,
|
|
87
|
+
"version": self.info.version,
|
|
88
|
+
},
|
|
89
|
+
"paths": {},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if self.info.description:
|
|
93
|
+
result["info"]["description"] = self.info.description
|
|
94
|
+
|
|
95
|
+
if self.servers:
|
|
96
|
+
result["servers"] = [
|
|
97
|
+
{"url": server.url, "description": server.description}
|
|
98
|
+
for server in self.servers
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for path, path_item in self.paths.items():
|
|
102
|
+
path_dict = {}
|
|
103
|
+
|
|
104
|
+
for method in ["get", "post", "put", "delete", "patch", "head", "options"]:
|
|
105
|
+
operation = getattr(path_item, method, None)
|
|
106
|
+
if operation:
|
|
107
|
+
op_dict = {
|
|
108
|
+
"responses": {
|
|
109
|
+
code: {"description": resp.description}
|
|
110
|
+
for code, resp in operation.responses.items()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if operation.summary:
|
|
115
|
+
op_dict["summary"] = operation.summary
|
|
116
|
+
if operation.description:
|
|
117
|
+
op_dict["description"] = operation.description
|
|
118
|
+
if operation.operationId:
|
|
119
|
+
op_dict["operationId"] = operation.operationId
|
|
120
|
+
if operation.tags:
|
|
121
|
+
op_dict["tags"] = operation.tags
|
|
122
|
+
|
|
123
|
+
path_dict[method] = op_dict
|
|
124
|
+
|
|
125
|
+
if path_dict:
|
|
126
|
+
result["paths"][path] = path_dict
|
|
127
|
+
|
|
128
|
+
return result
|
bustapi/openapi/utils.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BustAPI OpenAPI Utils
|
|
3
|
+
|
|
4
|
+
Utilities for generating OpenAPI specifications for BustAPI applications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from .models import (
|
|
10
|
+
OpenAPIInfo,
|
|
11
|
+
OpenAPIOperation,
|
|
12
|
+
OpenAPIPathItem,
|
|
13
|
+
OpenAPIResponse,
|
|
14
|
+
OpenAPIServer,
|
|
15
|
+
OpenAPISpec,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_openapi_spec(
|
|
20
|
+
title: str,
|
|
21
|
+
version: str,
|
|
22
|
+
description: Optional[str] = None,
|
|
23
|
+
routes: Optional[List[Any]] = None,
|
|
24
|
+
servers: Optional[List[Dict[str, str]]] = None,
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Generate OpenAPI specification for BustAPI application.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
title: API title
|
|
31
|
+
version: API version
|
|
32
|
+
description: API description
|
|
33
|
+
routes: List of routes to document
|
|
34
|
+
servers: List of server configurations
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
OpenAPI specification as dictionary
|
|
38
|
+
"""
|
|
39
|
+
# Create info object
|
|
40
|
+
info = OpenAPIInfo(title=title, version=version, description=description)
|
|
41
|
+
|
|
42
|
+
# Create servers
|
|
43
|
+
server_objects = []
|
|
44
|
+
if servers:
|
|
45
|
+
for server in servers:
|
|
46
|
+
server_objects.append(
|
|
47
|
+
OpenAPIServer(
|
|
48
|
+
url=server.get("url", "/"), description=server.get("description")
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Create paths from routes
|
|
53
|
+
paths = {}
|
|
54
|
+
if routes:
|
|
55
|
+
for route in routes:
|
|
56
|
+
path_item = create_path_item_from_route(route)
|
|
57
|
+
if path_item:
|
|
58
|
+
paths[route.get("path", "/")] = path_item
|
|
59
|
+
|
|
60
|
+
# Create OpenAPI spec
|
|
61
|
+
spec = OpenAPISpec(info=info, servers=server_objects, paths=paths)
|
|
62
|
+
|
|
63
|
+
return spec.to_dict()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_path_item_from_route(route: Dict[str, Any]) -> Optional[OpenAPIPathItem]:
|
|
67
|
+
"""
|
|
68
|
+
Create OpenAPI path item from route information.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
route: Route information dictionary
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
OpenAPI path item or None
|
|
75
|
+
"""
|
|
76
|
+
if not route:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
path_item = OpenAPIPathItem()
|
|
80
|
+
methods = route.get("methods", ["GET"])
|
|
81
|
+
handler = route.get("handler")
|
|
82
|
+
|
|
83
|
+
for method in methods:
|
|
84
|
+
method_lower = method.lower()
|
|
85
|
+
if hasattr(path_item, method_lower):
|
|
86
|
+
operation = create_operation_from_handler(handler, method)
|
|
87
|
+
setattr(path_item, method_lower, operation)
|
|
88
|
+
|
|
89
|
+
return path_item
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_operation_from_handler(handler: Any, method: str) -> OpenAPIOperation:
|
|
93
|
+
"""
|
|
94
|
+
Create OpenAPI operation from route handler.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
handler: Route handler function
|
|
98
|
+
method: HTTP method
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
OpenAPI operation
|
|
102
|
+
"""
|
|
103
|
+
if not handler:
|
|
104
|
+
return OpenAPIOperation()
|
|
105
|
+
|
|
106
|
+
# Extract information from handler
|
|
107
|
+
summary = None
|
|
108
|
+
description = None
|
|
109
|
+
operation_id = None
|
|
110
|
+
|
|
111
|
+
if hasattr(handler, "__name__"):
|
|
112
|
+
operation_id = f"{method.lower()}_{handler.__name__}"
|
|
113
|
+
|
|
114
|
+
if hasattr(handler, "__doc__") and handler.__doc__:
|
|
115
|
+
doc_lines = handler.__doc__.strip().split("\n")
|
|
116
|
+
if doc_lines:
|
|
117
|
+
summary = doc_lines[0].strip()
|
|
118
|
+
if len(doc_lines) > 1:
|
|
119
|
+
description = "\n".join(line.strip() for line in doc_lines[1:]).strip()
|
|
120
|
+
|
|
121
|
+
# Create default responses
|
|
122
|
+
responses = {
|
|
123
|
+
"200": OpenAPIResponse("Successful response"),
|
|
124
|
+
"422": OpenAPIResponse("Validation Error"),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return OpenAPIOperation(
|
|
128
|
+
summary=summary,
|
|
129
|
+
description=description,
|
|
130
|
+
operation_id=operation_id,
|
|
131
|
+
responses=responses,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_route_info(app) -> List[Dict[str, Any]]:
|
|
136
|
+
"""
|
|
137
|
+
Extract route information from BustAPI application.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
app: BustAPI application instance
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List of route information dictionaries
|
|
144
|
+
"""
|
|
145
|
+
routes = []
|
|
146
|
+
|
|
147
|
+
# Try to get routes from the app
|
|
148
|
+
if hasattr(app, "_routes"):
|
|
149
|
+
for path, route_info in app._routes.items():
|
|
150
|
+
routes.append(
|
|
151
|
+
{
|
|
152
|
+
"path": path,
|
|
153
|
+
"methods": route_info.get("methods", ["GET"]),
|
|
154
|
+
"handler": route_info.get("handler"),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return routes
|
bustapi/templating.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Simple Jinja2-based templating helpers for BustAPI.
|
|
2
|
+
|
|
3
|
+
This module provides a small wrapper around Jinja2 Environment creation and
|
|
4
|
+
rendering so the application can call `render_template` similarly to Flask.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import jinja2
|
|
11
|
+
except Exception: # pragma: no cover - optional dependency
|
|
12
|
+
jinja2 = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_jinja_env(template_folder: Optional[str] = None):
|
|
16
|
+
if jinja2 is None:
|
|
17
|
+
raise RuntimeError(
|
|
18
|
+
"Jinja2 is not installed. Add 'jinja2' to your dependencies."
|
|
19
|
+
)
|
|
20
|
+
loader = jinja2.FileSystemLoader(template_folder or "templates")
|
|
21
|
+
env = jinja2.Environment(loader=loader, autoescape=True)
|
|
22
|
+
return env
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_template(
|
|
26
|
+
env, template_name: str, context: Optional[Dict[str, Any]] = None
|
|
27
|
+
) -> str:
|
|
28
|
+
context = context or {}
|
|
29
|
+
template = env.get_template(template_name)
|
|
30
|
+
return template.render(**context)
|
bustapi/testing.py
CHANGED
|
@@ -92,7 +92,8 @@ class TestResponse:
|
|
|
92
92
|
return "application/json" in content_type.lower()
|
|
93
93
|
|
|
94
94
|
def __repr__(self) -> str:
|
|
95
|
-
|
|
95
|
+
content_type = self.headers.get("Content-Type", "")
|
|
96
|
+
return f"<TestResponse {self.status_code} [{content_type}]>"
|
|
96
97
|
|
|
97
98
|
|
|
98
99
|
class TestClient:
|