bustapi 0.1.0__cp311-cp311-macosx_11_0_arm64.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 +96 -0
- bustapi/app.py +552 -0
- bustapi/blueprints.py +500 -0
- bustapi/bustapi_core.cpython-311-darwin.so +0 -0
- bustapi/exceptions.py +438 -0
- bustapi/flask_compat.py +62 -0
- bustapi/helpers.py +370 -0
- bustapi/py.typed +0 -0
- bustapi/request.py +378 -0
- bustapi/response.py +406 -0
- bustapi/testing.py +375 -0
- bustapi-0.1.0.dist-info/METADATA +233 -0
- bustapi-0.1.0.dist-info/RECORD +16 -0
- bustapi-0.1.0.dist-info/WHEEL +4 -0
- bustapi-0.1.0.dist-info/entry_points.txt +2 -0
- bustapi-0.1.0.dist-info/licenses/LICENSE +21 -0
bustapi/response.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response handling for BustAPI - Flask-compatible response objects
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from typing import Any, Dict, Iterable, Optional, Union
|
|
8
|
+
|
|
9
|
+
from werkzeug.datastructures import Headers
|
|
10
|
+
|
|
11
|
+
ResponseType = Union[str, bytes, dict, list, tuple, "Response"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Response:
|
|
15
|
+
"""
|
|
16
|
+
Flask-compatible response object.
|
|
17
|
+
|
|
18
|
+
This class represents an HTTP response and provides methods to set
|
|
19
|
+
response data, status codes, and headers in a Flask-compatible way.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
response: Any = None,
|
|
25
|
+
status: Optional[int] = None,
|
|
26
|
+
headers: Optional[Union[Dict, Headers]] = None,
|
|
27
|
+
mimetype: Optional[str] = None,
|
|
28
|
+
content_type: Optional[str] = None,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Initialize response object.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
response: Response data (string, bytes, dict, etc.)
|
|
35
|
+
status: HTTP status code
|
|
36
|
+
headers: Response headers
|
|
37
|
+
mimetype: MIME type
|
|
38
|
+
content_type: Content type header
|
|
39
|
+
"""
|
|
40
|
+
self.status_code = status or 200
|
|
41
|
+
self.headers = Headers(headers) if headers else Headers()
|
|
42
|
+
|
|
43
|
+
# Set response data
|
|
44
|
+
if response is not None:
|
|
45
|
+
self.set_data(response)
|
|
46
|
+
else:
|
|
47
|
+
self.data = b""
|
|
48
|
+
|
|
49
|
+
# Set content type
|
|
50
|
+
if content_type:
|
|
51
|
+
self.content_type = content_type
|
|
52
|
+
elif mimetype:
|
|
53
|
+
self.content_type = mimetype
|
|
54
|
+
elif not self.content_type:
|
|
55
|
+
self.content_type = "text/html; charset=utf-8"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def status(self) -> str:
|
|
59
|
+
"""Status code and reason phrase."""
|
|
60
|
+
try:
|
|
61
|
+
status_obj = HTTPStatus(self.status_code)
|
|
62
|
+
return f"{self.status_code} {status_obj.phrase}"
|
|
63
|
+
except ValueError:
|
|
64
|
+
return str(self.status_code)
|
|
65
|
+
|
|
66
|
+
@status.setter
|
|
67
|
+
def status(self, value: Union[str, int]) -> None:
|
|
68
|
+
"""Set status code."""
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
# Parse "200 OK" format
|
|
71
|
+
self.status_code = int(value.split()[0])
|
|
72
|
+
else:
|
|
73
|
+
self.status_code = value
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def content_type(self) -> str:
|
|
77
|
+
"""Content type header."""
|
|
78
|
+
return self.headers.get("Content-Type", "")
|
|
79
|
+
|
|
80
|
+
@content_type.setter
|
|
81
|
+
def content_type(self, value: str) -> None:
|
|
82
|
+
"""Set content type header."""
|
|
83
|
+
self.headers["Content-Type"] = value
|
|
84
|
+
|
|
85
|
+
def set_data(self, data: Any) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Set response data.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
data: Response data to set
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(data, str):
|
|
93
|
+
self.data = data.encode("utf-8")
|
|
94
|
+
if not self.content_type:
|
|
95
|
+
self.content_type = "text/html; charset=utf-8"
|
|
96
|
+
elif isinstance(data, bytes):
|
|
97
|
+
self.data = data
|
|
98
|
+
elif isinstance(data, (dict, list)):
|
|
99
|
+
# Serialize as JSON
|
|
100
|
+
self.data = json.dumps(data).encode("utf-8")
|
|
101
|
+
self.content_type = "application/json"
|
|
102
|
+
else:
|
|
103
|
+
# Convert to string and encode
|
|
104
|
+
self.data = str(data).encode("utf-8")
|
|
105
|
+
if not self.content_type:
|
|
106
|
+
self.content_type = "text/html; charset=utf-8"
|
|
107
|
+
|
|
108
|
+
def get_data(self, as_text: bool = False) -> Union[bytes, str]:
|
|
109
|
+
"""
|
|
110
|
+
Get response data.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
as_text: Return as text instead of bytes
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Response data as bytes or text
|
|
117
|
+
"""
|
|
118
|
+
if as_text:
|
|
119
|
+
return self.data.decode("utf-8", errors="replace")
|
|
120
|
+
return self.data
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def response(self) -> Iterable[bytes]:
|
|
124
|
+
"""Response data as iterable of bytes."""
|
|
125
|
+
return [self.data]
|
|
126
|
+
|
|
127
|
+
def set_cookie(
|
|
128
|
+
self,
|
|
129
|
+
key: str,
|
|
130
|
+
value: str = "",
|
|
131
|
+
max_age: Optional[int] = None,
|
|
132
|
+
expires: Optional[str] = None,
|
|
133
|
+
path: str = "/",
|
|
134
|
+
domain: Optional[str] = None,
|
|
135
|
+
secure: bool = False,
|
|
136
|
+
httponly: bool = False,
|
|
137
|
+
samesite: Optional[str] = None,
|
|
138
|
+
):
|
|
139
|
+
"""
|
|
140
|
+
Set a cookie.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
key: Cookie name
|
|
144
|
+
value: Cookie value
|
|
145
|
+
max_age: Maximum age in seconds
|
|
146
|
+
expires: Expiration date
|
|
147
|
+
path: Cookie path
|
|
148
|
+
domain: Cookie domain
|
|
149
|
+
secure: Secure flag
|
|
150
|
+
httponly: HttpOnly flag
|
|
151
|
+
samesite: SameSite attribute
|
|
152
|
+
"""
|
|
153
|
+
cookie_parts = [f"{key}={value}"]
|
|
154
|
+
|
|
155
|
+
if max_age is not None:
|
|
156
|
+
cookie_parts.append(f"Max-Age={max_age}")
|
|
157
|
+
if expires:
|
|
158
|
+
cookie_parts.append(f"Expires={expires}")
|
|
159
|
+
if path:
|
|
160
|
+
cookie_parts.append(f"Path={path}")
|
|
161
|
+
if domain:
|
|
162
|
+
cookie_parts.append(f"Domain={domain}")
|
|
163
|
+
if secure:
|
|
164
|
+
cookie_parts.append("Secure")
|
|
165
|
+
if httponly:
|
|
166
|
+
cookie_parts.append("HttpOnly")
|
|
167
|
+
if samesite:
|
|
168
|
+
cookie_parts.append(f"SameSite={samesite}")
|
|
169
|
+
|
|
170
|
+
cookie_string = "; ".join(cookie_parts)
|
|
171
|
+
|
|
172
|
+
# Add to existing Set-Cookie headers
|
|
173
|
+
if "Set-Cookie" in self.headers:
|
|
174
|
+
existing = self.headers.getlist("Set-Cookie")
|
|
175
|
+
existing.append(cookie_string)
|
|
176
|
+
self.headers.setlist("Set-Cookie", existing)
|
|
177
|
+
else:
|
|
178
|
+
self.headers["Set-Cookie"] = cookie_string
|
|
179
|
+
|
|
180
|
+
def delete_cookie(self, key: str, path: str = "/", domain: Optional[str] = None):
|
|
181
|
+
"""
|
|
182
|
+
Delete a cookie by setting it to expire.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
key: Cookie name
|
|
186
|
+
path: Cookie path
|
|
187
|
+
domain: Cookie domain
|
|
188
|
+
"""
|
|
189
|
+
self.set_cookie(
|
|
190
|
+
key, "", expires="Thu, 01 Jan 1970 00:00:00 GMT", path=path, domain=domain
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def __repr__(self) -> str:
|
|
194
|
+
return f"<Response {self.status_code} [{self.content_type}]>"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def make_response(*args) -> Response:
|
|
198
|
+
"""
|
|
199
|
+
Create a Response object from various input types (Flask-compatible).
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
*args: Response arguments - can be:
|
|
203
|
+
- (response,)
|
|
204
|
+
- (response, status)
|
|
205
|
+
- (response, headers)
|
|
206
|
+
- (response, status, headers)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Response object
|
|
210
|
+
"""
|
|
211
|
+
if not args:
|
|
212
|
+
return Response()
|
|
213
|
+
|
|
214
|
+
if len(args) == 1:
|
|
215
|
+
rv = args[0]
|
|
216
|
+
if isinstance(rv, Response):
|
|
217
|
+
return rv
|
|
218
|
+
return Response(rv)
|
|
219
|
+
|
|
220
|
+
if len(args) == 2:
|
|
221
|
+
rv, status_or_headers = args
|
|
222
|
+
if isinstance(status_or_headers, (int, str)):
|
|
223
|
+
# (response, status)
|
|
224
|
+
return Response(rv, status=status_or_headers)
|
|
225
|
+
else:
|
|
226
|
+
# (response, headers)
|
|
227
|
+
return Response(rv, headers=status_or_headers)
|
|
228
|
+
|
|
229
|
+
if len(args) == 3:
|
|
230
|
+
# (response, status, headers)
|
|
231
|
+
rv, status, headers = args
|
|
232
|
+
return Response(rv, status=status, headers=headers)
|
|
233
|
+
|
|
234
|
+
raise TypeError(f"make_response() takes 1 to 3 arguments ({len(args)} given)")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def jsonify(*args, **kwargs) -> Response:
|
|
238
|
+
"""
|
|
239
|
+
Create a JSON response (Flask-compatible).
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
*args: Positional arguments for JSON data
|
|
243
|
+
**kwargs: Keyword arguments for JSON data
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Response object with JSON data
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
jsonify({'key': 'value'})
|
|
250
|
+
jsonify(key='value')
|
|
251
|
+
jsonify([1, 2, 3])
|
|
252
|
+
"""
|
|
253
|
+
if args and kwargs:
|
|
254
|
+
raise TypeError("jsonify() behavior with mixed arguments is deprecated")
|
|
255
|
+
|
|
256
|
+
if args:
|
|
257
|
+
if len(args) == 1:
|
|
258
|
+
data = args[0]
|
|
259
|
+
else:
|
|
260
|
+
data = args
|
|
261
|
+
else:
|
|
262
|
+
data = kwargs
|
|
263
|
+
|
|
264
|
+
response = Response()
|
|
265
|
+
response.set_data(data)
|
|
266
|
+
response.content_type = "application/json"
|
|
267
|
+
|
|
268
|
+
return response
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# HTTP status code helpers
|
|
272
|
+
def abort(code: int, description: Optional[str] = None, **kwargs):
|
|
273
|
+
"""
|
|
274
|
+
Abort request with HTTP error code.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
code: HTTP status code
|
|
278
|
+
description: Error description
|
|
279
|
+
**kwargs: Additional arguments
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
HTTPException: HTTP exception with specified code
|
|
283
|
+
"""
|
|
284
|
+
from .exceptions import HTTPException
|
|
285
|
+
|
|
286
|
+
raise HTTPException(code, description=description)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class HTTPException(Exception):
|
|
290
|
+
"""HTTP exception for error responses."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, code: int, description: Optional[str] = None):
|
|
293
|
+
self.code = code
|
|
294
|
+
self.description = description or self._get_default_description(code)
|
|
295
|
+
super().__init__(self.description)
|
|
296
|
+
|
|
297
|
+
def _get_default_description(self, code: int) -> str:
|
|
298
|
+
"""Get default description for HTTP status code."""
|
|
299
|
+
try:
|
|
300
|
+
return HTTPStatus(code).phrase
|
|
301
|
+
except ValueError:
|
|
302
|
+
return f"HTTP {code}"
|
|
303
|
+
|
|
304
|
+
def get_response(self) -> Response:
|
|
305
|
+
"""Get response object for this exception."""
|
|
306
|
+
return Response(self.description, status=self.code)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# Common HTTP exceptions
|
|
310
|
+
class BadRequest(HTTPException):
|
|
311
|
+
def __init__(self, description: Optional[str] = None):
|
|
312
|
+
super().__init__(400, description)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class Unauthorized(HTTPException):
|
|
316
|
+
def __init__(self, description: Optional[str] = None):
|
|
317
|
+
super().__init__(401, description)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class Forbidden(HTTPException):
|
|
321
|
+
def __init__(self, description: Optional[str] = None):
|
|
322
|
+
super().__init__(403, description)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class NotFound(HTTPException):
|
|
326
|
+
def __init__(self, description: Optional[str] = None):
|
|
327
|
+
super().__init__(404, description)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class MethodNotAllowed(HTTPException):
|
|
331
|
+
def __init__(self, description: Optional[str] = None):
|
|
332
|
+
super().__init__(405, description)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class InternalServerError(HTTPException):
|
|
336
|
+
def __init__(self, description: Optional[str] = None):
|
|
337
|
+
super().__init__(500, description)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Redirect response
|
|
341
|
+
def redirect(location: str, code: int = 302, Response: type = Response) -> Response:
|
|
342
|
+
"""
|
|
343
|
+
Create a redirect response.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
location: Redirect URL
|
|
347
|
+
code: HTTP status code (301, 302, etc.)
|
|
348
|
+
Response: Response class to use
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Redirect response
|
|
352
|
+
"""
|
|
353
|
+
response = Response("", status=code)
|
|
354
|
+
response.headers["Location"] = location
|
|
355
|
+
return response
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Static response helpers
|
|
359
|
+
def send_file(
|
|
360
|
+
file_path: str,
|
|
361
|
+
mimetype: Optional[str] = None,
|
|
362
|
+
as_attachment: bool = False,
|
|
363
|
+
attachment_filename: Optional[str] = None,
|
|
364
|
+
):
|
|
365
|
+
"""
|
|
366
|
+
Send a file as response (placeholder implementation).
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
file_path: Path to file
|
|
370
|
+
mimetype: MIME type
|
|
371
|
+
as_attachment: Send as attachment
|
|
372
|
+
attachment_filename: Attachment filename
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Response object
|
|
376
|
+
|
|
377
|
+
Note:
|
|
378
|
+
This is a placeholder implementation. Full file serving
|
|
379
|
+
should be implemented in the Rust backend for performance.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
with open(file_path, "rb") as f:
|
|
383
|
+
data = f.read()
|
|
384
|
+
|
|
385
|
+
response = Response(data)
|
|
386
|
+
|
|
387
|
+
if mimetype:
|
|
388
|
+
response.content_type = mimetype
|
|
389
|
+
else:
|
|
390
|
+
# Try to guess content type based on file extension
|
|
391
|
+
import mimetypes
|
|
392
|
+
|
|
393
|
+
guessed_type, _ = mimetypes.guess_type(file_path)
|
|
394
|
+
if guessed_type:
|
|
395
|
+
response.content_type = guessed_type
|
|
396
|
+
|
|
397
|
+
if as_attachment:
|
|
398
|
+
filename = attachment_filename or file_path.split("/")[-1]
|
|
399
|
+
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
|
|
400
|
+
|
|
401
|
+
return response
|
|
402
|
+
|
|
403
|
+
except FileNotFoundError:
|
|
404
|
+
abort(404, description="File not found")
|
|
405
|
+
except PermissionError:
|
|
406
|
+
abort(403, description="Permission denied")
|