tachyon-api 0.9.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.
Files changed (44) hide show
  1. tachyon_api/__init__.py +59 -0
  2. tachyon_api/app.py +699 -0
  3. tachyon_api/background.py +72 -0
  4. tachyon_api/cache.py +270 -0
  5. tachyon_api/cli/__init__.py +9 -0
  6. tachyon_api/cli/__main__.py +8 -0
  7. tachyon_api/cli/commands/__init__.py +5 -0
  8. tachyon_api/cli/commands/generate.py +190 -0
  9. tachyon_api/cli/commands/lint.py +186 -0
  10. tachyon_api/cli/commands/new.py +82 -0
  11. tachyon_api/cli/commands/openapi.py +128 -0
  12. tachyon_api/cli/main.py +69 -0
  13. tachyon_api/cli/templates/__init__.py +8 -0
  14. tachyon_api/cli/templates/project.py +194 -0
  15. tachyon_api/cli/templates/service.py +330 -0
  16. tachyon_api/core/__init__.py +12 -0
  17. tachyon_api/core/lifecycle.py +106 -0
  18. tachyon_api/core/websocket.py +92 -0
  19. tachyon_api/di.py +86 -0
  20. tachyon_api/exceptions.py +39 -0
  21. tachyon_api/files.py +14 -0
  22. tachyon_api/middlewares/__init__.py +4 -0
  23. tachyon_api/middlewares/core.py +40 -0
  24. tachyon_api/middlewares/cors.py +159 -0
  25. tachyon_api/middlewares/logger.py +123 -0
  26. tachyon_api/models.py +73 -0
  27. tachyon_api/openapi.py +419 -0
  28. tachyon_api/params.py +268 -0
  29. tachyon_api/processing/__init__.py +14 -0
  30. tachyon_api/processing/dependencies.py +172 -0
  31. tachyon_api/processing/parameters.py +484 -0
  32. tachyon_api/processing/response_processor.py +93 -0
  33. tachyon_api/responses.py +92 -0
  34. tachyon_api/router.py +161 -0
  35. tachyon_api/security.py +295 -0
  36. tachyon_api/testing.py +110 -0
  37. tachyon_api/utils/__init__.py +15 -0
  38. tachyon_api/utils/type_converter.py +113 -0
  39. tachyon_api/utils/type_utils.py +162 -0
  40. tachyon_api-0.9.0.dist-info/METADATA +291 -0
  41. tachyon_api-0.9.0.dist-info/RECORD +44 -0
  42. tachyon_api-0.9.0.dist-info/WHEEL +4 -0
  43. tachyon_api-0.9.0.dist-info/entry_points.txt +3 -0
  44. tachyon_api-0.9.0.dist-info/licenses/LICENSE +17 -0
@@ -0,0 +1,172 @@
1
+ """
2
+ Dependency resolution for Tachyon applications.
3
+
4
+ Handles:
5
+ - Type-based dependency injection (@injectable)
6
+ - Callable dependency injection (Depends(callable))
7
+ - Nested dependencies
8
+ - Dependency caching (singleton and per-request)
9
+ """
10
+
11
+ import asyncio
12
+ import inspect
13
+ from typing import Any, Callable, Dict, Type
14
+
15
+ from starlette.requests import Request
16
+
17
+ from ..di import Depends, _registry
18
+
19
+
20
+ class DependencyResolver:
21
+ """
22
+ Resolves dependencies for endpoint functions.
23
+
24
+ This class encapsulates the logic for:
25
+ - Resolving @injectable classes
26
+ - Resolving Depends(callable) functions
27
+ - Handling nested dependencies
28
+ - Caching instances
29
+ - Supporting dependency overrides for testing
30
+ """
31
+
32
+ def __init__(self, app_instance):
33
+ """
34
+ Initialize dependency resolver.
35
+
36
+ Args:
37
+ app_instance: The Tachyon app instance
38
+ """
39
+ self.app = app_instance
40
+
41
+ def resolve_dependency(self, cls: Type) -> Any:
42
+ """
43
+ Resolve a dependency and its sub-dependencies recursively.
44
+
45
+ This method implements dependency injection with singleton pattern,
46
+ automatically resolving constructor dependencies and caching instances.
47
+
48
+ Args:
49
+ cls: The class type to resolve and instantiate
50
+
51
+ Returns:
52
+ An instance of the requested class with all dependencies resolved
53
+
54
+ Raises:
55
+ TypeError: If the class cannot be instantiated or is not marked as injectable
56
+
57
+ Note:
58
+ - Uses singleton pattern - instances are cached and reused
59
+ - Supports both @injectable decorated classes and simple classes
60
+ - Recursively resolves constructor dependencies
61
+ - Checks dependency_overrides for test mocking
62
+ """
63
+ # Check for dependency override (for testing)
64
+ if cls in self.app.dependency_overrides:
65
+ override = self.app.dependency_overrides[cls]
66
+ # If override is callable, call it to get the instance
67
+ if callable(override) and not isinstance(override, type):
68
+ return override()
69
+ # If it's a class, instantiate it
70
+ elif isinstance(override, type):
71
+ return override()
72
+ # Otherwise return as-is
73
+ return override
74
+
75
+ # Return cached instance if available (singleton pattern)
76
+ if cls in self.app._instances_cache:
77
+ return self.app._instances_cache[cls]
78
+
79
+ # For non-injectable classes, try to create without arguments
80
+ if cls not in _registry:
81
+ try:
82
+ # Works for classes without __init__ or with no-arg __init__
83
+ return cls()
84
+ except TypeError:
85
+ raise TypeError(
86
+ f"Cannot resolve dependency '{cls.__name__}'. "
87
+ f"Did you forget to mark it with @injectable?"
88
+ )
89
+
90
+ # For injectable classes, resolve constructor dependencies
91
+ sig = inspect.signature(cls)
92
+ dependencies = {}
93
+
94
+ # Recursively resolve each constructor parameter
95
+ for param in sig.parameters.values():
96
+ if param.name != "self":
97
+ dependencies[param.name] = self.resolve_dependency(param.annotation)
98
+
99
+ # Create instance with resolved dependencies and cache it
100
+ instance = cls(**dependencies)
101
+ self.app._instances_cache[cls] = instance
102
+ return instance
103
+
104
+ async def resolve_callable_dependency(
105
+ self, dependency: Callable, cache: Dict, request: Request
106
+ ) -> Any:
107
+ """
108
+ Resolve a callable dependency (function, lambda, or class).
109
+
110
+ This method calls the dependency function to get its value, supporting
111
+ both sync and async functions. It also handles nested dependencies
112
+ if the callable has parameters with Depends() or Request annotations.
113
+
114
+ Args:
115
+ dependency: The callable to invoke
116
+ cache: Per-request cache to avoid calling the same dependency twice
117
+ request: The current request object for injection
118
+
119
+ Returns:
120
+ The result of calling the dependency function
121
+
122
+ Note:
123
+ - Results are cached per-request to avoid duplicate calls
124
+ - Supports async callables (coroutines)
125
+ - Supports nested Depends() in callable parameters
126
+ - Automatically injects Request when parameter is annotated with Request
127
+ """
128
+ # Check for dependency override (for testing)
129
+ if dependency in self.app.dependency_overrides:
130
+ override = self.app.dependency_overrides[dependency]
131
+ # If override is callable, call it
132
+ if callable(override):
133
+ result = override()
134
+ if asyncio.iscoroutine(result):
135
+ result = await result
136
+ return result
137
+ return override
138
+
139
+ # Check cache first (same callable = same result per request)
140
+ if dependency in cache:
141
+ return cache[dependency]
142
+
143
+ # Check if the dependency has its own dependencies (nested)
144
+ sig = inspect.signature(dependency)
145
+ nested_kwargs = {}
146
+
147
+ for param in sig.parameters.values():
148
+ # Inject Request object if parameter is annotated with Request
149
+ if param.annotation is Request:
150
+ nested_kwargs[param.name] = request
151
+ elif isinstance(param.default, Depends):
152
+ if param.default.dependency is not None:
153
+ # Nested callable dependency
154
+ nested_kwargs[param.name] = await self.resolve_callable_dependency(
155
+ param.default.dependency, cache, request
156
+ )
157
+ else:
158
+ # Nested type-based dependency
159
+ nested_kwargs[param.name] = self.resolve_dependency(
160
+ param.annotation
161
+ )
162
+
163
+ # Call the dependency (sync or async)
164
+ # Note: asyncio.iscoroutinefunction doesn't work for async __call__ methods,
165
+ # so we check if the result is a coroutine
166
+ result = dependency(**nested_kwargs)
167
+ if asyncio.iscoroutine(result):
168
+ result = await result
169
+
170
+ # Cache the result for this request
171
+ cache[dependency] = result
172
+ return result
@@ -0,0 +1,484 @@
1
+ """
2
+ Parameter processing for Tachyon applications.
3
+
4
+ Handles extraction and validation of:
5
+ - Path parameters
6
+ - Query parameters
7
+ - Body parameters
8
+ - Header parameters
9
+ - Cookie parameters
10
+ - Form parameters
11
+ - File uploads
12
+ """
13
+
14
+ import inspect
15
+ import msgspec
16
+ import typing
17
+ from typing import Dict, Any, Optional
18
+
19
+ from starlette.requests import Request
20
+ from starlette.responses import JSONResponse
21
+
22
+ from ..params import Body, Query, Path, Header, Cookie, Form, File
23
+ from ..models import Struct
24
+ from ..responses import validation_error_response
25
+ from ..utils import TypeConverter, TypeUtils
26
+ from ..background import BackgroundTasks
27
+ from ..di import Depends, _registry
28
+
29
+
30
+ class ParameterProcessor:
31
+ """
32
+ Processes and extracts parameters from HTTP requests.
33
+
34
+ This class encapsulates all the complex logic for:
35
+ - Detecting parameter types
36
+ - Extracting values from request
37
+ - Type conversion and validation
38
+ - Handling optional/required parameters
39
+ """
40
+
41
+ def __init__(self, app_instance):
42
+ """
43
+ Initialize parameter processor.
44
+
45
+ Args:
46
+ app_instance: The Tachyon app instance (for dependency resolution)
47
+ """
48
+ self.app = app_instance
49
+
50
+ async def process_parameters(
51
+ self,
52
+ endpoint_func,
53
+ request: Request,
54
+ dependency_cache: Dict,
55
+ ) -> tuple[Dict[str, Any], Optional[JSONResponse], Optional[Any]]:
56
+ """
57
+ Process all parameters for an endpoint function.
58
+
59
+ Args:
60
+ endpoint_func: The endpoint function to analyze
61
+ request: The incoming HTTP request
62
+ dependency_cache: Cache for callable dependencies
63
+
64
+ Returns:
65
+ Tuple of (kwargs_to_inject, error_response, background_tasks)
66
+ - kwargs_to_inject: Dictionary of parameter name -> value
67
+ - error_response: JSONResponse if validation error occurred, None otherwise
68
+ - background_tasks: BackgroundTasks instance if requested, None otherwise
69
+ """
70
+ kwargs_to_inject = {}
71
+ sig = inspect.signature(endpoint_func)
72
+ query_params = request.query_params
73
+ path_params = request.path_params
74
+ _raw_body = None
75
+ _form_data = None # Lazy-loaded form data for Form/File params
76
+ _background_tasks = None
77
+
78
+ # Process each parameter in the endpoint function signature
79
+ for param in sig.parameters.values():
80
+ # Check for Request object injection
81
+ if param.annotation is Request:
82
+ kwargs_to_inject[param.name] = request
83
+ continue
84
+
85
+ # Check for BackgroundTasks injection
86
+ if param.annotation is BackgroundTasks:
87
+ if _background_tasks is None:
88
+ _background_tasks = BackgroundTasks()
89
+ kwargs_to_inject[param.name] = _background_tasks
90
+ continue
91
+
92
+ # Determine if this parameter is a dependency
93
+ is_explicit_dependency = isinstance(param.default, Depends)
94
+ is_implicit_dependency = (
95
+ param.default is inspect.Parameter.empty
96
+ and param.annotation in _registry
97
+ )
98
+
99
+ # Process dependencies (explicit and implicit)
100
+ if is_explicit_dependency or is_implicit_dependency:
101
+ if (
102
+ is_explicit_dependency
103
+ and param.default.dependency is not None
104
+ ):
105
+ # Depends(callable) - call the factory function
106
+ resolved = await self.app._dependency_resolver.resolve_callable_dependency(
107
+ param.default.dependency, dependency_cache, request
108
+ )
109
+ kwargs_to_inject[param.name] = resolved
110
+ else:
111
+ # Depends() or implicit - resolve by type annotation
112
+ target_class = param.annotation
113
+ kwargs_to_inject[param.name] = self.app._dependency_resolver.resolve_dependency(
114
+ target_class
115
+ )
116
+
117
+ # Process Body parameters (JSON request body)
118
+ elif isinstance(param.default, Body):
119
+ result = await self._process_body_param(
120
+ param, request, kwargs_to_inject
121
+ )
122
+ if result is not None: # error response
123
+ return kwargs_to_inject, result, _background_tasks
124
+ # Check if _raw_body needs updating (for subsequent body params)
125
+ if _raw_body is None:
126
+ _raw_body = True # Mark as loaded
127
+
128
+ # Process Query parameters
129
+ elif isinstance(param.default, Query):
130
+ error_response = self._process_query_param(
131
+ param, query_params, kwargs_to_inject
132
+ )
133
+ if error_response:
134
+ return kwargs_to_inject, error_response, _background_tasks
135
+
136
+ # Process Header parameters
137
+ elif isinstance(param.default, Header):
138
+ error_response = self._process_header_param(
139
+ param, request, kwargs_to_inject
140
+ )
141
+ if error_response:
142
+ return kwargs_to_inject, error_response, _background_tasks
143
+
144
+ # Process Cookie parameters
145
+ elif isinstance(param.default, Cookie):
146
+ error_response = self._process_cookie_param(
147
+ param, request, kwargs_to_inject
148
+ )
149
+ if error_response:
150
+ return kwargs_to_inject, error_response, _background_tasks
151
+
152
+ # Process Form parameters
153
+ elif isinstance(param.default, Form):
154
+ if _form_data is None:
155
+ _form_data = await request.form()
156
+ error_response = self._process_form_param(
157
+ param, _form_data, kwargs_to_inject
158
+ )
159
+ if error_response:
160
+ return kwargs_to_inject, error_response, _background_tasks
161
+
162
+ # Process File parameters
163
+ elif isinstance(param.default, File):
164
+ if _form_data is None:
165
+ _form_data = await request.form()
166
+ error_response = self._process_file_param(
167
+ param, _form_data, kwargs_to_inject
168
+ )
169
+ if error_response:
170
+ return kwargs_to_inject, error_response, _background_tasks
171
+
172
+ # Process explicit Path parameters
173
+ elif isinstance(param.default, Path):
174
+ error_response = self._process_path_param(
175
+ param, path_params, kwargs_to_inject
176
+ )
177
+ if error_response:
178
+ return kwargs_to_inject, error_response, _background_tasks
179
+
180
+ # Process implicit Path parameters
181
+ elif (
182
+ param.default is inspect.Parameter.empty
183
+ and param.name in path_params
184
+ and not is_explicit_dependency
185
+ and not is_implicit_dependency
186
+ ):
187
+ error_response = self._process_implicit_path_param(
188
+ param, path_params, kwargs_to_inject
189
+ )
190
+ if error_response:
191
+ return kwargs_to_inject, error_response, _background_tasks
192
+
193
+ return kwargs_to_inject, None, _background_tasks
194
+
195
+ async def _process_body_param(
196
+ self,
197
+ param,
198
+ request: Request,
199
+ kwargs_to_inject: Dict,
200
+ ) -> Optional[JSONResponse]:
201
+ """Process Body parameter."""
202
+ model_class = param.annotation
203
+ if not issubclass(model_class, Struct):
204
+ raise TypeError(
205
+ "Body type must be an instance of Tachyon_api.models.Struct"
206
+ )
207
+
208
+ decoder = msgspec.json.Decoder(model_class)
209
+ try:
210
+ raw_body = await request.body()
211
+ validated_data = decoder.decode(raw_body)
212
+ kwargs_to_inject[param.name] = validated_data
213
+ return None
214
+ except msgspec.ValidationError as e:
215
+ # Attempt to build field errors map
216
+ field_errors = None
217
+ try:
218
+ path = getattr(e, "path", None)
219
+ if path:
220
+ field_name = None
221
+ for p in reversed(path):
222
+ if isinstance(p, str):
223
+ field_name = p
224
+ break
225
+ if field_name:
226
+ field_errors = {field_name: [str(e)]}
227
+ except Exception:
228
+ field_errors = None
229
+ return validation_error_response(str(e), errors=field_errors)
230
+
231
+ def _process_query_param(
232
+ self,
233
+ param,
234
+ query_params,
235
+ kwargs_to_inject: Dict,
236
+ ) -> Optional[JSONResponse]:
237
+ """Process Query parameter."""
238
+ query_info = param.default
239
+ param_name = param.name
240
+ ann = param.annotation
241
+ origin = typing.get_origin(ann)
242
+ args = typing.get_args(ann)
243
+
244
+ # List[T] handling
245
+ if origin in (list, typing.List):
246
+ item_type = args[0] if args else str
247
+ values = []
248
+ # collect repeated params
249
+ if hasattr(query_params, "getlist"):
250
+ values = query_params.getlist(param_name)
251
+ # if not repeated, check for CSV in single value
252
+ if not values and param_name in query_params:
253
+ raw = query_params[param_name]
254
+ values = raw.split(",") if "," in raw else [raw]
255
+ # flatten CSV in any element
256
+ flat_values = []
257
+ for v in values:
258
+ if isinstance(v, str) and "," in v:
259
+ flat_values.extend(v.split(","))
260
+ else:
261
+ flat_values.append(v)
262
+ values = flat_values
263
+ if not values:
264
+ if query_info.default is not ...:
265
+ kwargs_to_inject[param_name] = query_info.default
266
+ return None
267
+ return validation_error_response(
268
+ f"Missing required query parameter: {param_name}"
269
+ )
270
+ # Unwrap Optional for item type
271
+ base_item_type, item_is_opt = TypeUtils.unwrap_optional(item_type)
272
+ converted_list = []
273
+ for v in values:
274
+ if item_is_opt and (v == "" or v.lower() == "null"):
275
+ converted_list.append(None)
276
+ continue
277
+ converted_value = TypeConverter.convert_value(
278
+ v, base_item_type, param_name, is_path_param=False
279
+ )
280
+ if isinstance(converted_value, JSONResponse):
281
+ return converted_value
282
+ converted_list.append(converted_value)
283
+ kwargs_to_inject[param_name] = converted_list
284
+ return None
285
+
286
+ # Optional[T] handling for single value
287
+ base_type, _is_opt = TypeUtils.unwrap_optional(ann)
288
+
289
+ if param_name in query_params:
290
+ value_str = query_params[param_name]
291
+ converted_value = TypeConverter.convert_value(
292
+ value_str, base_type, param_name, is_path_param=False
293
+ )
294
+ if isinstance(converted_value, JSONResponse):
295
+ return converted_value
296
+ kwargs_to_inject[param_name] = converted_value
297
+ elif query_info.default is not ...:
298
+ kwargs_to_inject[param.name] = query_info.default
299
+ else:
300
+ return validation_error_response(
301
+ f"Missing required query parameter: {param_name}"
302
+ )
303
+ return None
304
+
305
+ def _process_header_param(
306
+ self,
307
+ param,
308
+ request: Request,
309
+ kwargs_to_inject: Dict,
310
+ ) -> Optional[JSONResponse]:
311
+ """Process Header parameter."""
312
+ header_info = param.default
313
+ # Use alias if provided, otherwise convert param name
314
+ if header_info.alias:
315
+ header_name = header_info.alias.lower()
316
+ else:
317
+ header_name = param.name.replace("_", "-").lower()
318
+
319
+ # Get header value (case-insensitive)
320
+ header_value = request.headers.get(header_name)
321
+
322
+ if header_value is not None:
323
+ kwargs_to_inject[param.name] = header_value
324
+ elif header_info.default is not ...:
325
+ kwargs_to_inject[param.name] = header_info.default
326
+ else:
327
+ return validation_error_response(
328
+ f"Missing required header: {header_name}"
329
+ )
330
+ return None
331
+
332
+ def _process_cookie_param(
333
+ self,
334
+ param,
335
+ request: Request,
336
+ kwargs_to_inject: Dict,
337
+ ) -> Optional[JSONResponse]:
338
+ """Process Cookie parameter."""
339
+ cookie_info = param.default
340
+ cookie_name = cookie_info.alias or param.name
341
+
342
+ cookie_value = request.cookies.get(cookie_name)
343
+
344
+ if cookie_value is not None:
345
+ kwargs_to_inject[param.name] = cookie_value
346
+ elif cookie_info.default is not ...:
347
+ kwargs_to_inject[param.name] = cookie_info.default
348
+ else:
349
+ return validation_error_response(
350
+ f"Missing required cookie: {cookie_name}"
351
+ )
352
+ return None
353
+
354
+ def _process_form_param(
355
+ self,
356
+ param,
357
+ form_data,
358
+ kwargs_to_inject: Dict,
359
+ ) -> Optional[JSONResponse]:
360
+ """Process Form parameter."""
361
+ form_info = param.default
362
+ field_name = form_info.alias or param.name
363
+
364
+ if field_name in form_data:
365
+ kwargs_to_inject[param.name] = form_data[field_name]
366
+ elif form_info.default is not ...:
367
+ kwargs_to_inject[param.name] = form_info.default
368
+ else:
369
+ return validation_error_response(
370
+ f"Missing required form field: {field_name}"
371
+ )
372
+ return None
373
+
374
+ def _process_file_param(
375
+ self,
376
+ param,
377
+ form_data,
378
+ kwargs_to_inject: Dict,
379
+ ) -> Optional[JSONResponse]:
380
+ """Process File parameter."""
381
+ file_info = param.default
382
+ field_name = param.name
383
+
384
+ if field_name in form_data:
385
+ uploaded_file = form_data[field_name]
386
+ # Check if it's actually a file (UploadFile)
387
+ if hasattr(uploaded_file, "filename"):
388
+ kwargs_to_inject[param.name] = uploaded_file
389
+ elif file_info.default is not ...:
390
+ kwargs_to_inject[param.name] = file_info.default
391
+ else:
392
+ return validation_error_response(
393
+ f"Invalid file upload for: {field_name}"
394
+ )
395
+ elif file_info.default is not ...:
396
+ kwargs_to_inject[param.name] = file_info.default
397
+ else:
398
+ return validation_error_response(
399
+ f"Missing required file: {field_name}"
400
+ )
401
+ return None
402
+
403
+ def _process_path_param(
404
+ self,
405
+ param,
406
+ path_params,
407
+ kwargs_to_inject: Dict,
408
+ ) -> Optional[JSONResponse]:
409
+ """Process explicit Path parameter."""
410
+ param_name = param.name
411
+ if param_name not in path_params:
412
+ return JSONResponse({"detail": "Not Found"}, status_code=404)
413
+
414
+ value_str = path_params[param_name]
415
+ ann = param.annotation
416
+ origin = typing.get_origin(ann)
417
+ args = typing.get_args(ann)
418
+
419
+ # List[T] handling
420
+ if origin in (list, typing.List):
421
+ item_type = args[0] if args else str
422
+ parts = value_str.split(",") if value_str else []
423
+ base_item_type, item_is_opt = TypeUtils.unwrap_optional(item_type)
424
+ converted_list = []
425
+ for v in parts:
426
+ if item_is_opt and (v == "" or v.lower() == "null"):
427
+ converted_list.append(None)
428
+ continue
429
+ converted_value = TypeConverter.convert_value(
430
+ v, base_item_type, param_name, is_path_param=True
431
+ )
432
+ if isinstance(converted_value, JSONResponse):
433
+ return converted_value
434
+ converted_list.append(converted_value)
435
+ kwargs_to_inject[param_name] = converted_list
436
+ else:
437
+ converted_value = TypeConverter.convert_value(
438
+ value_str, ann, param_name, is_path_param=True
439
+ )
440
+ if isinstance(converted_value, JSONResponse):
441
+ return converted_value
442
+ kwargs_to_inject[param_name] = converted_value
443
+
444
+ return None
445
+
446
+ def _process_implicit_path_param(
447
+ self,
448
+ param,
449
+ path_params,
450
+ kwargs_to_inject: Dict,
451
+ ) -> Optional[JSONResponse]:
452
+ """Process implicit Path parameter (URL path variables without Path())."""
453
+ param_name = param.name
454
+ value_str = path_params[param_name]
455
+ ann = param.annotation
456
+ origin = typing.get_origin(ann)
457
+ args = typing.get_args(ann)
458
+
459
+ # List[T] handling
460
+ if origin in (list, typing.List):
461
+ item_type = args[0] if args else str
462
+ parts = value_str.split(",") if value_str else []
463
+ base_item_type, item_is_opt = TypeUtils.unwrap_optional(item_type)
464
+ converted_list = []
465
+ for v in parts:
466
+ if item_is_opt and (v == "" or v.lower() == "null"):
467
+ converted_list.append(None)
468
+ continue
469
+ converted_value = TypeConverter.convert_value(
470
+ v, base_item_type, param_name, is_path_param=True
471
+ )
472
+ if isinstance(converted_value, JSONResponse):
473
+ return converted_value
474
+ converted_list.append(converted_value)
475
+ kwargs_to_inject[param_name] = converted_list
476
+ else:
477
+ converted_value = TypeConverter.convert_value(
478
+ value_str, ann, param_name, is_path_param=True
479
+ )
480
+ if isinstance(converted_value, JSONResponse):
481
+ return converted_value
482
+ kwargs_to_inject[param_name] = converted_value
483
+
484
+ return None