tachyon-api 0.5.5__py3-none-any.whl → 0.5.7__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 +291 -45
- tachyon_api/cache.py +261 -0
- tachyon_api/openapi.py +106 -22
- tachyon_api/responses.py +27 -3
- tachyon_api-0.5.7.dist-info/METADATA +145 -0
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.7.dist-info}/RECORD +9 -8
- tachyon_api-0.5.5.dist-info/METADATA +0 -239
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.7.dist-info}/LICENSE +0 -0
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.7.dist-info}/WHEEL +0 -0
tachyon_api/__init__.py
CHANGED
|
@@ -18,6 +18,17 @@ from .models import Struct
|
|
|
18
18
|
from .params import Query, Body, Path
|
|
19
19
|
from .di import injectable, Depends
|
|
20
20
|
from .router import Router
|
|
21
|
+
from .cache import (
|
|
22
|
+
cache,
|
|
23
|
+
CacheConfig,
|
|
24
|
+
create_cache_config,
|
|
25
|
+
set_cache_config,
|
|
26
|
+
get_cache_config,
|
|
27
|
+
InMemoryCacheBackend,
|
|
28
|
+
BaseCacheBackend,
|
|
29
|
+
RedisCacheBackend,
|
|
30
|
+
MemcachedCacheBackend,
|
|
31
|
+
)
|
|
21
32
|
|
|
22
33
|
__all__ = [
|
|
23
34
|
"Tachyon",
|
|
@@ -28,4 +39,13 @@ __all__ = [
|
|
|
28
39
|
"injectable",
|
|
29
40
|
"Depends",
|
|
30
41
|
"Router",
|
|
42
|
+
"cache",
|
|
43
|
+
"CacheConfig",
|
|
44
|
+
"create_cache_config",
|
|
45
|
+
"set_cache_config",
|
|
46
|
+
"get_cache_config",
|
|
47
|
+
"InMemoryCacheBackend",
|
|
48
|
+
"BaseCacheBackend",
|
|
49
|
+
"RedisCacheBackend",
|
|
50
|
+
"MemcachedCacheBackend",
|
|
31
51
|
]
|
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
|
|
@@ -29,6 +30,16 @@ from .middlewares.core import (
|
|
|
29
30
|
apply_middleware_to_router,
|
|
30
31
|
create_decorated_middleware_class,
|
|
31
32
|
)
|
|
33
|
+
from .responses import (
|
|
34
|
+
TachyonJSONResponse,
|
|
35
|
+
validation_error_response,
|
|
36
|
+
response_validation_error_response,
|
|
37
|
+
)
|
|
38
|
+
# New: optional cache configuration support
|
|
39
|
+
try:
|
|
40
|
+
from .cache import set_cache_config
|
|
41
|
+
except Exception: # pragma: no cover - defensive import guard
|
|
42
|
+
set_cache_config = None # type: ignore
|
|
32
43
|
|
|
33
44
|
|
|
34
45
|
class Tachyon:
|
|
@@ -46,13 +57,15 @@ class Tachyon:
|
|
|
46
57
|
openapi_generator: Generator for OpenAPI schema and documentation
|
|
47
58
|
"""
|
|
48
59
|
|
|
49
|
-
def __init__(self, openapi_config: OpenAPIConfig = None):
|
|
60
|
+
def __init__(self, openapi_config: OpenAPIConfig = None, cache_config=None):
|
|
50
61
|
"""
|
|
51
62
|
Initialize a new Tachyon application instance.
|
|
52
63
|
|
|
53
64
|
Args:
|
|
54
65
|
openapi_config: Optional OpenAPI configuration. If not provided,
|
|
55
66
|
uses default configuration similar to FastAPI.
|
|
67
|
+
cache_config: Optional cache configuration (tachyon_api.cache.CacheConfig).
|
|
68
|
+
If provided, it will be set as the active cache configuration.
|
|
56
69
|
"""
|
|
57
70
|
self._router = Starlette()
|
|
58
71
|
self.routes = []
|
|
@@ -64,6 +77,15 @@ class Tachyon:
|
|
|
64
77
|
self.openapi_generator = OpenAPIGenerator(self.openapi_config)
|
|
65
78
|
self._docs_setup = False
|
|
66
79
|
|
|
80
|
+
# Apply cache configuration if provided
|
|
81
|
+
self.cache_config = cache_config
|
|
82
|
+
if cache_config is not None and set_cache_config is not None:
|
|
83
|
+
try:
|
|
84
|
+
set_cache_config(cache_config)
|
|
85
|
+
except Exception:
|
|
86
|
+
# Do not break app initialization if cache setup fails
|
|
87
|
+
pass
|
|
88
|
+
|
|
67
89
|
# Dynamically create HTTP method decorators (get, post, put, delete, etc.)
|
|
68
90
|
http_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
|
69
91
|
|
|
@@ -100,6 +122,14 @@ class Tachyon:
|
|
|
100
122
|
- Boolean conversion accepts: "true", "1", "t", "yes" (case-insensitive)
|
|
101
123
|
- Path parameter errors return 404, query parameter errors return 422
|
|
102
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
|
+
|
|
103
133
|
try:
|
|
104
134
|
if target_type is bool:
|
|
105
135
|
return value_str.lower() in ("true", "1", "t", "yes")
|
|
@@ -111,11 +141,19 @@ class Tachyon:
|
|
|
111
141
|
if is_path_param:
|
|
112
142
|
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
113
143
|
else:
|
|
114
|
-
type_name = "integer" if target_type is int else target_type
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
157
|
|
|
120
158
|
def _resolve_dependency(self, cls: Type) -> Any:
|
|
121
159
|
"""
|
|
@@ -210,6 +248,8 @@ class Tachyon:
|
|
|
210
248
|
4. Path parameters (both explicit with Path() and implicit from URL)
|
|
211
249
|
"""
|
|
212
250
|
|
|
251
|
+
response_model = kwargs.get("response_model")
|
|
252
|
+
|
|
213
253
|
async def handler(request):
|
|
214
254
|
"""
|
|
215
255
|
Async request handler that processes parameters and calls the endpoint.
|
|
@@ -254,33 +294,92 @@ class Tachyon:
|
|
|
254
294
|
validated_data = decoder.decode(_raw_body)
|
|
255
295
|
kwargs_to_inject[param.name] = validated_data
|
|
256
296
|
except msgspec.ValidationError as e:
|
|
257
|
-
|
|
297
|
+
# Attempt to build field errors map using e.path
|
|
298
|
+
field_errors = None
|
|
299
|
+
try:
|
|
300
|
+
path = getattr(e, "path", None)
|
|
301
|
+
if path:
|
|
302
|
+
# Choose last string-ish path element as field name
|
|
303
|
+
field_name = None
|
|
304
|
+
for p in reversed(path):
|
|
305
|
+
if isinstance(p, str):
|
|
306
|
+
field_name = p
|
|
307
|
+
break
|
|
308
|
+
if field_name:
|
|
309
|
+
field_errors = {field_name: [str(e)]}
|
|
310
|
+
except Exception:
|
|
311
|
+
field_errors = None
|
|
312
|
+
return validation_error_response(str(e), errors=field_errors)
|
|
258
313
|
|
|
259
314
|
# Process Query parameters (URL query string)
|
|
260
315
|
elif isinstance(param.default, Query):
|
|
261
316
|
query_info = param.default
|
|
262
317
|
param_name = param.name
|
|
263
318
|
|
|
319
|
+
# Determine typing for advanced cases
|
|
320
|
+
ann = param.annotation
|
|
321
|
+
origin = typing.get_origin(ann)
|
|
322
|
+
args = typing.get_args(ann)
|
|
323
|
+
|
|
324
|
+
# List[T] handling
|
|
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
|
|
347
|
+
return validation_error_response(
|
|
348
|
+
f"Missing required query parameter: {param_name}"
|
|
349
|
+
)
|
|
350
|
+
# Unwrap Optional for item type
|
|
351
|
+
base_item_type, item_is_opt = self._unwrap_optional(item_type)
|
|
352
|
+
converted_list = []
|
|
353
|
+
for v in values:
|
|
354
|
+
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
355
|
+
converted_list.append(None)
|
|
356
|
+
continue
|
|
357
|
+
converted_value = self._convert_value(
|
|
358
|
+
v, base_item_type, param_name, is_path_param=False
|
|
359
|
+
)
|
|
360
|
+
if isinstance(converted_value, JSONResponse):
|
|
361
|
+
return converted_value
|
|
362
|
+
converted_list.append(converted_value)
|
|
363
|
+
kwargs_to_inject[param_name] = converted_list
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Optional[T] handling for single value
|
|
367
|
+
base_type, _is_opt = self._unwrap_optional(ann)
|
|
368
|
+
|
|
264
369
|
if param_name in query_params:
|
|
265
370
|
value_str = query_params[param_name]
|
|
266
371
|
converted_value = self._convert_value(
|
|
267
|
-
value_str,
|
|
372
|
+
value_str, base_type, param_name, is_path_param=False
|
|
268
373
|
)
|
|
269
|
-
# Return error response if conversion failed
|
|
270
374
|
if isinstance(converted_value, JSONResponse):
|
|
271
375
|
return converted_value
|
|
272
376
|
kwargs_to_inject[param_name] = converted_value
|
|
273
377
|
|
|
274
378
|
elif query_info.default is not ...:
|
|
275
|
-
# Use default value if parameter is optional
|
|
276
379
|
kwargs_to_inject[param.name] = query_info.default
|
|
277
380
|
else:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
{
|
|
281
|
-
"detail": f"Missing required query parameter: {param_name}"
|
|
282
|
-
},
|
|
283
|
-
status_code=422,
|
|
381
|
+
return validation_error_response(
|
|
382
|
+
f"Missing required query parameter: {param_name}"
|
|
284
383
|
)
|
|
285
384
|
|
|
286
385
|
# Process explicit Path parameters (with Path() annotation)
|
|
@@ -288,13 +387,35 @@ class Tachyon:
|
|
|
288
387
|
param_name = param.name
|
|
289
388
|
if param_name in path_params:
|
|
290
389
|
value_str = path_params[param_name]
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
if
|
|
296
|
-
|
|
297
|
-
|
|
390
|
+
# Support List[T] in path params via CSV
|
|
391
|
+
ann = param.annotation
|
|
392
|
+
origin = typing.get_origin(ann)
|
|
393
|
+
args = typing.get_args(ann)
|
|
394
|
+
if origin in (list, typing.List):
|
|
395
|
+
item_type = args[0] if args else str
|
|
396
|
+
parts = value_str.split(",") if value_str else []
|
|
397
|
+
# Unwrap Optional for item type
|
|
398
|
+
base_item_type, item_is_opt = self._unwrap_optional(item_type)
|
|
399
|
+
converted_list = []
|
|
400
|
+
for v in parts:
|
|
401
|
+
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
402
|
+
converted_list.append(None)
|
|
403
|
+
continue
|
|
404
|
+
converted_value = self._convert_value(
|
|
405
|
+
v, base_item_type, param_name, is_path_param=True
|
|
406
|
+
)
|
|
407
|
+
if isinstance(converted_value, JSONResponse):
|
|
408
|
+
return converted_value
|
|
409
|
+
converted_list.append(converted_value)
|
|
410
|
+
kwargs_to_inject[param_name] = converted_list
|
|
411
|
+
else:
|
|
412
|
+
converted_value = self._convert_value(
|
|
413
|
+
value_str, ann, param_name, is_path_param=True
|
|
414
|
+
)
|
|
415
|
+
# Return 404 if conversion failed
|
|
416
|
+
if isinstance(converted_value, JSONResponse):
|
|
417
|
+
return converted_value
|
|
418
|
+
kwargs_to_inject[param_name] = converted_value
|
|
298
419
|
else:
|
|
299
420
|
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
300
421
|
|
|
@@ -307,13 +428,35 @@ class Tachyon:
|
|
|
307
428
|
):
|
|
308
429
|
param_name = param.name
|
|
309
430
|
value_str = path_params[param_name]
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
if
|
|
315
|
-
|
|
316
|
-
|
|
431
|
+
# Support List[T] via CSV
|
|
432
|
+
ann = param.annotation
|
|
433
|
+
origin = typing.get_origin(ann)
|
|
434
|
+
args = typing.get_args(ann)
|
|
435
|
+
if origin in (list, typing.List):
|
|
436
|
+
item_type = args[0] if args else str
|
|
437
|
+
parts = value_str.split(",") if value_str else []
|
|
438
|
+
# Unwrap Optional for item type
|
|
439
|
+
base_item_type, item_is_opt = self._unwrap_optional(item_type)
|
|
440
|
+
converted_list = []
|
|
441
|
+
for v in parts:
|
|
442
|
+
if item_is_opt and (v == "" or v.lower() == "null"):
|
|
443
|
+
converted_list.append(None)
|
|
444
|
+
continue
|
|
445
|
+
converted_value = self._convert_value(
|
|
446
|
+
v, base_item_type, param_name, is_path_param=True
|
|
447
|
+
)
|
|
448
|
+
if isinstance(converted_value, JSONResponse):
|
|
449
|
+
return converted_value
|
|
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
|
|
317
460
|
|
|
318
461
|
# Call the endpoint function with injected parameters
|
|
319
462
|
if asyncio.iscoroutinefunction(endpoint_func):
|
|
@@ -325,6 +468,13 @@ class Tachyon:
|
|
|
325
468
|
if isinstance(payload, Response):
|
|
326
469
|
return payload
|
|
327
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
|
+
|
|
328
478
|
# Convert Struct objects to dictionaries for JSON serialization
|
|
329
479
|
if isinstance(payload, Struct):
|
|
330
480
|
payload = msgspec.to_builtins(payload)
|
|
@@ -334,7 +484,7 @@ class Tachyon:
|
|
|
334
484
|
if isinstance(value, Struct):
|
|
335
485
|
payload[key] = msgspec.to_builtins(value)
|
|
336
486
|
|
|
337
|
-
return
|
|
487
|
+
return TachyonJSONResponse(payload)
|
|
338
488
|
|
|
339
489
|
# Register the route with Starlette
|
|
340
490
|
route = Route(path, endpoint=handler, methods=[method])
|
|
@@ -365,6 +515,40 @@ class Tachyon:
|
|
|
365
515
|
"""
|
|
366
516
|
sig = inspect.signature(endpoint_func)
|
|
367
517
|
|
|
518
|
+
# Ensure common error schemas exist in components
|
|
519
|
+
self.openapi_generator.add_schema(
|
|
520
|
+
"ValidationErrorResponse",
|
|
521
|
+
{
|
|
522
|
+
"type": "object",
|
|
523
|
+
"properties": {
|
|
524
|
+
"success": {"type": "boolean"},
|
|
525
|
+
"error": {"type": "string"},
|
|
526
|
+
"code": {"type": "string"},
|
|
527
|
+
"errors": {
|
|
528
|
+
"type": "object",
|
|
529
|
+
"additionalProperties": {
|
|
530
|
+
"type": "array",
|
|
531
|
+
"items": {"type": "string"},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
"required": ["success", "error", "code"],
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
self.openapi_generator.add_schema(
|
|
539
|
+
"ResponseValidationError",
|
|
540
|
+
{
|
|
541
|
+
"type": "object",
|
|
542
|
+
"properties": {
|
|
543
|
+
"success": {"type": "boolean"},
|
|
544
|
+
"error": {"type": "string"},
|
|
545
|
+
"detail": {"type": "string"},
|
|
546
|
+
"code": {"type": "string"},
|
|
547
|
+
},
|
|
548
|
+
"required": ["success", "error", "code"],
|
|
549
|
+
},
|
|
550
|
+
)
|
|
551
|
+
|
|
368
552
|
# Build the OpenAPI operation object
|
|
369
553
|
operation = {
|
|
370
554
|
"summary": kwargs.get(
|
|
@@ -375,10 +559,37 @@ class Tachyon:
|
|
|
375
559
|
"200": {
|
|
376
560
|
"description": "Successful Response",
|
|
377
561
|
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
378
|
-
}
|
|
562
|
+
},
|
|
563
|
+
"422": {
|
|
564
|
+
"description": "Validation Error",
|
|
565
|
+
"content": {
|
|
566
|
+
"application/json": {
|
|
567
|
+
"schema": {"$ref": "#/components/schemas/ValidationErrorResponse"}
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
"500": {
|
|
572
|
+
"description": "Response Validation Error",
|
|
573
|
+
"content": {
|
|
574
|
+
"application/json": {
|
|
575
|
+
"schema": {"$ref": "#/components/schemas/ResponseValidationError"}
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
},
|
|
379
579
|
},
|
|
380
580
|
}
|
|
381
581
|
|
|
582
|
+
# If a response_model is provided and is a Struct, use it for the 200 response schema
|
|
583
|
+
response_model = kwargs.get("response_model")
|
|
584
|
+
if response_model is not None and issubclass(response_model, Struct):
|
|
585
|
+
from .openapi import build_components_for_struct
|
|
586
|
+
comps = build_components_for_struct(response_model)
|
|
587
|
+
for name, schema in comps.items():
|
|
588
|
+
self.openapi_generator.add_schema(name, schema)
|
|
589
|
+
operation["responses"]["200"]["content"]["application/json"]["schema"] = {
|
|
590
|
+
"$ref": f"#/components/schemas/{response_model.__name__}"
|
|
591
|
+
}
|
|
592
|
+
|
|
382
593
|
# Add tags if provided
|
|
383
594
|
if "tags" in kwargs:
|
|
384
595
|
operation["tags"] = kwargs["tags"]
|
|
@@ -402,7 +613,7 @@ class Tachyon:
|
|
|
402
613
|
"name": param.name,
|
|
403
614
|
"in": "query",
|
|
404
615
|
"required": param.default.default is ...,
|
|
405
|
-
"schema":
|
|
616
|
+
"schema": self._build_param_openapi_schema(param.annotation),
|
|
406
617
|
"description": getattr(param.default, "description", ""),
|
|
407
618
|
}
|
|
408
619
|
)
|
|
@@ -416,7 +627,7 @@ class Tachyon:
|
|
|
416
627
|
"name": param.name,
|
|
417
628
|
"in": "path",
|
|
418
629
|
"required": True,
|
|
419
|
-
"schema":
|
|
630
|
+
"schema": self._build_param_openapi_schema(param.annotation),
|
|
420
631
|
"description": getattr(param.default, "description", "")
|
|
421
632
|
if isinstance(param.default, Path)
|
|
422
633
|
else "",
|
|
@@ -427,17 +638,16 @@ class Tachyon:
|
|
|
427
638
|
elif isinstance(param.default, Body):
|
|
428
639
|
model_class = param.annotation
|
|
429
640
|
if issubclass(model_class, Struct):
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
)
|
|
641
|
+
from .openapi import build_components_for_struct
|
|
642
|
+
comps = build_components_for_struct(model_class)
|
|
643
|
+
for name, schema in comps.items():
|
|
644
|
+
self.openapi_generator.add_schema(name, schema)
|
|
435
645
|
|
|
436
646
|
request_body_schema = {
|
|
437
647
|
"content": {
|
|
438
648
|
"application/json": {
|
|
439
649
|
"schema": {
|
|
440
|
-
"$ref": f"#/components/schemas/{
|
|
650
|
+
"$ref": f"#/components/schemas/{model_class.__name__}"
|
|
441
651
|
}
|
|
442
652
|
}
|
|
443
653
|
},
|
|
@@ -448,11 +658,9 @@ class Tachyon:
|
|
|
448
658
|
if parameters:
|
|
449
659
|
operation["parameters"] = parameters
|
|
450
660
|
|
|
451
|
-
# Add request body if it exists
|
|
452
661
|
if request_body_schema:
|
|
453
662
|
operation["requestBody"] = request_body_schema
|
|
454
663
|
|
|
455
|
-
# Add the operation to the OpenAPI schema
|
|
456
664
|
self.openapi_generator.add_path(path, method, operation)
|
|
457
665
|
|
|
458
666
|
@staticmethod
|
|
@@ -476,6 +684,44 @@ class Tachyon:
|
|
|
476
684
|
}
|
|
477
685
|
return type_map.get(python_type, "string")
|
|
478
686
|
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _build_param_openapi_schema(python_type: Type) -> Dict[str, Any]:
|
|
689
|
+
"""Build OpenAPI schema for parameter types, supporting Optional[T] and List[T]."""
|
|
690
|
+
origin = typing.get_origin(python_type)
|
|
691
|
+
args = typing.get_args(python_type)
|
|
692
|
+
nullable = False
|
|
693
|
+
# Optional[T]
|
|
694
|
+
if origin is Union and args:
|
|
695
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
696
|
+
if len(non_none) == 1:
|
|
697
|
+
python_type = non_none[0]
|
|
698
|
+
nullable = True
|
|
699
|
+
# List[T] (and List[Optional[T]])
|
|
700
|
+
origin = typing.get_origin(python_type)
|
|
701
|
+
args = typing.get_args(python_type)
|
|
702
|
+
if origin in (list, typing.List):
|
|
703
|
+
item_type = args[0] if args else str
|
|
704
|
+
# Unwrap Optional in items for List[Optional[T]]
|
|
705
|
+
item_origin = typing.get_origin(item_type)
|
|
706
|
+
item_args = typing.get_args(item_type)
|
|
707
|
+
item_nullable = False
|
|
708
|
+
if item_origin is Union and item_args:
|
|
709
|
+
item_non_none = [a for a in item_args if a is not type(None)] # noqa: E721
|
|
710
|
+
if len(item_non_none) == 1:
|
|
711
|
+
item_type = item_non_none[0]
|
|
712
|
+
item_nullable = True
|
|
713
|
+
schema = {
|
|
714
|
+
"type": "array",
|
|
715
|
+
"items": {"type": Tachyon._get_openapi_type(item_type)},
|
|
716
|
+
}
|
|
717
|
+
if item_nullable:
|
|
718
|
+
schema["items"]["nullable"] = True
|
|
719
|
+
else:
|
|
720
|
+
schema = {"type": Tachyon._get_openapi_type(python_type)}
|
|
721
|
+
if nullable:
|
|
722
|
+
schema["nullable"] = True
|
|
723
|
+
return schema
|
|
724
|
+
|
|
479
725
|
def _setup_docs(self):
|
|
480
726
|
"""
|
|
481
727
|
Setup OpenAPI documentation endpoints.
|
|
@@ -576,7 +822,7 @@ class Tachyon:
|
|
|
576
822
|
middleware_class: The middleware class.
|
|
577
823
|
**options: Options to be passed to the middleware constructor.
|
|
578
824
|
"""
|
|
579
|
-
#
|
|
825
|
+
# Use centralized helper to apply middleware to internal Starlette app
|
|
580
826
|
apply_middleware_to_router(self._router, middleware_class, **options)
|
|
581
827
|
|
|
582
828
|
if not hasattr(self, "middleware_stack"):
|
|
@@ -596,11 +842,11 @@ class Tachyon:
|
|
|
596
842
|
"""
|
|
597
843
|
|
|
598
844
|
def decorator(middleware_func):
|
|
599
|
-
#
|
|
845
|
+
# Create a middleware class from the decorated function
|
|
600
846
|
DecoratedMiddleware = create_decorated_middleware_class(
|
|
601
847
|
middleware_func, middleware_type
|
|
602
848
|
)
|
|
603
|
-
#
|
|
849
|
+
# Register the middleware using the existing method
|
|
604
850
|
self.add_middleware(DecoratedMiddleware)
|
|
605
851
|
return middleware_func
|
|
606
852
|
|