tachyon-api 0.5.5__py3-none-any.whl → 0.5.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tachyon-api might be problematic. Click here for more details.

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