tachyon-api 0.5.10__py3-none-any.whl → 0.6.0__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 +6 -6
- tachyon_api/core/__init__.py +7 -0
- tachyon_api/core/app.py +355 -0
- tachyon_api/dependencies/__init__.py +8 -0
- tachyon_api/dependencies/resolver.py +78 -0
- tachyon_api/features/__init__.py +30 -0
- tachyon_api/middlewares/__init__.py +13 -1
- tachyon_api/middlewares/manager.py +70 -0
- tachyon_api/openapi/__init__.py +27 -0
- tachyon_api/openapi/builder.py +315 -0
- tachyon_api/{openapi.py → openapi/schema.py} +1 -1
- tachyon_api/processing/__init__.py +8 -0
- tachyon_api/processing/parameters.py +308 -0
- tachyon_api/processing/responses.py +144 -0
- tachyon_api/routing/__init__.py +8 -0
- tachyon_api/{router.py → routing/router.py} +1 -1
- tachyon_api/routing/routes.py +243 -0
- tachyon_api/schemas/__init__.py +17 -0
- tachyon_api/utils/type_converter.py +1 -1
- {tachyon_api-0.5.10.dist-info → tachyon_api-0.6.0.dist-info}/METADATA +8 -5
- tachyon_api-0.6.0.dist-info/RECORD +33 -0
- tachyon_api/app.py +0 -820
- tachyon_api-0.5.10.dist-info/RECORD +0 -20
- /tachyon_api/{di.py → dependencies/injection.py} +0 -0
- /tachyon_api/{cache.py → features/cache.py} +0 -0
- /tachyon_api/{models.py → schemas/models.py} +0 -0
- /tachyon_api/{params.py → schemas/parameters.py} +0 -0
- /tachyon_api/{responses.py → schemas/responses.py} +0 -0
- {tachyon_api-0.5.10.dist-info → tachyon_api-0.6.0.dist-info}/LICENSE +0 -0
- {tachyon_api-0.5.10.dist-info → tachyon_api-0.6.0.dist-info}/WHEEL +0 -0
tachyon_api/app.py
DELETED
|
@@ -1,820 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Tachyon Web Framework - Main Application Module
|
|
3
|
-
|
|
4
|
-
This module contains the core Tachyon class that provides a lightweight,
|
|
5
|
-
FastAPI-inspired web framework with built-in dependency injection,
|
|
6
|
-
parameter validation, and automatic type conversion.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import asyncio
|
|
10
|
-
import inspect
|
|
11
|
-
import msgspec
|
|
12
|
-
from functools import partial
|
|
13
|
-
from typing import Any, Dict, Type, Union, Callable
|
|
14
|
-
import typing
|
|
15
|
-
|
|
16
|
-
from starlette.applications import Starlette
|
|
17
|
-
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
18
|
-
from starlette.routing import Route
|
|
19
|
-
|
|
20
|
-
from .di import Depends, _registry
|
|
21
|
-
from .models import Struct
|
|
22
|
-
from .openapi import (
|
|
23
|
-
OpenAPIGenerator,
|
|
24
|
-
OpenAPIConfig,
|
|
25
|
-
create_openapi_config,
|
|
26
|
-
)
|
|
27
|
-
from .params import Body, Query, Path
|
|
28
|
-
from .middlewares.core import (
|
|
29
|
-
apply_middleware_to_router,
|
|
30
|
-
create_decorated_middleware_class,
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class Tachyon:
|
|
49
|
-
"""
|
|
50
|
-
Main Tachyon application class.
|
|
51
|
-
|
|
52
|
-
Provides a web framework with automatic parameter validation, dependency injection,
|
|
53
|
-
and type conversion. Built on top of Starlette for ASGI compatibility.
|
|
54
|
-
|
|
55
|
-
Attributes:
|
|
56
|
-
_router: Internal Starlette application instance
|
|
57
|
-
routes: List of registered routes for introspection
|
|
58
|
-
_instances_cache: Cache for dependency injection singleton instances
|
|
59
|
-
openapi_config: Configuration for OpenAPI documentation
|
|
60
|
-
openapi_generator: Generator for OpenAPI schema and documentation
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
def __init__(self, openapi_config: OpenAPIConfig = None, cache_config=None):
|
|
64
|
-
"""
|
|
65
|
-
Initialize a new Tachyon application instance.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
openapi_config: Optional OpenAPI configuration. If not provided,
|
|
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.
|
|
72
|
-
"""
|
|
73
|
-
self._router = Starlette()
|
|
74
|
-
self.routes = []
|
|
75
|
-
self.middleware_stack = []
|
|
76
|
-
self._instances_cache: Dict[Type, Any] = {}
|
|
77
|
-
|
|
78
|
-
# Initialize OpenAPI configuration and generator
|
|
79
|
-
self.openapi_config = openapi_config or create_openapi_config()
|
|
80
|
-
self.openapi_generator = OpenAPIGenerator(self.openapi_config)
|
|
81
|
-
self._docs_setup = False
|
|
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
|
-
|
|
92
|
-
# Dynamically create HTTP method decorators (get, post, put, delete, etc.)
|
|
93
|
-
http_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
|
94
|
-
|
|
95
|
-
for method in http_methods:
|
|
96
|
-
setattr(
|
|
97
|
-
self,
|
|
98
|
-
method.lower(),
|
|
99
|
-
partial(self._create_decorator, http_method=method),
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
def _resolve_dependency(self, cls: Type) -> Any:
|
|
103
|
-
"""
|
|
104
|
-
Resolve a dependency and its sub-dependencies recursively.
|
|
105
|
-
|
|
106
|
-
This method implements dependency injection with singleton pattern,
|
|
107
|
-
automatically resolving constructor dependencies and caching instances.
|
|
108
|
-
|
|
109
|
-
Args:
|
|
110
|
-
cls: The class type to resolve and instantiate
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
An instance of the requested class with all dependencies resolved
|
|
114
|
-
|
|
115
|
-
Raises:
|
|
116
|
-
TypeError: If the class cannot be instantiated or is not marked as injectable
|
|
117
|
-
|
|
118
|
-
Note:
|
|
119
|
-
- Uses singleton pattern - instances are cached and reused
|
|
120
|
-
- Supports both @injectable decorated classes and simple classes
|
|
121
|
-
- Recursively resolves constructor dependencies
|
|
122
|
-
"""
|
|
123
|
-
# Return cached instance if available (singleton pattern)
|
|
124
|
-
if cls in self._instances_cache:
|
|
125
|
-
return self._instances_cache[cls]
|
|
126
|
-
|
|
127
|
-
# For non-injectable classes, try to create without arguments
|
|
128
|
-
if cls not in _registry:
|
|
129
|
-
try:
|
|
130
|
-
# Works for classes without __init__ or with no-arg __init__
|
|
131
|
-
return cls()
|
|
132
|
-
except TypeError:
|
|
133
|
-
raise TypeError(
|
|
134
|
-
f"Cannot resolve dependency '{cls.__name__}'. "
|
|
135
|
-
f"Did you forget to mark it with @injectable?"
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
# For injectable classes, resolve constructor dependencies
|
|
139
|
-
sig = inspect.signature(cls)
|
|
140
|
-
dependencies = {}
|
|
141
|
-
|
|
142
|
-
# Recursively resolve each constructor parameter
|
|
143
|
-
for param in sig.parameters.values():
|
|
144
|
-
if param.name != "self":
|
|
145
|
-
dependencies[param.name] = self._resolve_dependency(param.annotation)
|
|
146
|
-
|
|
147
|
-
# Create instance with resolved dependencies and cache it
|
|
148
|
-
instance = cls(**dependencies)
|
|
149
|
-
self._instances_cache[cls] = instance
|
|
150
|
-
return instance
|
|
151
|
-
|
|
152
|
-
def _create_decorator(self, path: str, *, http_method: str, **kwargs):
|
|
153
|
-
"""
|
|
154
|
-
Create a decorator for the specified HTTP method.
|
|
155
|
-
|
|
156
|
-
This factory method creates method-specific decorators (e.g., @app.get, @app.post)
|
|
157
|
-
that register endpoint functions with the application.
|
|
158
|
-
|
|
159
|
-
Args:
|
|
160
|
-
path: URL path pattern (supports path parameters with {param} syntax)
|
|
161
|
-
http_method: HTTP method name (GET, POST, PUT, DELETE, etc.)
|
|
162
|
-
|
|
163
|
-
Returns:
|
|
164
|
-
A decorator function that registers the endpoint
|
|
165
|
-
"""
|
|
166
|
-
|
|
167
|
-
def decorator(endpoint_func: Callable):
|
|
168
|
-
self._add_route(path, endpoint_func, http_method, **kwargs)
|
|
169
|
-
return endpoint_func
|
|
170
|
-
|
|
171
|
-
return decorator
|
|
172
|
-
|
|
173
|
-
def _add_route(self, path: str, endpoint_func: Callable, method: str, **kwargs):
|
|
174
|
-
"""
|
|
175
|
-
Register a route with the application and create an async handler.
|
|
176
|
-
|
|
177
|
-
This is the core method that handles parameter injection, validation, and
|
|
178
|
-
type conversion. It creates an async handler that processes requests and
|
|
179
|
-
automatically injects dependencies, path parameters, query parameters, and
|
|
180
|
-
request body data into the endpoint function.
|
|
181
|
-
|
|
182
|
-
Args:
|
|
183
|
-
path: URL path pattern (e.g., "/users/{user_id}")
|
|
184
|
-
endpoint_func: The endpoint function to handle requests
|
|
185
|
-
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
186
|
-
|
|
187
|
-
Note:
|
|
188
|
-
The created handler processes parameters in the following order:
|
|
189
|
-
1. Dependencies (explicit with Depends() or implicit via @injectable)
|
|
190
|
-
2. Body parameters (JSON request body validated against Struct models)
|
|
191
|
-
3. Query parameters (URL query string with type conversion)
|
|
192
|
-
4. Path parameters (both explicit with Path() and implicit from URL)
|
|
193
|
-
"""
|
|
194
|
-
|
|
195
|
-
response_model = kwargs.get("response_model")
|
|
196
|
-
|
|
197
|
-
async def handler(request):
|
|
198
|
-
"""
|
|
199
|
-
Async request handler that processes parameters and calls the endpoint.
|
|
200
|
-
|
|
201
|
-
This handler analyzes the endpoint function signature and automatically
|
|
202
|
-
injects the appropriate values based on parameter annotations and defaults.
|
|
203
|
-
"""
|
|
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
|
|
218
|
-
)
|
|
219
|
-
|
|
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
|
|
225
|
-
)
|
|
226
|
-
|
|
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
|
|
386
|
-
value_str = path_params[param_name]
|
|
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()
|
|
449
|
-
|
|
450
|
-
# Register the route with Starlette
|
|
451
|
-
route = Route(path, endpoint=handler, methods=[method])
|
|
452
|
-
self._router.routes.append(route)
|
|
453
|
-
self.routes.append(
|
|
454
|
-
{"path": path, "method": method, "func": endpoint_func, **kwargs}
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
# Generate OpenAPI documentation for this route
|
|
458
|
-
include_in_schema = kwargs.get("include_in_schema", True)
|
|
459
|
-
if include_in_schema:
|
|
460
|
-
self._generate_openapi_for_route(path, method, endpoint_func, **kwargs)
|
|
461
|
-
|
|
462
|
-
def _generate_openapi_for_route(
|
|
463
|
-
self, path: str, method: str, endpoint_func: Callable, **kwargs
|
|
464
|
-
):
|
|
465
|
-
"""
|
|
466
|
-
Generate OpenAPI documentation for a specific route.
|
|
467
|
-
|
|
468
|
-
This method analyzes the endpoint function signature and generates appropriate
|
|
469
|
-
OpenAPI schema entries for parameters, request body, and responses.
|
|
470
|
-
|
|
471
|
-
Args:
|
|
472
|
-
path: URL path pattern
|
|
473
|
-
method: HTTP method
|
|
474
|
-
endpoint_func: The endpoint function
|
|
475
|
-
**kwargs: Additional route metadata (summary, description, tags, etc.)
|
|
476
|
-
"""
|
|
477
|
-
sig = inspect.signature(endpoint_func)
|
|
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
|
-
|
|
513
|
-
# Build the OpenAPI operation object
|
|
514
|
-
operation = {
|
|
515
|
-
"summary": kwargs.get(
|
|
516
|
-
"summary", self._generate_summary_from_function(endpoint_func)
|
|
517
|
-
),
|
|
518
|
-
"description": kwargs.get("description", endpoint_func.__doc__ or ""),
|
|
519
|
-
"responses": {
|
|
520
|
-
"200": {
|
|
521
|
-
"description": "Successful Response",
|
|
522
|
-
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
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
|
-
},
|
|
544
|
-
},
|
|
545
|
-
}
|
|
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
|
-
|
|
559
|
-
# Add tags if provided
|
|
560
|
-
if "tags" in kwargs:
|
|
561
|
-
operation["tags"] = kwargs["tags"]
|
|
562
|
-
|
|
563
|
-
# Process parameters from function signature
|
|
564
|
-
parameters = []
|
|
565
|
-
request_body_schema = None
|
|
566
|
-
|
|
567
|
-
for param in sig.parameters.values():
|
|
568
|
-
# Skip dependency parameters
|
|
569
|
-
if isinstance(param.default, Depends) or (
|
|
570
|
-
param.default is inspect.Parameter.empty
|
|
571
|
-
and param.annotation in _registry
|
|
572
|
-
):
|
|
573
|
-
continue
|
|
574
|
-
|
|
575
|
-
# Process query parameters
|
|
576
|
-
elif isinstance(param.default, Query):
|
|
577
|
-
parameters.append(
|
|
578
|
-
{
|
|
579
|
-
"name": param.name,
|
|
580
|
-
"in": "query",
|
|
581
|
-
"required": param.default.default is ...,
|
|
582
|
-
"schema": self._build_param_openapi_schema(param.annotation),
|
|
583
|
-
"description": getattr(param.default, "description", ""),
|
|
584
|
-
}
|
|
585
|
-
)
|
|
586
|
-
|
|
587
|
-
# Process path parameters
|
|
588
|
-
elif isinstance(param.default, Path) or self._is_path_parameter(
|
|
589
|
-
param.name, path
|
|
590
|
-
):
|
|
591
|
-
parameters.append(
|
|
592
|
-
{
|
|
593
|
-
"name": param.name,
|
|
594
|
-
"in": "path",
|
|
595
|
-
"required": True,
|
|
596
|
-
"schema": self._build_param_openapi_schema(param.annotation),
|
|
597
|
-
"description": getattr(param.default, "description", "")
|
|
598
|
-
if isinstance(param.default, Path)
|
|
599
|
-
else "",
|
|
600
|
-
}
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
# Process body parameters
|
|
604
|
-
elif isinstance(param.default, Body):
|
|
605
|
-
model_class = param.annotation
|
|
606
|
-
if issubclass(model_class, Struct):
|
|
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)
|
|
612
|
-
|
|
613
|
-
request_body_schema = {
|
|
614
|
-
"content": {
|
|
615
|
-
"application/json": {
|
|
616
|
-
"schema": {
|
|
617
|
-
"$ref": f"#/components/schemas/{model_class.__name__}"
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
},
|
|
621
|
-
"required": True,
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
# Add parameters to operation if any exist
|
|
625
|
-
if parameters:
|
|
626
|
-
operation["parameters"] = parameters
|
|
627
|
-
|
|
628
|
-
if request_body_schema:
|
|
629
|
-
operation["requestBody"] = request_body_schema
|
|
630
|
-
|
|
631
|
-
self.openapi_generator.add_path(path, method, operation)
|
|
632
|
-
|
|
633
|
-
@staticmethod
|
|
634
|
-
def _generate_summary_from_function(func: Callable) -> str:
|
|
635
|
-
"""Generate a human-readable summary from function name."""
|
|
636
|
-
return func.__name__.replace("_", " ").title()
|
|
637
|
-
|
|
638
|
-
@staticmethod
|
|
639
|
-
def _is_path_parameter(param_name: str, path: str) -> bool:
|
|
640
|
-
"""Check if a parameter name corresponds to a path parameter in the URL."""
|
|
641
|
-
return f"{{{param_name}}}" in path
|
|
642
|
-
|
|
643
|
-
@staticmethod
|
|
644
|
-
def _get_openapi_type(python_type: Type) -> str:
|
|
645
|
-
"""Convert Python type to OpenAPI schema type."""
|
|
646
|
-
type_map: Dict[Type, str] = {
|
|
647
|
-
int: "integer",
|
|
648
|
-
str: "string",
|
|
649
|
-
bool: "boolean",
|
|
650
|
-
float: "number",
|
|
651
|
-
}
|
|
652
|
-
return type_map.get(python_type, "string")
|
|
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
|
-
|
|
692
|
-
def _setup_docs(self):
|
|
693
|
-
"""
|
|
694
|
-
Setup OpenAPI documentation endpoints.
|
|
695
|
-
|
|
696
|
-
This method registers the routes for serving OpenAPI JSON schema,
|
|
697
|
-
Swagger UI, and ReDoc documentation interfaces.
|
|
698
|
-
"""
|
|
699
|
-
if self._docs_setup:
|
|
700
|
-
return
|
|
701
|
-
|
|
702
|
-
self._docs_setup = True
|
|
703
|
-
|
|
704
|
-
# OpenAPI JSON schema endpoint
|
|
705
|
-
@self.get(self.openapi_config.openapi_url, include_in_schema=False)
|
|
706
|
-
def get_openapi_schema():
|
|
707
|
-
"""Serve the OpenAPI JSON schema."""
|
|
708
|
-
return self.openapi_generator.get_openapi_schema()
|
|
709
|
-
|
|
710
|
-
# Scalar API Reference documentation endpoint (default for /docs)
|
|
711
|
-
@self.get(self.openapi_config.docs_url, include_in_schema=False)
|
|
712
|
-
def get_scalar_docs():
|
|
713
|
-
"""Serve the Scalar API Reference documentation interface."""
|
|
714
|
-
html = self.openapi_generator.get_scalar_html(
|
|
715
|
-
self.openapi_config.openapi_url, self.openapi_config.info.title
|
|
716
|
-
)
|
|
717
|
-
return HTMLResponse(html)
|
|
718
|
-
|
|
719
|
-
# Swagger UI documentation endpoint (legacy support)
|
|
720
|
-
@self.get("/swagger", include_in_schema=False)
|
|
721
|
-
def get_swagger_ui():
|
|
722
|
-
"""Serve the Swagger UI documentation interface."""
|
|
723
|
-
html = self.openapi_generator.get_swagger_ui_html(
|
|
724
|
-
self.openapi_config.openapi_url, self.openapi_config.info.title
|
|
725
|
-
)
|
|
726
|
-
return HTMLResponse(html)
|
|
727
|
-
|
|
728
|
-
# ReDoc documentation endpoint
|
|
729
|
-
@self.get(self.openapi_config.redoc_url, include_in_schema=False)
|
|
730
|
-
def get_redoc():
|
|
731
|
-
"""Serve the ReDoc documentation interface."""
|
|
732
|
-
html = self.openapi_generator.get_redoc_html(
|
|
733
|
-
self.openapi_config.openapi_url, self.openapi_config.info.title
|
|
734
|
-
)
|
|
735
|
-
return HTMLResponse(html)
|
|
736
|
-
|
|
737
|
-
async def __call__(self, scope, receive, send):
|
|
738
|
-
"""
|
|
739
|
-
ASGI application entry point.
|
|
740
|
-
|
|
741
|
-
Delegates request handling to the internal Starlette application.
|
|
742
|
-
This makes Tachyon compatible with ASGI servers like Uvicorn.
|
|
743
|
-
"""
|
|
744
|
-
# Setup documentation endpoints on first request
|
|
745
|
-
if not self._docs_setup:
|
|
746
|
-
self._setup_docs()
|
|
747
|
-
await self._router(scope, receive, send)
|
|
748
|
-
|
|
749
|
-
def include_router(self, router, **kwargs):
|
|
750
|
-
"""
|
|
751
|
-
Include a Router instance in the application.
|
|
752
|
-
|
|
753
|
-
This method registers all routes from the router with the main application,
|
|
754
|
-
applying the router's prefix, tags, and dependencies.
|
|
755
|
-
|
|
756
|
-
Args:
|
|
757
|
-
router: The Router instance to include
|
|
758
|
-
**kwargs: Additional options (currently reserved for future use)
|
|
759
|
-
"""
|
|
760
|
-
from .router import Router
|
|
761
|
-
|
|
762
|
-
if not isinstance(router, Router):
|
|
763
|
-
raise TypeError("Expected Router instance")
|
|
764
|
-
|
|
765
|
-
# Register all routes from the router
|
|
766
|
-
for route_info in router.routes:
|
|
767
|
-
# Get the full path with prefix
|
|
768
|
-
full_path = router.get_full_path(route_info["path"])
|
|
769
|
-
|
|
770
|
-
# Create a copy of route info with the full path
|
|
771
|
-
route_kwargs = route_info.copy()
|
|
772
|
-
route_kwargs.pop("path", None)
|
|
773
|
-
route_kwargs.pop("method", None)
|
|
774
|
-
route_kwargs.pop("func", None)
|
|
775
|
-
|
|
776
|
-
# Register the route with the main app
|
|
777
|
-
self._add_route(
|
|
778
|
-
full_path, route_info["func"], route_info["method"], **route_kwargs
|
|
779
|
-
)
|
|
780
|
-
|
|
781
|
-
def add_middleware(self, middleware_class, **options):
|
|
782
|
-
"""
|
|
783
|
-
Adds a middleware to the application's stack.
|
|
784
|
-
|
|
785
|
-
Middlewares are processed in the order they are added. They follow
|
|
786
|
-
the ASGI middleware specification.
|
|
787
|
-
|
|
788
|
-
Args:
|
|
789
|
-
middleware_class: The middleware class.
|
|
790
|
-
**options: Options to be passed to the middleware constructor.
|
|
791
|
-
"""
|
|
792
|
-
# Use centralized helper to apply middleware to internal Starlette app
|
|
793
|
-
apply_middleware_to_router(self._router, middleware_class, **options)
|
|
794
|
-
|
|
795
|
-
if not hasattr(self, "middleware_stack"):
|
|
796
|
-
self.middleware_stack = []
|
|
797
|
-
self.middleware_stack.append({"func": middleware_class, "options": options})
|
|
798
|
-
|
|
799
|
-
def middleware(self, middleware_type="http"):
|
|
800
|
-
"""
|
|
801
|
-
Decorator for adding a middleware to the application.
|
|
802
|
-
Similar to route decorators (@app.get, etc.)
|
|
803
|
-
|
|
804
|
-
Args:
|
|
805
|
-
middleware_type: Type of middleware ('http' by default)
|
|
806
|
-
|
|
807
|
-
Returns:
|
|
808
|
-
A decorator that registers the decorated function as middleware.
|
|
809
|
-
"""
|
|
810
|
-
|
|
811
|
-
def decorator(middleware_func):
|
|
812
|
-
# Create a middleware class from the decorated function
|
|
813
|
-
DecoratedMiddleware = create_decorated_middleware_class(
|
|
814
|
-
middleware_func, middleware_type
|
|
815
|
-
)
|
|
816
|
-
# Register the middleware using the existing method
|
|
817
|
-
self.add_middleware(DecoratedMiddleware)
|
|
818
|
-
return middleware_func
|
|
819
|
-
|
|
820
|
-
return decorator
|