tachyon-api 0.5.5__py3-none-any.whl → 0.5.9__py3-none-any.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 tachyon-api might be problematic. Click here for more details.
- tachyon_api/__init__.py +20 -0
- tachyon_api/app.py +385 -172
- tachyon_api/cache.py +270 -0
- tachyon_api/middlewares/__init__.py +0 -1
- tachyon_api/middlewares/cors.py +29 -12
- tachyon_api/middlewares/logger.py +6 -2
- tachyon_api/openapi.py +110 -28
- tachyon_api/responses.py +46 -3
- tachyon_api/utils/__init__.py +14 -0
- tachyon_api/utils/type_converter.py +113 -0
- tachyon_api/utils/type_utils.py +111 -0
- tachyon_api-0.5.9.dist-info/METADATA +146 -0
- tachyon_api-0.5.9.dist-info/RECORD +20 -0
- tachyon_api-0.5.5.dist-info/METADATA +0 -239
- tachyon_api-0.5.5.dist-info/RECORD +0 -16
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.9.dist-info}/LICENSE +0 -0
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.9.dist-info}/WHEEL +0 -0
tachyon_api/app.py
CHANGED
|
@@ -11,6 +11,7 @@ import inspect
|
|
|
11
11
|
import msgspec
|
|
12
12
|
from functools import partial
|
|
13
13
|
from typing import Any, Dict, Type, Union, Callable
|
|
14
|
+
import typing
|
|
14
15
|
|
|
15
16
|
from starlette.applications import Starlette
|
|
16
17
|
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
@@ -22,13 +23,26 @@ from .openapi import (
|
|
|
22
23
|
OpenAPIGenerator,
|
|
23
24
|
OpenAPIConfig,
|
|
24
25
|
create_openapi_config,
|
|
25
|
-
_generate_schema_for_struct,
|
|
26
26
|
)
|
|
27
27
|
from .params import Body, Query, Path
|
|
28
28
|
from .middlewares.core import (
|
|
29
29
|
apply_middleware_to_router,
|
|
30
30
|
create_decorated_middleware_class,
|
|
31
31
|
)
|
|
32
|
+
from .responses import (
|
|
33
|
+
TachyonJSONResponse,
|
|
34
|
+
validation_error_response,
|
|
35
|
+
response_validation_error_response,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from .utils import TypeConverter, TypeUtils
|
|
39
|
+
|
|
40
|
+
from .responses import internal_server_error_response
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from .cache import set_cache_config
|
|
44
|
+
except ImportError:
|
|
45
|
+
set_cache_config = None # type: ignore
|
|
32
46
|
|
|
33
47
|
|
|
34
48
|
class Tachyon:
|
|
@@ -46,13 +60,15 @@ class Tachyon:
|
|
|
46
60
|
openapi_generator: Generator for OpenAPI schema and documentation
|
|
47
61
|
"""
|
|
48
62
|
|
|
49
|
-
def __init__(self, openapi_config: OpenAPIConfig = None):
|
|
63
|
+
def __init__(self, openapi_config: OpenAPIConfig = None, cache_config=None):
|
|
50
64
|
"""
|
|
51
65
|
Initialize a new Tachyon application instance.
|
|
52
66
|
|
|
53
67
|
Args:
|
|
54
68
|
openapi_config: Optional OpenAPI configuration. If not provided,
|
|
55
69
|
uses default configuration similar to FastAPI.
|
|
70
|
+
cache_config: Optional cache configuration (tachyon_api.cache.CacheConfig).
|
|
71
|
+
If provided, it will be set as the active cache configuration.
|
|
56
72
|
"""
|
|
57
73
|
self._router = Starlette()
|
|
58
74
|
self.routes = []
|
|
@@ -64,6 +80,15 @@ class Tachyon:
|
|
|
64
80
|
self.openapi_generator = OpenAPIGenerator(self.openapi_config)
|
|
65
81
|
self._docs_setup = False
|
|
66
82
|
|
|
83
|
+
# Apply cache configuration if provided
|
|
84
|
+
self.cache_config = cache_config
|
|
85
|
+
if cache_config is not None and set_cache_config is not None:
|
|
86
|
+
try:
|
|
87
|
+
set_cache_config(cache_config)
|
|
88
|
+
except Exception:
|
|
89
|
+
# Do not break app initialization if cache setup fails
|
|
90
|
+
pass
|
|
91
|
+
|
|
67
92
|
# Dynamically create HTTP method decorators (get, post, put, delete, etc.)
|
|
68
93
|
http_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
|
69
94
|
|
|
@@ -74,49 +99,6 @@ class Tachyon:
|
|
|
74
99
|
partial(self._create_decorator, http_method=method),
|
|
75
100
|
)
|
|
76
101
|
|
|
77
|
-
@staticmethod
|
|
78
|
-
def _convert_value(
|
|
79
|
-
value_str: str,
|
|
80
|
-
target_type: Type,
|
|
81
|
-
param_name: str,
|
|
82
|
-
is_path_param: bool = False, # noqa
|
|
83
|
-
) -> Union[Any, JSONResponse]:
|
|
84
|
-
"""
|
|
85
|
-
Convert a string value to the target type with appropriate error handling.
|
|
86
|
-
|
|
87
|
-
This method handles type conversion for query and path parameters,
|
|
88
|
-
including special handling for boolean values and proper error responses.
|
|
89
|
-
|
|
90
|
-
Args:
|
|
91
|
-
value_str: The string value to convert
|
|
92
|
-
target_type: The target Python type to convert to
|
|
93
|
-
param_name: Name of the parameter (for error messages)
|
|
94
|
-
is_path_param: Whether this is a path parameter (affects error response)
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
The converted value, or a JSONResponse with appropriate error code
|
|
98
|
-
|
|
99
|
-
Note:
|
|
100
|
-
- Boolean conversion accepts: "true", "1", "t", "yes" (case-insensitive)
|
|
101
|
-
- Path parameter errors return 404, query parameter errors return 422
|
|
102
|
-
"""
|
|
103
|
-
try:
|
|
104
|
-
if target_type is bool:
|
|
105
|
-
return value_str.lower() in ("true", "1", "t", "yes")
|
|
106
|
-
elif target_type is not str:
|
|
107
|
-
return target_type(value_str)
|
|
108
|
-
else:
|
|
109
|
-
return value_str
|
|
110
|
-
except (ValueError, TypeError):
|
|
111
|
-
if is_path_param:
|
|
112
|
-
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
113
|
-
else:
|
|
114
|
-
type_name = "integer" if target_type is int else target_type.__name__
|
|
115
|
-
return JSONResponse(
|
|
116
|
-
{"detail": f"Invalid value for {type_name} conversion"},
|
|
117
|
-
status_code=422,
|
|
118
|
-
)
|
|
119
|
-
|
|
120
102
|
def _resolve_dependency(self, cls: Type) -> Any:
|
|
121
103
|
"""
|
|
122
104
|
Resolve a dependency and its sub-dependencies recursively.
|
|
@@ -210,6 +192,8 @@ class Tachyon:
|
|
|
210
192
|
4. Path parameters (both explicit with Path() and implicit from URL)
|
|
211
193
|
"""
|
|
212
194
|
|
|
195
|
+
response_model = kwargs.get("response_model")
|
|
196
|
+
|
|
213
197
|
async def handler(request):
|
|
214
198
|
"""
|
|
215
199
|
Async request handler that processes parameters and calls the endpoint.
|
|
@@ -217,124 +201,251 @@ class Tachyon:
|
|
|
217
201
|
This handler analyzes the endpoint function signature and automatically
|
|
218
202
|
injects the appropriate values based on parameter annotations and defaults.
|
|
219
203
|
"""
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
# Process dependencies (explicit and implicit)
|
|
236
|
-
if is_explicit_dependency or is_implicit_dependency:
|
|
237
|
-
target_class = param.annotation
|
|
238
|
-
kwargs_to_inject[param.name] = self._resolve_dependency(
|
|
239
|
-
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
|
|
240
218
|
)
|
|
241
219
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"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
|
|
248
225
|
)
|
|
249
226
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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)
|
|
236
|
+
try:
|
|
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
|
|
243
|
+
field_errors = None
|
|
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
|
|
257
|
+
return validation_error_response(
|
|
258
|
+
str(e), errors=field_errors
|
|
259
|
+
)
|
|
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
|
|
322
|
+
)
|
|
323
|
+
if isinstance(converted_value, JSONResponse):
|
|
324
|
+
return converted_value
|
|
325
|
+
kwargs_to_inject[param_name] = converted_value
|
|
326
|
+
|
|
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
|
+
)
|
|
333
|
+
|
|
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
|
+
)
|
|
377
|
+
|
|
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
|
|
290
386
|
value_str = path_params[param_name]
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
if
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
387
|
+
# Support List[T] via CSV
|
|
388
|
+
ann = param.annotation
|
|
389
|
+
origin = typing.get_origin(ann)
|
|
390
|
+
args = typing.get_args(ann)
|
|
391
|
+
if origin in (list, typing.List):
|
|
392
|
+
item_type = args[0] if args else str
|
|
393
|
+
parts = value_str.split(",") if value_str else []
|
|
394
|
+
# Unwrap Optional for item type
|
|
395
|
+
base_item_type, item_is_opt = TypeUtils.unwrap_optional(
|
|
396
|
+
item_type
|
|
397
|
+
)
|
|
398
|
+
converted_list = []
|
|
399
|
+
for v in parts:
|
|
400
|
+
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
401
|
+
converted_list.append(None)
|
|
402
|
+
continue
|
|
403
|
+
converted_value = TypeConverter.convert_value(
|
|
404
|
+
v, base_item_type, param_name, is_path_param=True
|
|
405
|
+
)
|
|
406
|
+
if isinstance(converted_value, JSONResponse):
|
|
407
|
+
return converted_value
|
|
408
|
+
converted_list.append(converted_value)
|
|
409
|
+
kwargs_to_inject[param_name] = converted_list
|
|
410
|
+
else:
|
|
411
|
+
converted_value = TypeConverter.convert_value(
|
|
412
|
+
value_str, ann, param_name, is_path_param=True
|
|
413
|
+
)
|
|
414
|
+
# Return 404 if conversion failed
|
|
415
|
+
if isinstance(converted_value, JSONResponse):
|
|
416
|
+
return converted_value
|
|
417
|
+
kwargs_to_inject[param_name] = converted_value
|
|
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()
|
|
338
449
|
|
|
339
450
|
# Register the route with Starlette
|
|
340
451
|
route = Route(path, endpoint=handler, methods=[method])
|
|
@@ -365,6 +476,40 @@ class Tachyon:
|
|
|
365
476
|
"""
|
|
366
477
|
sig = inspect.signature(endpoint_func)
|
|
367
478
|
|
|
479
|
+
# Ensure common error schemas exist in components
|
|
480
|
+
self.openapi_generator.add_schema(
|
|
481
|
+
"ValidationErrorResponse",
|
|
482
|
+
{
|
|
483
|
+
"type": "object",
|
|
484
|
+
"properties": {
|
|
485
|
+
"success": {"type": "boolean"},
|
|
486
|
+
"error": {"type": "string"},
|
|
487
|
+
"code": {"type": "string"},
|
|
488
|
+
"errors": {
|
|
489
|
+
"type": "object",
|
|
490
|
+
"additionalProperties": {
|
|
491
|
+
"type": "array",
|
|
492
|
+
"items": {"type": "string"},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
"required": ["success", "error", "code"],
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
self.openapi_generator.add_schema(
|
|
500
|
+
"ResponseValidationError",
|
|
501
|
+
{
|
|
502
|
+
"type": "object",
|
|
503
|
+
"properties": {
|
|
504
|
+
"success": {"type": "boolean"},
|
|
505
|
+
"error": {"type": "string"},
|
|
506
|
+
"detail": {"type": "string"},
|
|
507
|
+
"code": {"type": "string"},
|
|
508
|
+
},
|
|
509
|
+
"required": ["success", "error", "code"],
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
|
|
368
513
|
# Build the OpenAPI operation object
|
|
369
514
|
operation = {
|
|
370
515
|
"summary": kwargs.get(
|
|
@@ -375,10 +520,42 @@ class Tachyon:
|
|
|
375
520
|
"200": {
|
|
376
521
|
"description": "Successful Response",
|
|
377
522
|
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
378
|
-
}
|
|
523
|
+
},
|
|
524
|
+
"422": {
|
|
525
|
+
"description": "Validation Error",
|
|
526
|
+
"content": {
|
|
527
|
+
"application/json": {
|
|
528
|
+
"schema": {
|
|
529
|
+
"$ref": "#/components/schemas/ValidationErrorResponse"
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
"500": {
|
|
535
|
+
"description": "Response Validation Error",
|
|
536
|
+
"content": {
|
|
537
|
+
"application/json": {
|
|
538
|
+
"schema": {
|
|
539
|
+
"$ref": "#/components/schemas/ResponseValidationError"
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
},
|
|
379
544
|
},
|
|
380
545
|
}
|
|
381
546
|
|
|
547
|
+
# If a response_model is provided and is a Struct, use it for the 200 response schema
|
|
548
|
+
response_model = kwargs.get("response_model")
|
|
549
|
+
if response_model is not None and issubclass(response_model, Struct):
|
|
550
|
+
from .openapi import build_components_for_struct
|
|
551
|
+
|
|
552
|
+
comps = build_components_for_struct(response_model)
|
|
553
|
+
for name, schema in comps.items():
|
|
554
|
+
self.openapi_generator.add_schema(name, schema)
|
|
555
|
+
operation["responses"]["200"]["content"]["application/json"]["schema"] = {
|
|
556
|
+
"$ref": f"#/components/schemas/{response_model.__name__}"
|
|
557
|
+
}
|
|
558
|
+
|
|
382
559
|
# Add tags if provided
|
|
383
560
|
if "tags" in kwargs:
|
|
384
561
|
operation["tags"] = kwargs["tags"]
|
|
@@ -402,7 +579,7 @@ class Tachyon:
|
|
|
402
579
|
"name": param.name,
|
|
403
580
|
"in": "query",
|
|
404
581
|
"required": param.default.default is ...,
|
|
405
|
-
"schema":
|
|
582
|
+
"schema": self._build_param_openapi_schema(param.annotation),
|
|
406
583
|
"description": getattr(param.default, "description", ""),
|
|
407
584
|
}
|
|
408
585
|
)
|
|
@@ -416,7 +593,7 @@ class Tachyon:
|
|
|
416
593
|
"name": param.name,
|
|
417
594
|
"in": "path",
|
|
418
595
|
"required": True,
|
|
419
|
-
"schema":
|
|
596
|
+
"schema": self._build_param_openapi_schema(param.annotation),
|
|
420
597
|
"description": getattr(param.default, "description", "")
|
|
421
598
|
if isinstance(param.default, Path)
|
|
422
599
|
else "",
|
|
@@ -427,17 +604,17 @@ class Tachyon:
|
|
|
427
604
|
elif isinstance(param.default, Body):
|
|
428
605
|
model_class = param.annotation
|
|
429
606
|
if issubclass(model_class, Struct):
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
607
|
+
from .openapi import build_components_for_struct
|
|
608
|
+
|
|
609
|
+
comps = build_components_for_struct(model_class)
|
|
610
|
+
for name, schema in comps.items():
|
|
611
|
+
self.openapi_generator.add_schema(name, schema)
|
|
435
612
|
|
|
436
613
|
request_body_schema = {
|
|
437
614
|
"content": {
|
|
438
615
|
"application/json": {
|
|
439
616
|
"schema": {
|
|
440
|
-
"$ref": f"#/components/schemas/{
|
|
617
|
+
"$ref": f"#/components/schemas/{model_class.__name__}"
|
|
441
618
|
}
|
|
442
619
|
}
|
|
443
620
|
},
|
|
@@ -448,11 +625,9 @@ class Tachyon:
|
|
|
448
625
|
if parameters:
|
|
449
626
|
operation["parameters"] = parameters
|
|
450
627
|
|
|
451
|
-
# Add request body if it exists
|
|
452
628
|
if request_body_schema:
|
|
453
629
|
operation["requestBody"] = request_body_schema
|
|
454
630
|
|
|
455
|
-
# Add the operation to the OpenAPI schema
|
|
456
631
|
self.openapi_generator.add_path(path, method, operation)
|
|
457
632
|
|
|
458
633
|
@staticmethod
|
|
@@ -476,6 +651,44 @@ class Tachyon:
|
|
|
476
651
|
}
|
|
477
652
|
return type_map.get(python_type, "string")
|
|
478
653
|
|
|
654
|
+
@staticmethod
|
|
655
|
+
def _build_param_openapi_schema(python_type: Type) -> Dict[str, Any]:
|
|
656
|
+
"""Build OpenAPI schema for parameter types, supporting Optional[T] and List[T]."""
|
|
657
|
+
origin = typing.get_origin(python_type)
|
|
658
|
+
args = typing.get_args(python_type)
|
|
659
|
+
nullable = False
|
|
660
|
+
# Optional[T]
|
|
661
|
+
if origin is Union and args:
|
|
662
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
663
|
+
if len(non_none) == 1:
|
|
664
|
+
python_type = non_none[0]
|
|
665
|
+
nullable = True
|
|
666
|
+
# List[T] (and List[Optional[T]])
|
|
667
|
+
origin = typing.get_origin(python_type)
|
|
668
|
+
args = typing.get_args(python_type)
|
|
669
|
+
if origin in (list, typing.List):
|
|
670
|
+
item_type = args[0] if args else str
|
|
671
|
+
# Unwrap Optional in items for List[Optional[T]]
|
|
672
|
+
item_origin = typing.get_origin(item_type)
|
|
673
|
+
item_args = typing.get_args(item_type)
|
|
674
|
+
item_nullable = False
|
|
675
|
+
if item_origin is Union and item_args:
|
|
676
|
+
item_non_none = [a for a in item_args if a is not type(None)] # noqa: E721
|
|
677
|
+
if len(item_non_none) == 1:
|
|
678
|
+
item_type = item_non_none[0]
|
|
679
|
+
item_nullable = True
|
|
680
|
+
schema = {
|
|
681
|
+
"type": "array",
|
|
682
|
+
"items": {"type": Tachyon._get_openapi_type(item_type)},
|
|
683
|
+
}
|
|
684
|
+
if item_nullable:
|
|
685
|
+
schema["items"]["nullable"] = True
|
|
686
|
+
else:
|
|
687
|
+
schema = {"type": Tachyon._get_openapi_type(python_type)}
|
|
688
|
+
if nullable:
|
|
689
|
+
schema["nullable"] = True
|
|
690
|
+
return schema
|
|
691
|
+
|
|
479
692
|
def _setup_docs(self):
|
|
480
693
|
"""
|
|
481
694
|
Setup OpenAPI documentation endpoints.
|
|
@@ -576,7 +789,7 @@ class Tachyon:
|
|
|
576
789
|
middleware_class: The middleware class.
|
|
577
790
|
**options: Options to be passed to the middleware constructor.
|
|
578
791
|
"""
|
|
579
|
-
#
|
|
792
|
+
# Use centralized helper to apply middleware to internal Starlette app
|
|
580
793
|
apply_middleware_to_router(self._router, middleware_class, **options)
|
|
581
794
|
|
|
582
795
|
if not hasattr(self, "middleware_stack"):
|
|
@@ -596,11 +809,11 @@ class Tachyon:
|
|
|
596
809
|
"""
|
|
597
810
|
|
|
598
811
|
def decorator(middleware_func):
|
|
599
|
-
#
|
|
812
|
+
# Create a middleware class from the decorated function
|
|
600
813
|
DecoratedMiddleware = create_decorated_middleware_class(
|
|
601
814
|
middleware_func, middleware_type
|
|
602
815
|
)
|
|
603
|
-
#
|
|
816
|
+
# Register the middleware using the existing method
|
|
604
817
|
self.add_middleware(DecoratedMiddleware)
|
|
605
818
|
return middleware_func
|
|
606
819
|
|