tachyon-api 0.5.7__tar.gz → 0.5.10__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.

@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tachyon-api
3
- Version: 0.5.7
3
+ Version: 0.5.10
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
7
7
  Author-email: jm.panozzozenere@gmail.com
8
- Requires-Python: >=3.10,<4.0
8
+ Requires-Python: >=3.10,<3.14
9
9
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.10
@@ -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, LoggerMiddleware
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, and now end-to-end safety with OpenAPI:
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, LoggerMiddleware
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, and now end-to-end safety with OpenAPI:
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,18 +1,17 @@
1
1
  [tool.poetry]
2
2
  name = "tachyon-api"
3
- version = "0.5.7"
3
+ version = "0.5.10"
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"
7
7
  readme = "README.md"
8
8
 
9
9
  [tool.poetry.dependencies]
10
- python = "^3.10"
10
+ python = ">=3.10,<3.14"
11
11
  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
- # New: optional cache configuration support
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 Exception: # pragma: no cover - defensive import guard
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
- kwargs_to_inject = {}
261
- sig = inspect.signature(endpoint_func)
262
- query_params = request.query_params
263
- path_params = request.path_params
264
- _raw_body = None
265
-
266
- # Process each parameter in the endpoint function signature
267
- for param in sig.parameters.values():
268
- # Determine if this parameter is a dependency
269
- is_explicit_dependency = isinstance(param.default, Depends)
270
- is_implicit_dependency = (
271
- param.default is inspect.Parameter.empty
272
- and param.annotation in _registry
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
- # Process Body parameters (JSON request body)
283
- elif isinstance(param.default, Body):
284
- model_class = param.annotation
285
- if not issubclass(model_class, Struct):
286
- raise TypeError(
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
- decoder = msgspec.json.Decoder(model_class)
291
- try:
292
- if _raw_body is None:
293
- _raw_body = await request.body()
294
- validated_data = decoder.decode(_raw_body)
295
- kwargs_to_inject[param.name] = validated_data
296
- except msgspec.ValidationError as e:
297
- # Attempt to build field errors map using e.path
298
- field_errors = None
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
- 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:
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
- return validation_error_response(str(e), errors=field_errors)
313
-
314
- # Process Query parameters (URL query string)
315
- elif isinstance(param.default, Query):
316
- query_info = param.default
317
- param_name = param.name
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
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
- f"Missing required query parameter: {param_name}"
258
+ str(e), errors=field_errors
349
259
  )
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
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
- converted_list.append(converted_value)
363
- kwargs_to_inject[param_name] = converted_list
364
- continue
325
+ kwargs_to_inject[param_name] = converted_value
365
326
 
366
- # Optional[T] handling for single value
367
- base_type, _is_opt = self._unwrap_optional(ann)
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
- if param_name in query_params:
370
- value_str = query_params[param_name]
371
- converted_value = self._convert_value(
372
- value_str, base_type, param_name, is_path_param=False
373
- )
374
- if isinstance(converted_value, JSONResponse):
375
- return converted_value
376
- kwargs_to_inject[param_name] = converted_value
377
-
378
- elif query_info.default is not ...:
379
- kwargs_to_inject[param.name] = query_info.default
380
- else:
381
- return validation_error_response(
382
- f"Missing required query parameter: {param_name}"
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
- # Process explicit Path parameters (with Path() annotation)
386
- elif isinstance(param.default, Path):
387
- param_name = param.name
388
- if param_name in path_params:
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] in path params via CSV
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 = self._unwrap_optional(item_type)
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 = self._convert_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 = self._convert_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
- else:
420
- return JSONResponse({"detail": "Not Found"}, status_code=404)
421
-
422
- # Process implicit Path parameters (URL path variables without Path())
423
- elif (
424
- param.default is inspect.Parameter.empty
425
- and param.name in path_params
426
- and not is_explicit_dependency
427
- and not is_implicit_dependency
428
- ):
429
- param_name = param.name
430
- value_str = path_params[param_name]
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
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": {"$ref": "#/components/schemas/ValidationErrorResponse"}
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": {"$ref": "#/components/schemas/ResponseValidationError"}
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(self, key: str, value: Any, ttl: Optional[float] = None) -> None: # pragma: no cover - interface
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] + [f"{k}={repr(v)}" for k, v in sorted(kwargs.items())]
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 = key_builder or cfg.key_builder or (lambda f, a, kw: _default_key_builder(f, a, kw))
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
-
@@ -2,4 +2,3 @@ from .cors import CORSMiddleware
2
2
  from .logger import LoggerMiddleware
3
3
 
4
4
  __all__ = ["CORSMiddleware", "LoggerMiddleware"]
5
-
@@ -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") is not None
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(headers_out, "access-control-allow-credentials", "true")
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 = ", ".join(self.allow_methods) if "*" not in self.allow_methods else "*"
102
- self._append_header(resp_headers, "access-control-allow-methods", allow_methods)
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(req_headers, "access-control-request-headers")
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(resp_headers, "access-control-allow-headers", allow_headers)
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(resp_headers, "access-control-max-age", str(self.max_age))
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
- "type": "http.response.start",
120
- "status": 200,
121
- "headers": resp_headers,
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("utf-8", "replace")
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(self.level, f"<-- {method} {path} {status} ({duration:.4f}s)")
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(struct_class: Type[Struct]) -> Dict[str, Dict[str, Any]]:
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
- # Convert parameters to JSON string, handling Python booleans correctly
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
- {"success": False, "error": msg, "detail": msg, "code": "RESPONSE_VALIDATION_ERROR"},
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