tachyon-api 0.5.7__tar.gz → 0.5.9__tar.gz
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 tachyon-api might be problematic. Click here for more details.
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/PKG-INFO +6 -5
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/README.md +5 -3
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/pyproject.toml +2 -2
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/app.py +219 -252
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/cache.py +14 -5
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/middlewares/__init__.py +0 -1
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/middlewares/cors.py +29 -12
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/middlewares/logger.py +6 -2
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/openapi.py +7 -9
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/responses.py +21 -2
- tachyon_api-0.5.9/tachyon_api/utils/__init__.py +14 -0
- tachyon_api-0.5.9/tachyon_api/utils/type_converter.py +113 -0
- tachyon_api-0.5.9/tachyon_api/utils/type_utils.py +111 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/LICENSE +0 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/__init__.py +0 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/di.py +0 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/middlewares/core.py +0 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/models.py +0 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/params.py +0 -0
- {tachyon_api-0.5.7 → tachyon_api-0.5.9}/tachyon_api/router.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: tachyon-api
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.9
|
|
4
4
|
Summary: A lightweight, FastAPI-inspired web framework
|
|
5
5
|
License: GPL-3.0-or-later
|
|
6
6
|
Author: Juan Manuel Panozzo Zénere
|
|
@@ -14,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
15
|
Requires-Dist: msgspec (>=0.19.0,<0.20.0)
|
|
16
16
|
Requires-Dist: orjson (>=3.11.1,<4.0.0)
|
|
17
|
-
Requires-Dist: ruff (>=0.12.7,<0.13.0)
|
|
18
17
|
Requires-Dist: starlette (>=0.47.2,<0.48.0)
|
|
19
18
|
Requires-Dist: typer (>=0.16.0,<0.17.0)
|
|
20
19
|
Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
|
|
@@ -59,7 +58,7 @@ def create_user(user: User):
|
|
|
59
58
|
- 🔄 Middlewares (class + decorator)
|
|
60
59
|
- 🧠 Cache decorator with TTL (in-memory, Redis, Memcached)
|
|
61
60
|
- 🚀 High-performance JSON (msgspec + orjson)
|
|
62
|
-
- 🧾 Unified error format (422/500)
|
|
61
|
+
- 🧾 Unified error format (422/500) + global exception handler (500)
|
|
63
62
|
- 🧰 Default JSON response (TachyonJSONResponse)
|
|
64
63
|
- 🔒 End-to-end safety: request Body validation + typed response_model
|
|
65
64
|
- 📘 Deep OpenAPI schemas: nested Structs, Optional/List (nullable/array), formats (uuid, date-time)
|
|
@@ -82,7 +81,7 @@ Tachyon API is built with TDD principles at its core. The test suite covers rout
|
|
|
82
81
|
|
|
83
82
|
## 🔄 Middleware Support
|
|
84
83
|
|
|
85
|
-
- Built-in: CORSMiddleware
|
|
84
|
+
- Built-in: CORSMiddleware and LoggerMiddleware
|
|
86
85
|
- Use app.add_middleware(...) or @app.middleware()
|
|
87
86
|
|
|
88
87
|
## ⚡ Cache with TTL
|
|
@@ -92,10 +91,11 @@ Tachyon API is built with TDD principles at its core. The test suite covers rout
|
|
|
92
91
|
|
|
93
92
|
## 📚 Example Application
|
|
94
93
|
|
|
95
|
-
The example demonstrates clean architecture, routers, middlewares, caching,
|
|
94
|
+
The example demonstrates clean architecture, routers, middlewares, caching, end-to-end safety, and global exception handling:
|
|
96
95
|
|
|
97
96
|
- /orjson-demo: default JSON powered by orjson
|
|
98
97
|
- /api/v1/users/e2e: Body + response_model, unified errors and deep OpenAPI schemas
|
|
98
|
+
- /error-demo: triggers an unhandled exception to showcase the global handler (structured 500)
|
|
99
99
|
|
|
100
100
|
Run the example:
|
|
101
101
|
|
|
@@ -117,6 +117,7 @@ Docs at /docs (Scalar), /swagger, /redoc.
|
|
|
117
117
|
- Default response: TachyonJSONResponse serializes complex types (UUID/date/datetime, Struct) via orjson and centralized encoders.
|
|
118
118
|
- 422 Validation: { success: false, error, code: VALIDATION_ERROR, [errors] }.
|
|
119
119
|
- 500 Response model: { success: false, error: "Response validation error: ...", detail, code: RESPONSE_VALIDATION_ERROR }.
|
|
120
|
+
- 500 Unhandled exceptions (global): { success: false, error: "Internal Server Error", code: INTERNAL_SERVER_ERROR }.
|
|
120
121
|
|
|
121
122
|
## 📝 Contributing
|
|
122
123
|
|
|
@@ -37,7 +37,7 @@ def create_user(user: User):
|
|
|
37
37
|
- 🔄 Middlewares (class + decorator)
|
|
38
38
|
- 🧠 Cache decorator with TTL (in-memory, Redis, Memcached)
|
|
39
39
|
- 🚀 High-performance JSON (msgspec + orjson)
|
|
40
|
-
- 🧾 Unified error format (422/500)
|
|
40
|
+
- 🧾 Unified error format (422/500) + global exception handler (500)
|
|
41
41
|
- 🧰 Default JSON response (TachyonJSONResponse)
|
|
42
42
|
- 🔒 End-to-end safety: request Body validation + typed response_model
|
|
43
43
|
- 📘 Deep OpenAPI schemas: nested Structs, Optional/List (nullable/array), formats (uuid, date-time)
|
|
@@ -60,7 +60,7 @@ Tachyon API is built with TDD principles at its core. The test suite covers rout
|
|
|
60
60
|
|
|
61
61
|
## 🔄 Middleware Support
|
|
62
62
|
|
|
63
|
-
- Built-in: CORSMiddleware
|
|
63
|
+
- Built-in: CORSMiddleware and LoggerMiddleware
|
|
64
64
|
- Use app.add_middleware(...) or @app.middleware()
|
|
65
65
|
|
|
66
66
|
## ⚡ Cache with TTL
|
|
@@ -70,10 +70,11 @@ Tachyon API is built with TDD principles at its core. The test suite covers rout
|
|
|
70
70
|
|
|
71
71
|
## 📚 Example Application
|
|
72
72
|
|
|
73
|
-
The example demonstrates clean architecture, routers, middlewares, caching,
|
|
73
|
+
The example demonstrates clean architecture, routers, middlewares, caching, end-to-end safety, and global exception handling:
|
|
74
74
|
|
|
75
75
|
- /orjson-demo: default JSON powered by orjson
|
|
76
76
|
- /api/v1/users/e2e: Body + response_model, unified errors and deep OpenAPI schemas
|
|
77
|
+
- /error-demo: triggers an unhandled exception to showcase the global handler (structured 500)
|
|
77
78
|
|
|
78
79
|
Run the example:
|
|
79
80
|
|
|
@@ -95,6 +96,7 @@ Docs at /docs (Scalar), /swagger, /redoc.
|
|
|
95
96
|
- Default response: TachyonJSONResponse serializes complex types (UUID/date/datetime, Struct) via orjson and centralized encoders.
|
|
96
97
|
- 422 Validation: { success: false, error, code: VALIDATION_ERROR, [errors] }.
|
|
97
98
|
- 500 Response model: { success: false, error: "Response validation error: ...", detail, code: RESPONSE_VALIDATION_ERROR }.
|
|
99
|
+
- 500 Unhandled exceptions (global): { success: false, error: "Internal Server Error", code: INTERNAL_SERVER_ERROR }.
|
|
98
100
|
|
|
99
101
|
## 📝 Contributing
|
|
100
102
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "tachyon-api"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.9"
|
|
4
4
|
description = "A lightweight, FastAPI-inspired web framework"
|
|
5
5
|
authors = ["Juan Manuel Panozzo Zénere <jm.panozzozenere@gmail.com>"]
|
|
6
6
|
license = "GPL-3.0-or-later"
|
|
@@ -12,7 +12,6 @@ starlette = "^0.47.2"
|
|
|
12
12
|
uvicorn = "^0.35.0"
|
|
13
13
|
msgspec = "^0.19.0"
|
|
14
14
|
typer = "^0.16.0"
|
|
15
|
-
ruff = "^0.12.7"
|
|
16
15
|
orjson = "^3.11.1"
|
|
17
16
|
|
|
18
17
|
|
|
@@ -20,6 +19,7 @@ orjson = "^3.11.1"
|
|
|
20
19
|
pytest = "^8.4.1"
|
|
21
20
|
pytest-asyncio = "^1.1.0"
|
|
22
21
|
httpx = "^0.28.1"
|
|
22
|
+
ruff = "^0.12.7"
|
|
23
23
|
|
|
24
24
|
[build-system]
|
|
25
25
|
requires = ["poetry-core"]
|
|
@@ -23,7 +23,6 @@ from .openapi import (
|
|
|
23
23
|
OpenAPIGenerator,
|
|
24
24
|
OpenAPIConfig,
|
|
25
25
|
create_openapi_config,
|
|
26
|
-
_generate_schema_for_struct,
|
|
27
26
|
)
|
|
28
27
|
from .params import Body, Query, Path
|
|
29
28
|
from .middlewares.core import (
|
|
@@ -35,10 +34,14 @@ from .responses import (
|
|
|
35
34
|
validation_error_response,
|
|
36
35
|
response_validation_error_response,
|
|
37
36
|
)
|
|
38
|
-
|
|
37
|
+
|
|
38
|
+
from .utils import TypeConverter, TypeUtils
|
|
39
|
+
|
|
40
|
+
from .responses import internal_server_error_response
|
|
41
|
+
|
|
39
42
|
try:
|
|
40
43
|
from .cache import set_cache_config
|
|
41
|
-
except
|
|
44
|
+
except ImportError:
|
|
42
45
|
set_cache_config = None # type: ignore
|
|
43
46
|
|
|
44
47
|
|
|
@@ -96,65 +99,6 @@ class Tachyon:
|
|
|
96
99
|
partial(self._create_decorator, http_method=method),
|
|
97
100
|
)
|
|
98
101
|
|
|
99
|
-
@staticmethod
|
|
100
|
-
def _convert_value(
|
|
101
|
-
value_str: str,
|
|
102
|
-
target_type: Type,
|
|
103
|
-
param_name: str,
|
|
104
|
-
is_path_param: bool = False, # noqa
|
|
105
|
-
) -> Union[Any, JSONResponse]:
|
|
106
|
-
"""
|
|
107
|
-
Convert a string value to the target type with appropriate error handling.
|
|
108
|
-
|
|
109
|
-
This method handles type conversion for query and path parameters,
|
|
110
|
-
including special handling for boolean values and proper error responses.
|
|
111
|
-
|
|
112
|
-
Args:
|
|
113
|
-
value_str: The string value to convert
|
|
114
|
-
target_type: The target Python type to convert to
|
|
115
|
-
param_name: Name of the parameter (for error messages)
|
|
116
|
-
is_path_param: Whether this is a path parameter (affects error response)
|
|
117
|
-
|
|
118
|
-
Returns:
|
|
119
|
-
The converted value, or a JSONResponse with appropriate error code
|
|
120
|
-
|
|
121
|
-
Note:
|
|
122
|
-
- Boolean conversion accepts: "true", "1", "t", "yes" (case-insensitive)
|
|
123
|
-
- Path parameter errors return 404, query parameter errors return 422
|
|
124
|
-
"""
|
|
125
|
-
# Unwrap Optional/Union[T, None]
|
|
126
|
-
origin = typing.get_origin(target_type)
|
|
127
|
-
args = typing.get_args(target_type)
|
|
128
|
-
if origin is Union and args:
|
|
129
|
-
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
130
|
-
if len(non_none) == 1:
|
|
131
|
-
target_type = non_none[0]
|
|
132
|
-
|
|
133
|
-
try:
|
|
134
|
-
if target_type is bool:
|
|
135
|
-
return value_str.lower() in ("true", "1", "t", "yes")
|
|
136
|
-
elif target_type is not str:
|
|
137
|
-
return target_type(value_str)
|
|
138
|
-
else:
|
|
139
|
-
return value_str
|
|
140
|
-
except (ValueError, TypeError):
|
|
141
|
-
if is_path_param:
|
|
142
|
-
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
143
|
-
else:
|
|
144
|
-
type_name = "integer" if target_type is int else getattr(target_type, "__name__", str(target_type))
|
|
145
|
-
return validation_error_response(f"Invalid value for {type_name} conversion")
|
|
146
|
-
|
|
147
|
-
@staticmethod
|
|
148
|
-
def _unwrap_optional(python_type: Type) -> tuple[Type, bool]:
|
|
149
|
-
"""Return (inner_type, is_optional) for Optional[T] or (python_type, False)."""
|
|
150
|
-
origin = typing.get_origin(python_type)
|
|
151
|
-
args = typing.get_args(python_type)
|
|
152
|
-
if origin is Union and args:
|
|
153
|
-
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
154
|
-
if len(non_none) == 1:
|
|
155
|
-
return non_none[0], True
|
|
156
|
-
return python_type, False
|
|
157
|
-
|
|
158
102
|
def _resolve_dependency(self, cls: Type) -> Any:
|
|
159
103
|
"""
|
|
160
104
|
Resolve a dependency and its sub-dependencies recursively.
|
|
@@ -257,137 +201,190 @@ class Tachyon:
|
|
|
257
201
|
This handler analyzes the endpoint function signature and automatically
|
|
258
202
|
injects the appropriate values based on parameter annotations and defaults.
|
|
259
203
|
"""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
# Process dependencies (explicit and implicit)
|
|
276
|
-
if is_explicit_dependency or is_implicit_dependency:
|
|
277
|
-
target_class = param.annotation
|
|
278
|
-
kwargs_to_inject[param.name] = self._resolve_dependency(
|
|
279
|
-
target_class
|
|
204
|
+
try:
|
|
205
|
+
kwargs_to_inject = {}
|
|
206
|
+
sig = inspect.signature(endpoint_func)
|
|
207
|
+
query_params = request.query_params
|
|
208
|
+
path_params = request.path_params
|
|
209
|
+
_raw_body = None
|
|
210
|
+
|
|
211
|
+
# Process each parameter in the endpoint function signature
|
|
212
|
+
for param in sig.parameters.values():
|
|
213
|
+
# Determine if this parameter is a dependency
|
|
214
|
+
is_explicit_dependency = isinstance(param.default, Depends)
|
|
215
|
+
is_implicit_dependency = (
|
|
216
|
+
param.default is inspect.Parameter.empty
|
|
217
|
+
and param.annotation in _registry
|
|
280
218
|
)
|
|
281
219
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
"Body type must be an instance of Tachyon_api.models.Struct"
|
|
220
|
+
# Process dependencies (explicit and implicit)
|
|
221
|
+
if is_explicit_dependency or is_implicit_dependency:
|
|
222
|
+
target_class = param.annotation
|
|
223
|
+
kwargs_to_inject[param.name] = self._resolve_dependency(
|
|
224
|
+
target_class
|
|
288
225
|
)
|
|
289
226
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
227
|
+
# Process Body parameters (JSON request body)
|
|
228
|
+
elif isinstance(param.default, Body):
|
|
229
|
+
model_class = param.annotation
|
|
230
|
+
if not issubclass(model_class, Struct):
|
|
231
|
+
raise TypeError(
|
|
232
|
+
"Body type must be an instance of Tachyon_api.models.Struct"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
decoder = msgspec.json.Decoder(model_class)
|
|
299
236
|
try:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
field_name = p
|
|
307
|
-
break
|
|
308
|
-
if field_name:
|
|
309
|
-
field_errors = {field_name: [str(e)]}
|
|
310
|
-
except Exception:
|
|
237
|
+
if _raw_body is None:
|
|
238
|
+
_raw_body = await request.body()
|
|
239
|
+
validated_data = decoder.decode(_raw_body)
|
|
240
|
+
kwargs_to_inject[param.name] = validated_data
|
|
241
|
+
except msgspec.ValidationError as e:
|
|
242
|
+
# Attempt to build field errors map using e.path
|
|
311
243
|
field_errors = None
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if origin in (list, typing.List):
|
|
326
|
-
item_type = args[0] if args else str
|
|
327
|
-
values = []
|
|
328
|
-
# collect repeated params
|
|
329
|
-
if hasattr(query_params, "getlist"):
|
|
330
|
-
values = query_params.getlist(param_name)
|
|
331
|
-
# if not repeated, check for CSV in single value
|
|
332
|
-
if not values and param_name in query_params:
|
|
333
|
-
raw = query_params[param_name]
|
|
334
|
-
values = raw.split(",") if "," in raw else [raw]
|
|
335
|
-
# flatten CSV in any element
|
|
336
|
-
flat_values = []
|
|
337
|
-
for v in values:
|
|
338
|
-
if isinstance(v, str) and "," in v:
|
|
339
|
-
flat_values.extend(v.split(","))
|
|
340
|
-
else:
|
|
341
|
-
flat_values.append(v)
|
|
342
|
-
values = flat_values
|
|
343
|
-
if not values:
|
|
344
|
-
if query_info.default is not ...:
|
|
345
|
-
kwargs_to_inject[param_name] = query_info.default
|
|
346
|
-
continue
|
|
244
|
+
try:
|
|
245
|
+
path = getattr(e, "path", None)
|
|
246
|
+
if path:
|
|
247
|
+
# Choose last string-ish path element as field name
|
|
248
|
+
field_name = None
|
|
249
|
+
for p in reversed(path):
|
|
250
|
+
if isinstance(p, str):
|
|
251
|
+
field_name = p
|
|
252
|
+
break
|
|
253
|
+
if field_name:
|
|
254
|
+
field_errors = {field_name: [str(e)]}
|
|
255
|
+
except Exception:
|
|
256
|
+
field_errors = None
|
|
347
257
|
return validation_error_response(
|
|
348
|
-
|
|
258
|
+
str(e), errors=field_errors
|
|
349
259
|
)
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
260
|
+
|
|
261
|
+
# Process Query parameters (URL query string)
|
|
262
|
+
elif isinstance(param.default, Query):
|
|
263
|
+
query_info = param.default
|
|
264
|
+
param_name = param.name
|
|
265
|
+
|
|
266
|
+
# Determine typing for advanced cases
|
|
267
|
+
ann = param.annotation
|
|
268
|
+
origin = typing.get_origin(ann)
|
|
269
|
+
args = typing.get_args(ann)
|
|
270
|
+
|
|
271
|
+
# List[T] handling
|
|
272
|
+
if origin in (list, typing.List):
|
|
273
|
+
item_type = args[0] if args else str
|
|
274
|
+
values = []
|
|
275
|
+
# collect repeated params
|
|
276
|
+
if hasattr(query_params, "getlist"):
|
|
277
|
+
values = query_params.getlist(param_name)
|
|
278
|
+
# if not repeated, check for CSV in single value
|
|
279
|
+
if not values and param_name in query_params:
|
|
280
|
+
raw = query_params[param_name]
|
|
281
|
+
values = raw.split(",") if "," in raw else [raw]
|
|
282
|
+
# flatten CSV in any element
|
|
283
|
+
flat_values = []
|
|
284
|
+
for v in values:
|
|
285
|
+
if isinstance(v, str) and "," in v:
|
|
286
|
+
flat_values.extend(v.split(","))
|
|
287
|
+
else:
|
|
288
|
+
flat_values.append(v)
|
|
289
|
+
values = flat_values
|
|
290
|
+
if not values:
|
|
291
|
+
if query_info.default is not ...:
|
|
292
|
+
kwargs_to_inject[param_name] = query_info.default
|
|
293
|
+
continue
|
|
294
|
+
return validation_error_response(
|
|
295
|
+
f"Missing required query parameter: {param_name}"
|
|
296
|
+
)
|
|
297
|
+
# Unwrap Optional for item type
|
|
298
|
+
base_item_type, item_is_opt = TypeUtils.unwrap_optional(
|
|
299
|
+
item_type
|
|
300
|
+
)
|
|
301
|
+
converted_list = []
|
|
302
|
+
for v in values:
|
|
303
|
+
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
304
|
+
converted_list.append(None)
|
|
305
|
+
continue
|
|
306
|
+
converted_value = TypeConverter.convert_value(
|
|
307
|
+
v, base_item_type, param_name, is_path_param=False
|
|
308
|
+
)
|
|
309
|
+
if isinstance(converted_value, JSONResponse):
|
|
310
|
+
return converted_value
|
|
311
|
+
converted_list.append(converted_value)
|
|
312
|
+
kwargs_to_inject[param_name] = converted_list
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Optional[T] handling for single value
|
|
316
|
+
base_type, _is_opt = TypeUtils.unwrap_optional(ann)
|
|
317
|
+
|
|
318
|
+
if param_name in query_params:
|
|
319
|
+
value_str = query_params[param_name]
|
|
320
|
+
converted_value = TypeConverter.convert_value(
|
|
321
|
+
value_str, base_type, param_name, is_path_param=False
|
|
359
322
|
)
|
|
360
323
|
if isinstance(converted_value, JSONResponse):
|
|
361
324
|
return converted_value
|
|
362
|
-
|
|
363
|
-
kwargs_to_inject[param_name] = converted_list
|
|
364
|
-
continue
|
|
325
|
+
kwargs_to_inject[param_name] = converted_value
|
|
365
326
|
|
|
366
|
-
|
|
367
|
-
|
|
327
|
+
elif query_info.default is not ...:
|
|
328
|
+
kwargs_to_inject[param.name] = query_info.default
|
|
329
|
+
else:
|
|
330
|
+
return validation_error_response(
|
|
331
|
+
f"Missing required query parameter: {param_name}"
|
|
332
|
+
)
|
|
368
333
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
334
|
+
# Process explicit Path parameters (with Path() annotation)
|
|
335
|
+
elif isinstance(param.default, Path):
|
|
336
|
+
param_name = param.name
|
|
337
|
+
if param_name in path_params:
|
|
338
|
+
value_str = path_params[param_name]
|
|
339
|
+
# Support List[T] in path params via CSV
|
|
340
|
+
ann = param.annotation
|
|
341
|
+
origin = typing.get_origin(ann)
|
|
342
|
+
args = typing.get_args(ann)
|
|
343
|
+
if origin in (list, typing.List):
|
|
344
|
+
item_type = args[0] if args else str
|
|
345
|
+
parts = value_str.split(",") if value_str else []
|
|
346
|
+
# Unwrap Optional for item type
|
|
347
|
+
base_item_type, item_is_opt = TypeUtils.unwrap_optional(
|
|
348
|
+
item_type
|
|
349
|
+
)
|
|
350
|
+
converted_list = []
|
|
351
|
+
for v in parts:
|
|
352
|
+
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
353
|
+
converted_list.append(None)
|
|
354
|
+
continue
|
|
355
|
+
converted_value = TypeConverter.convert_value(
|
|
356
|
+
v,
|
|
357
|
+
base_item_type,
|
|
358
|
+
param_name,
|
|
359
|
+
is_path_param=True,
|
|
360
|
+
)
|
|
361
|
+
if isinstance(converted_value, JSONResponse):
|
|
362
|
+
return converted_value
|
|
363
|
+
converted_list.append(converted_value)
|
|
364
|
+
kwargs_to_inject[param_name] = converted_list
|
|
365
|
+
else:
|
|
366
|
+
converted_value = TypeConverter.convert_value(
|
|
367
|
+
value_str, ann, param_name, is_path_param=True
|
|
368
|
+
)
|
|
369
|
+
# Return 404 if conversion failed
|
|
370
|
+
if isinstance(converted_value, JSONResponse):
|
|
371
|
+
return converted_value
|
|
372
|
+
kwargs_to_inject[param_name] = converted_value
|
|
373
|
+
else:
|
|
374
|
+
return JSONResponse(
|
|
375
|
+
{"detail": "Not Found"}, status_code=404
|
|
376
|
+
)
|
|
384
377
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
378
|
+
# Process implicit Path parameters (URL path variables without Path())
|
|
379
|
+
elif (
|
|
380
|
+
param.default is inspect.Parameter.empty
|
|
381
|
+
and param.name in path_params
|
|
382
|
+
and not is_explicit_dependency
|
|
383
|
+
and not is_implicit_dependency
|
|
384
|
+
):
|
|
385
|
+
param_name = param.name
|
|
389
386
|
value_str = path_params[param_name]
|
|
390
|
-
# Support List[T]
|
|
387
|
+
# Support List[T] via CSV
|
|
391
388
|
ann = param.annotation
|
|
392
389
|
origin = typing.get_origin(ann)
|
|
393
390
|
args = typing.get_args(ann)
|
|
@@ -395,13 +392,15 @@ class Tachyon:
|
|
|
395
392
|
item_type = args[0] if args else str
|
|
396
393
|
parts = value_str.split(",") if value_str else []
|
|
397
394
|
# Unwrap Optional for item type
|
|
398
|
-
base_item_type, item_is_opt =
|
|
395
|
+
base_item_type, item_is_opt = TypeUtils.unwrap_optional(
|
|
396
|
+
item_type
|
|
397
|
+
)
|
|
399
398
|
converted_list = []
|
|
400
399
|
for v in parts:
|
|
401
400
|
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
402
401
|
converted_list.append(None)
|
|
403
402
|
continue
|
|
404
|
-
converted_value =
|
|
403
|
+
converted_value = TypeConverter.convert_value(
|
|
405
404
|
v, base_item_type, param_name, is_path_param=True
|
|
406
405
|
)
|
|
407
406
|
if isinstance(converted_value, JSONResponse):
|
|
@@ -409,82 +408,44 @@ class Tachyon:
|
|
|
409
408
|
converted_list.append(converted_value)
|
|
410
409
|
kwargs_to_inject[param_name] = converted_list
|
|
411
410
|
else:
|
|
412
|
-
converted_value =
|
|
411
|
+
converted_value = TypeConverter.convert_value(
|
|
413
412
|
value_str, ann, param_name, is_path_param=True
|
|
414
413
|
)
|
|
415
414
|
# Return 404 if conversion failed
|
|
416
415
|
if isinstance(converted_value, JSONResponse):
|
|
417
416
|
return converted_value
|
|
418
417
|
kwargs_to_inject[param_name] = converted_value
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
converted_list.append(converted_value)
|
|
451
|
-
kwargs_to_inject[param_name] = converted_list
|
|
452
|
-
else:
|
|
453
|
-
converted_value = self._convert_value(
|
|
454
|
-
value_str, ann, param_name, is_path_param=True
|
|
455
|
-
)
|
|
456
|
-
# Return 404 if conversion failed
|
|
457
|
-
if isinstance(converted_value, JSONResponse):
|
|
458
|
-
return converted_value
|
|
459
|
-
kwargs_to_inject[param_name] = converted_value
|
|
460
|
-
|
|
461
|
-
# Call the endpoint function with injected parameters
|
|
462
|
-
if asyncio.iscoroutinefunction(endpoint_func):
|
|
463
|
-
payload = await endpoint_func(**kwargs_to_inject)
|
|
464
|
-
else:
|
|
465
|
-
payload = endpoint_func(**kwargs_to_inject)
|
|
466
|
-
|
|
467
|
-
# If the endpoint already returned a Response object, return it directly
|
|
468
|
-
if isinstance(payload, Response):
|
|
469
|
-
return payload
|
|
470
|
-
|
|
471
|
-
# Validate/convert response against response_model if provided
|
|
472
|
-
if response_model is not None:
|
|
473
|
-
try:
|
|
474
|
-
payload = msgspec.convert(payload, response_model)
|
|
475
|
-
except Exception as e:
|
|
476
|
-
return response_validation_error_response(str(e))
|
|
477
|
-
|
|
478
|
-
# Convert Struct objects to dictionaries for JSON serialization
|
|
479
|
-
if isinstance(payload, Struct):
|
|
480
|
-
payload = msgspec.to_builtins(payload)
|
|
481
|
-
elif isinstance(payload, dict):
|
|
482
|
-
# Convert any Struct values in the dictionary
|
|
483
|
-
for key, value in payload.items():
|
|
484
|
-
if isinstance(value, Struct):
|
|
485
|
-
payload[key] = msgspec.to_builtins(value)
|
|
486
|
-
|
|
487
|
-
return TachyonJSONResponse(payload)
|
|
418
|
+
|
|
419
|
+
# Call the endpoint function with injected parameters
|
|
420
|
+
if asyncio.iscoroutinefunction(endpoint_func):
|
|
421
|
+
payload = await endpoint_func(**kwargs_to_inject)
|
|
422
|
+
else:
|
|
423
|
+
payload = endpoint_func(**kwargs_to_inject)
|
|
424
|
+
|
|
425
|
+
# If the endpoint already returned a Response object, return it directly
|
|
426
|
+
if isinstance(payload, Response):
|
|
427
|
+
return payload
|
|
428
|
+
|
|
429
|
+
# Validate/convert response against response_model if provided
|
|
430
|
+
if response_model is not None:
|
|
431
|
+
try:
|
|
432
|
+
payload = msgspec.convert(payload, response_model)
|
|
433
|
+
except Exception as e:
|
|
434
|
+
return response_validation_error_response(str(e))
|
|
435
|
+
|
|
436
|
+
# Convert Struct objects to dictionaries for JSON serialization
|
|
437
|
+
if isinstance(payload, Struct):
|
|
438
|
+
payload = msgspec.to_builtins(payload)
|
|
439
|
+
elif isinstance(payload, dict):
|
|
440
|
+
# Convert any Struct values in the dictionary
|
|
441
|
+
for key, value in payload.items():
|
|
442
|
+
if isinstance(value, Struct):
|
|
443
|
+
payload[key] = msgspec.to_builtins(value)
|
|
444
|
+
|
|
445
|
+
return TachyonJSONResponse(payload)
|
|
446
|
+
except Exception:
|
|
447
|
+
# Fallback: prevent unhandled exceptions from leaking to the client
|
|
448
|
+
return internal_server_error_response()
|
|
488
449
|
|
|
489
450
|
# Register the route with Starlette
|
|
490
451
|
route = Route(path, endpoint=handler, methods=[method])
|
|
@@ -564,7 +525,9 @@ class Tachyon:
|
|
|
564
525
|
"description": "Validation Error",
|
|
565
526
|
"content": {
|
|
566
527
|
"application/json": {
|
|
567
|
-
"schema": {
|
|
528
|
+
"schema": {
|
|
529
|
+
"$ref": "#/components/schemas/ValidationErrorResponse"
|
|
530
|
+
}
|
|
568
531
|
}
|
|
569
532
|
},
|
|
570
533
|
},
|
|
@@ -572,7 +535,9 @@ class Tachyon:
|
|
|
572
535
|
"description": "Response Validation Error",
|
|
573
536
|
"content": {
|
|
574
537
|
"application/json": {
|
|
575
|
-
"schema": {
|
|
538
|
+
"schema": {
|
|
539
|
+
"$ref": "#/components/schemas/ResponseValidationError"
|
|
540
|
+
}
|
|
576
541
|
}
|
|
577
542
|
},
|
|
578
543
|
},
|
|
@@ -583,6 +548,7 @@ class Tachyon:
|
|
|
583
548
|
response_model = kwargs.get("response_model")
|
|
584
549
|
if response_model is not None and issubclass(response_model, Struct):
|
|
585
550
|
from .openapi import build_components_for_struct
|
|
551
|
+
|
|
586
552
|
comps = build_components_for_struct(response_model)
|
|
587
553
|
for name, schema in comps.items():
|
|
588
554
|
self.openapi_generator.add_schema(name, schema)
|
|
@@ -639,6 +605,7 @@ class Tachyon:
|
|
|
639
605
|
model_class = param.annotation
|
|
640
606
|
if issubclass(model_class, Struct):
|
|
641
607
|
from .openapi import build_components_for_struct
|
|
608
|
+
|
|
642
609
|
comps = build_components_for_struct(model_class)
|
|
643
610
|
for name, schema in comps.items():
|
|
644
611
|
self.openapi_generator.add_schema(name, schema)
|
|
@@ -11,12 +11,12 @@ Design notes:
|
|
|
11
11
|
- Unless predicate allows opt-out per-call
|
|
12
12
|
- TTL can be provided per-decorator or falls back to global default
|
|
13
13
|
"""
|
|
14
|
+
|
|
14
15
|
from __future__ import annotations
|
|
15
16
|
|
|
16
17
|
import time
|
|
17
18
|
import asyncio
|
|
18
19
|
import hashlib
|
|
19
|
-
import inspect
|
|
20
20
|
from dataclasses import dataclass
|
|
21
21
|
from functools import wraps
|
|
22
22
|
from typing import Any, Callable, Optional, Tuple
|
|
@@ -28,7 +28,9 @@ class BaseCacheBackend:
|
|
|
28
28
|
def get(self, key: str) -> Any: # pragma: no cover - interface
|
|
29
29
|
raise NotImplementedError
|
|
30
30
|
|
|
31
|
-
def set(
|
|
31
|
+
def set(
|
|
32
|
+
self, key: str, value: Any, ttl: Optional[float] = None
|
|
33
|
+
) -> None: # pragma: no cover - interface
|
|
32
34
|
raise NotImplementedError
|
|
33
35
|
|
|
34
36
|
def delete(self, key: str) -> None: # pragma: no cover - interface
|
|
@@ -86,7 +88,9 @@ _cache_config: Optional[CacheConfig] = None
|
|
|
86
88
|
def _default_key_builder(func: Callable, args: tuple, kwargs: dict) -> str:
|
|
87
89
|
parts = [getattr(func, "__module__", ""), getattr(func, "__qualname__", ""), "|"]
|
|
88
90
|
# Stable kwargs order
|
|
89
|
-
items = [repr(a) for a in args] + [
|
|
91
|
+
items = [repr(a) for a in args] + [
|
|
92
|
+
f"{k}={repr(v)}" for k, v in sorted(kwargs.items())
|
|
93
|
+
]
|
|
90
94
|
raw_key = ":".join(parts + items)
|
|
91
95
|
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
|
92
96
|
|
|
@@ -146,9 +150,14 @@ def cache(
|
|
|
146
150
|
cfg = get_cache_config()
|
|
147
151
|
be = backend or cfg.backend
|
|
148
152
|
ttl_value = cfg.default_ttl if TTL is None else TTL
|
|
149
|
-
kb =
|
|
153
|
+
kb = (
|
|
154
|
+
key_builder
|
|
155
|
+
or cfg.key_builder
|
|
156
|
+
or (lambda f, a, kw: _default_key_builder(f, a, kw))
|
|
157
|
+
)
|
|
150
158
|
|
|
151
159
|
if asyncio.iscoroutinefunction(func):
|
|
160
|
+
|
|
152
161
|
@wraps(func)
|
|
153
162
|
async def async_wrapper(*args, **kwargs):
|
|
154
163
|
if not cfg.enabled or (unless and unless(args, kwargs)):
|
|
@@ -167,6 +176,7 @@ def cache(
|
|
|
167
176
|
|
|
168
177
|
return async_wrapper
|
|
169
178
|
else:
|
|
179
|
+
|
|
170
180
|
@wraps(func)
|
|
171
181
|
def wrapper(*args, **kwargs):
|
|
172
182
|
if not cfg.enabled or (unless and unless(args, kwargs)):
|
|
@@ -258,4 +268,3 @@ class MemcachedCacheBackend(BaseCacheBackend):
|
|
|
258
268
|
self.client.flush_all()
|
|
259
269
|
except Exception:
|
|
260
270
|
pass
|
|
261
|
-
|
|
@@ -70,7 +70,8 @@ class CORSMiddleware:
|
|
|
70
70
|
method = scope.get("method", "").upper()
|
|
71
71
|
is_preflight = (
|
|
72
72
|
method == "OPTIONS"
|
|
73
|
-
and self._get_header(req_headers, "access-control-request-method")
|
|
73
|
+
and self._get_header(req_headers, "access-control-request-method")
|
|
74
|
+
is not None
|
|
74
75
|
)
|
|
75
76
|
|
|
76
77
|
# Build common CORS headers
|
|
@@ -89,7 +90,9 @@ class CORSMiddleware:
|
|
|
89
90
|
|
|
90
91
|
# Credentials
|
|
91
92
|
if self.allow_credentials:
|
|
92
|
-
self._append_header(
|
|
93
|
+
self._append_header(
|
|
94
|
+
headers_out, "access-control-allow-credentials", "true"
|
|
95
|
+
)
|
|
93
96
|
|
|
94
97
|
return headers_out
|
|
95
98
|
|
|
@@ -98,28 +101,42 @@ class CORSMiddleware:
|
|
|
98
101
|
resp_headers = build_cors_headers()
|
|
99
102
|
if resp_headers:
|
|
100
103
|
# Methods
|
|
101
|
-
allow_methods =
|
|
102
|
-
|
|
104
|
+
allow_methods = (
|
|
105
|
+
", ".join(self.allow_methods)
|
|
106
|
+
if "*" not in self.allow_methods
|
|
107
|
+
else "*"
|
|
108
|
+
)
|
|
109
|
+
self._append_header(
|
|
110
|
+
resp_headers, "access-control-allow-methods", allow_methods
|
|
111
|
+
)
|
|
103
112
|
|
|
104
113
|
# Requested headers or wildcard
|
|
105
|
-
req_acrh = self._get_header(
|
|
114
|
+
req_acrh = self._get_header(
|
|
115
|
+
req_headers, "access-control-request-headers"
|
|
116
|
+
)
|
|
106
117
|
if "*" in self.allow_headers:
|
|
107
118
|
allow_headers = req_acrh or "*"
|
|
108
119
|
else:
|
|
109
120
|
allow_headers = ", ".join(self.allow_headers)
|
|
110
121
|
if allow_headers:
|
|
111
|
-
self._append_header(
|
|
122
|
+
self._append_header(
|
|
123
|
+
resp_headers, "access-control-allow-headers", allow_headers
|
|
124
|
+
)
|
|
112
125
|
|
|
113
126
|
# Max-Age
|
|
114
127
|
if self.max_age:
|
|
115
|
-
self._append_header(
|
|
128
|
+
self._append_header(
|
|
129
|
+
resp_headers, "access-control-max-age", str(self.max_age)
|
|
130
|
+
)
|
|
116
131
|
|
|
117
132
|
# Respond directly
|
|
118
|
-
await send(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
await send(
|
|
134
|
+
{
|
|
135
|
+
"type": "http.response.start",
|
|
136
|
+
"status": 200,
|
|
137
|
+
"headers": resp_headers,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
123
140
|
await send({"type": "http.response.body", "body": b""})
|
|
124
141
|
return
|
|
125
142
|
# If origin not allowed, continue the chain (app will respond accordingly)
|
|
@@ -79,7 +79,9 @@ class LoggerMiddleware:
|
|
|
79
79
|
receive_to_use = receive_passthrough
|
|
80
80
|
if body_chunks and not more_body:
|
|
81
81
|
try:
|
|
82
|
-
request_body_preview = b"".join(body_chunks)[:2048].decode(
|
|
82
|
+
request_body_preview = b"".join(body_chunks)[:2048].decode(
|
|
83
|
+
"utf-8", "replace"
|
|
84
|
+
)
|
|
83
85
|
except Exception:
|
|
84
86
|
request_body_preview = "<non-text body>"
|
|
85
87
|
else:
|
|
@@ -113,7 +115,9 @@ class LoggerMiddleware:
|
|
|
113
115
|
finally:
|
|
114
116
|
duration = time.time() - start
|
|
115
117
|
status = status_code_holder["status"] or 0
|
|
116
|
-
self.logger.log(
|
|
118
|
+
self.logger.log(
|
|
119
|
+
self.level, f"<-- {method} {path} {status} ({duration:.4f}s)"
|
|
120
|
+
)
|
|
117
121
|
if self.include_headers and response_headers_holder:
|
|
118
122
|
res_headers = _normalized_headers(response_headers_holder)
|
|
119
123
|
self.logger.log(self.level, f" res headers: {res_headers}")
|
|
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
|
|
|
3
3
|
import datetime
|
|
4
4
|
import uuid
|
|
5
5
|
import typing
|
|
6
|
+
import json
|
|
6
7
|
|
|
7
8
|
from .models import Struct
|
|
8
9
|
|
|
@@ -97,7 +98,9 @@ def _generate_struct_schema(
|
|
|
97
98
|
return schema
|
|
98
99
|
|
|
99
100
|
|
|
100
|
-
def build_components_for_struct(
|
|
101
|
+
def build_components_for_struct(
|
|
102
|
+
struct_class: Type[Struct],
|
|
103
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
101
104
|
"""
|
|
102
105
|
Build components schemas for the given Struct and all nested Structs.
|
|
103
106
|
|
|
@@ -260,13 +263,8 @@ class OpenAPIGenerator:
|
|
|
260
263
|
"""Generate HTML for Swagger UI"""
|
|
261
264
|
swagger_ui_parameters = self.config.swagger_ui_parameters or {}
|
|
262
265
|
|
|
263
|
-
#
|
|
264
|
-
params_json = (
|
|
265
|
-
str(swagger_ui_parameters)
|
|
266
|
-
.replace("'", '"')
|
|
267
|
-
.replace("True", "true")
|
|
268
|
-
.replace("False", "false")
|
|
269
|
-
)
|
|
266
|
+
# Serialize parameters to JSON safely
|
|
267
|
+
params_json = json.dumps(swagger_ui_parameters)
|
|
270
268
|
|
|
271
269
|
html = f"""<!DOCTYPE html>
|
|
272
270
|
<html>
|
|
@@ -287,7 +285,7 @@ class OpenAPIGenerator:
|
|
|
287
285
|
SwaggerUIBundle.presets.standalone
|
|
288
286
|
],
|
|
289
287
|
layout: "BaseLayout",
|
|
290
|
-
...{
|
|
288
|
+
...{params_json}
|
|
291
289
|
}})
|
|
292
290
|
</script>
|
|
293
291
|
</body>
|
|
@@ -6,7 +6,6 @@ with Starlette responses.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from starlette.responses import JSONResponse, HTMLResponse # noqa
|
|
9
|
-
import orjson
|
|
10
9
|
from .models import encode_json
|
|
11
10
|
|
|
12
11
|
|
|
@@ -63,7 +62,27 @@ def response_validation_error_response(error="Response validation error"):
|
|
|
63
62
|
if not msg.lower().startswith("response validation error"):
|
|
64
63
|
msg = f"Response validation error: {msg}"
|
|
65
64
|
return TachyonJSONResponse(
|
|
66
|
-
{
|
|
65
|
+
{
|
|
66
|
+
"success": False,
|
|
67
|
+
"error": msg,
|
|
68
|
+
"detail": msg,
|
|
69
|
+
"code": "RESPONSE_VALIDATION_ERROR",
|
|
70
|
+
},
|
|
71
|
+
status_code=500,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def internal_server_error_response():
|
|
76
|
+
"""Create a 500 internal server error response for unhandled exceptions.
|
|
77
|
+
|
|
78
|
+
This intentionally avoids leaking internal exception details in the payload.
|
|
79
|
+
"""
|
|
80
|
+
return TachyonJSONResponse(
|
|
81
|
+
{
|
|
82
|
+
"success": False,
|
|
83
|
+
"error": "Internal Server Error",
|
|
84
|
+
"code": "INTERNAL_SERVER_ERROR",
|
|
85
|
+
},
|
|
67
86
|
status_code=500,
|
|
68
87
|
)
|
|
69
88
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tachyon API - Utilities Module
|
|
3
|
+
|
|
4
|
+
This package contains utility functions and classes that provide common functionality
|
|
5
|
+
across the Tachyon framework, including type conversion, validation, and helper functions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .type_utils import TypeUtils
|
|
9
|
+
from .type_converter import TypeConverter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TypeUtils",
|
|
13
|
+
"TypeConverter",
|
|
14
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tachyon API - Type Converter
|
|
3
|
+
|
|
4
|
+
This module provides functionality for converting string values to appropriate Python types
|
|
5
|
+
with proper error handling. Used primarily for converting URL parameters and query strings
|
|
6
|
+
to typed values expected by endpoint functions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Type, Union, Any
|
|
10
|
+
from starlette.responses import JSONResponse
|
|
11
|
+
|
|
12
|
+
from ..responses import validation_error_response
|
|
13
|
+
from .type_utils import TypeUtils
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TypeConverter:
|
|
17
|
+
"""
|
|
18
|
+
Handles conversion of string values to target Python types.
|
|
19
|
+
|
|
20
|
+
This class provides methods to convert string representations of values
|
|
21
|
+
(typically from URL parameters or query strings) to their appropriate
|
|
22
|
+
Python types with comprehensive error handling.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def convert_value(
|
|
27
|
+
value_str: str,
|
|
28
|
+
target_type: Type,
|
|
29
|
+
param_name: str,
|
|
30
|
+
is_path_param: bool = False,
|
|
31
|
+
) -> Union[Any, JSONResponse]:
|
|
32
|
+
"""
|
|
33
|
+
Convert a string value to the target type with appropriate error handling.
|
|
34
|
+
|
|
35
|
+
This method handles type conversion for query and path parameters,
|
|
36
|
+
including special handling for boolean values and proper error responses.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
value_str: The string value to convert
|
|
40
|
+
target_type: The target Python type to convert to
|
|
41
|
+
param_name: Name of the parameter (for error messages)
|
|
42
|
+
is_path_param: Whether this is a path parameter (affects error response)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The converted value, or a JSONResponse with appropriate error code
|
|
46
|
+
|
|
47
|
+
Note:
|
|
48
|
+
- Boolean conversion accepts: "true", "1", "t", "yes" (case-insensitive)
|
|
49
|
+
- Path parameter errors return 404, query parameter errors return 422
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
>>> TypeConverter.convert_value("123", int, "limit")
|
|
53
|
+
123
|
|
54
|
+
>>> TypeConverter.convert_value("true", bool, "active")
|
|
55
|
+
True
|
|
56
|
+
>>> TypeConverter.convert_value("invalid", int, "limit")
|
|
57
|
+
JSONResponse({"success": False, "error": "Invalid value for integer conversion", ...})
|
|
58
|
+
"""
|
|
59
|
+
# Unwrap Optional/Union[T, None]
|
|
60
|
+
target_type, _ = TypeUtils.unwrap_optional(target_type)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
if target_type is bool:
|
|
64
|
+
return value_str.lower() in ("true", "1", "t", "yes")
|
|
65
|
+
elif target_type is not str:
|
|
66
|
+
return target_type(value_str)
|
|
67
|
+
else:
|
|
68
|
+
return value_str
|
|
69
|
+
except (ValueError, TypeError):
|
|
70
|
+
if is_path_param:
|
|
71
|
+
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
72
|
+
else:
|
|
73
|
+
type_name = TypeUtils.get_type_name(target_type)
|
|
74
|
+
return validation_error_response(
|
|
75
|
+
f"Invalid value for {type_name} conversion"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def convert_list_values(
|
|
80
|
+
values: list[str], item_type: Type, param_name: str, is_path_param: bool = False
|
|
81
|
+
) -> Union[list[Any], JSONResponse]:
|
|
82
|
+
"""
|
|
83
|
+
Convert a list of string values to the target item type.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
values: List of string values to convert
|
|
87
|
+
item_type: Target type for each item
|
|
88
|
+
param_name: Parameter name for error messages
|
|
89
|
+
is_path_param: Whether this is a path parameter
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of converted values or error response
|
|
93
|
+
"""
|
|
94
|
+
base_item_type, item_is_optional = TypeUtils.unwrap_optional(item_type)
|
|
95
|
+
converted_list = []
|
|
96
|
+
|
|
97
|
+
for value_str in values:
|
|
98
|
+
# Handle null/empty values for optional items
|
|
99
|
+
if item_is_optional and (value_str == "" or value_str.lower() == "null"):
|
|
100
|
+
converted_list.append(None)
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
converted_value = TypeConverter.convert_value(
|
|
104
|
+
value_str, base_item_type, param_name, is_path_param
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# If conversion failed, return the error response
|
|
108
|
+
if isinstance(converted_value, JSONResponse):
|
|
109
|
+
return converted_value
|
|
110
|
+
|
|
111
|
+
converted_list.append(converted_value)
|
|
112
|
+
|
|
113
|
+
return converted_list
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tachyon API - Type Utilities
|
|
3
|
+
|
|
4
|
+
This module provides utility functions for working with Python types,
|
|
5
|
+
particularly for handling Optional types, Union types, and generic types
|
|
6
|
+
used throughout the Tachyon framework.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import typing
|
|
10
|
+
from typing import Type, Tuple, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TypeUtils:
|
|
14
|
+
"""
|
|
15
|
+
Utility class for type inspection and manipulation.
|
|
16
|
+
|
|
17
|
+
Provides static methods to analyze Python type annotations,
|
|
18
|
+
particularly useful for handling Optional[T], Union types,
|
|
19
|
+
and generic types in parameter processing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def unwrap_optional(python_type: Type) -> Tuple[Type, bool]:
|
|
24
|
+
"""
|
|
25
|
+
Unwrap Optional[T] types to get the inner type and optionality flag.
|
|
26
|
+
|
|
27
|
+
This method analyzes a type annotation and determines if it represents
|
|
28
|
+
an Optional type (Union[T, None]). It returns the inner type and a
|
|
29
|
+
boolean indicating whether the type is optional.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
python_type: The type annotation to analyze
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tuple containing:
|
|
36
|
+
- inner_type: The unwrapped type (T from Optional[T])
|
|
37
|
+
- is_optional: Boolean indicating if the type was Optional
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
>>> TypeUtils.unwrap_optional(Optional[str])
|
|
41
|
+
(str, True)
|
|
42
|
+
>>> TypeUtils.unwrap_optional(str)
|
|
43
|
+
(str, False)
|
|
44
|
+
>>> TypeUtils.unwrap_optional(Union[int, None])
|
|
45
|
+
(int, True)
|
|
46
|
+
"""
|
|
47
|
+
origin = typing.get_origin(python_type)
|
|
48
|
+
args = typing.get_args(python_type)
|
|
49
|
+
|
|
50
|
+
if origin is Union and args:
|
|
51
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
52
|
+
if len(non_none) == 1:
|
|
53
|
+
return non_none[0], True
|
|
54
|
+
|
|
55
|
+
return python_type, False
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def is_list_type(python_type: Type) -> Tuple[bool, Type]:
|
|
59
|
+
"""
|
|
60
|
+
Check if a type is a List type and extract the item type.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
python_type: The type annotation to check
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple containing:
|
|
67
|
+
- is_list: Boolean indicating if the type is a List
|
|
68
|
+
- item_type: The type of list items (str if not a list or no args)
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
>>> TypeUtils.is_list_type(List[str])
|
|
72
|
+
(True, str)
|
|
73
|
+
>>> TypeUtils.is_list_type(str)
|
|
74
|
+
(False, str)
|
|
75
|
+
"""
|
|
76
|
+
origin = typing.get_origin(python_type)
|
|
77
|
+
args = typing.get_args(python_type)
|
|
78
|
+
|
|
79
|
+
if origin in (list, typing.List):
|
|
80
|
+
item_type = args[0] if args else str
|
|
81
|
+
return True, item_type
|
|
82
|
+
|
|
83
|
+
return False, str
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def get_type_name(python_type: Type) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Get a human-readable name for a type.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
python_type: The type to get the name for
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Human-readable type name
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
>>> TypeUtils.get_type_name(int)
|
|
98
|
+
'integer'
|
|
99
|
+
>>> TypeUtils.get_type_name(str)
|
|
100
|
+
'string'
|
|
101
|
+
"""
|
|
102
|
+
if python_type is int:
|
|
103
|
+
return "integer"
|
|
104
|
+
elif python_type is str:
|
|
105
|
+
return "string"
|
|
106
|
+
elif python_type is bool:
|
|
107
|
+
return "boolean"
|
|
108
|
+
elif python_type is float:
|
|
109
|
+
return "number"
|
|
110
|
+
else:
|
|
111
|
+
return getattr(python_type, "__name__", str(python_type))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|