tachyon-api 0.5.10__py3-none-any.whl → 0.6.0__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,315 @@
1
+ """
2
+ OpenAPI Documentation Builder for Tachyon API
3
+
4
+ This module handles the generation and management of OpenAPI documentation
5
+ for the Tachyon framework, including schema generation, parameter processing,
6
+ and documentation endpoint setup.
7
+ """
8
+
9
+ import inspect
10
+ from typing import Any, Dict, Type, Union, Callable
11
+ from starlette.responses import HTMLResponse
12
+
13
+ from ..schemas.models import Struct
14
+ from ..schemas.parameters import Body, Query, Path
15
+ from .schema import (
16
+ OpenAPIGenerator,
17
+ OpenAPIConfig,
18
+ build_components_for_struct,
19
+ )
20
+
21
+
22
+ class OpenAPIBuilder:
23
+ """
24
+ Handles OpenAPI documentation generation and management.
25
+
26
+ This class centralizes all OpenAPI-related functionality, including
27
+ schema generation, parameter processing, and documentation endpoint setup.
28
+ """
29
+
30
+ def __init__(
31
+ self, openapi_config: OpenAPIConfig, openapi_generator: OpenAPIGenerator
32
+ ):
33
+ """
34
+ Initialize the OpenAPI builder.
35
+
36
+ Args:
37
+ openapi_config: OpenAPI configuration
38
+ openapi_generator: OpenAPI generator instance
39
+ """
40
+ self.openapi_config = openapi_config
41
+ self.openapi_generator = openapi_generator
42
+
43
+ def generate_openapi_for_route(
44
+ self, path: str, method: str, endpoint_func: Callable, **kwargs
45
+ ):
46
+ """
47
+ Generate OpenAPI documentation for a specific route.
48
+
49
+ This method analyzes the endpoint function signature and generates appropriate
50
+ OpenAPI schema entries for parameters, request body, and responses.
51
+
52
+ Args:
53
+ path: URL path pattern
54
+ method: HTTP method
55
+ endpoint_func: The endpoint function
56
+ **kwargs: Additional route metadata (summary, description, tags, etc.)
57
+ """
58
+ sig = inspect.signature(endpoint_func)
59
+
60
+ # Ensure common error schemas exist in components
61
+ self.openapi_generator.add_schema(
62
+ "ValidationErrorResponse",
63
+ {
64
+ "type": "object",
65
+ "properties": {
66
+ "success": {"type": "boolean"},
67
+ "error": {"type": "string"},
68
+ "code": {"type": "string"},
69
+ "errors": {
70
+ "type": "object",
71
+ "additionalProperties": {
72
+ "type": "array",
73
+ "items": {"type": "string"},
74
+ },
75
+ },
76
+ },
77
+ "required": ["success", "error", "code"],
78
+ },
79
+ )
80
+ self.openapi_generator.add_schema(
81
+ "ResponseValidationError",
82
+ {
83
+ "type": "object",
84
+ "properties": {
85
+ "success": {"type": "boolean"},
86
+ "error": {"type": "string"},
87
+ "detail": {"type": "string"},
88
+ "code": {"type": "string"},
89
+ },
90
+ "required": ["success", "error", "code"],
91
+ },
92
+ )
93
+
94
+ # Build the OpenAPI operation object
95
+ operation = {
96
+ "summary": kwargs.get(
97
+ "summary", self._generate_summary_from_function(endpoint_func)
98
+ ),
99
+ "description": kwargs.get("description", endpoint_func.__doc__ or ""),
100
+ "responses": {
101
+ "200": {
102
+ "description": "Successful Response",
103
+ "content": {"application/json": {"schema": {"type": "object"}}},
104
+ },
105
+ "422": {
106
+ "description": "Validation Error",
107
+ "content": {
108
+ "application/json": {
109
+ "schema": {
110
+ "$ref": "#/components/schemas/ValidationErrorResponse"
111
+ }
112
+ }
113
+ },
114
+ },
115
+ "500": {
116
+ "description": "Response Validation Error",
117
+ "content": {
118
+ "application/json": {
119
+ "schema": {
120
+ "$ref": "#/components/schemas/ResponseValidationError"
121
+ }
122
+ }
123
+ },
124
+ },
125
+ },
126
+ }
127
+
128
+ # If a response_model is provided and is a Struct, use it for the 200 response schema
129
+ response_model = kwargs.get("response_model")
130
+ if response_model is not None and issubclass(response_model, Struct):
131
+ comps = build_components_for_struct(response_model)
132
+ for name, schema in comps.items():
133
+ self.openapi_generator.add_schema(name, schema)
134
+ operation["responses"]["200"]["content"]["application/json"]["schema"] = {
135
+ "$ref": f"#/components/schemas/{response_model.__name__}"
136
+ }
137
+
138
+ # Add tags if provided
139
+ if "tags" in kwargs:
140
+ operation["tags"] = kwargs["tags"]
141
+
142
+ # Process parameters from function signature
143
+ parameters = []
144
+ request_body_schema = None
145
+
146
+ for param in sig.parameters.values():
147
+ # Skip dependency parameters
148
+ if isinstance(
149
+ param.default, (Body.__class__, Query.__class__, Path.__class__)
150
+ ) or (
151
+ param.default is inspect.Parameter.empty
152
+ and param.annotation.__name__ in ["Depends"]
153
+ ):
154
+ continue
155
+
156
+ # Process query parameters
157
+ elif isinstance(param.default, Query):
158
+ parameters.append(
159
+ {
160
+ "name": param.name,
161
+ "in": "query",
162
+ "required": param.default.default is ...,
163
+ "schema": self._build_param_openapi_schema(param.annotation),
164
+ "description": getattr(param.default, "description", ""),
165
+ }
166
+ )
167
+
168
+ # Process path parameters
169
+ elif isinstance(param.default, Path) or self._is_path_parameter(
170
+ param.name, path
171
+ ):
172
+ parameters.append(
173
+ {
174
+ "name": param.name,
175
+ "in": "path",
176
+ "required": True,
177
+ "schema": self._build_param_openapi_schema(param.annotation),
178
+ "description": getattr(param.default, "description", "")
179
+ if isinstance(param.default, Path)
180
+ else "",
181
+ }
182
+ )
183
+
184
+ # Process body parameters
185
+ elif isinstance(param.default, Body):
186
+ model_class = param.annotation
187
+ if issubclass(model_class, Struct):
188
+ comps = build_components_for_struct(model_class)
189
+ for name, schema in comps.items():
190
+ self.openapi_generator.add_schema(name, schema)
191
+
192
+ request_body_schema = {
193
+ "content": {
194
+ "application/json": {
195
+ "schema": {
196
+ "$ref": f"#/components/schemas/{model_class.__name__}"
197
+ }
198
+ }
199
+ },
200
+ "required": True,
201
+ }
202
+
203
+ # Add parameters to operation if any exist
204
+ if parameters:
205
+ operation["parameters"] = parameters
206
+
207
+ if request_body_schema:
208
+ operation["requestBody"] = request_body_schema
209
+
210
+ self.openapi_generator.add_path(path, method, operation)
211
+
212
+ @staticmethod
213
+ def _generate_summary_from_function(func: Callable) -> str:
214
+ """Generate a human-readable summary from function name."""
215
+ return func.__name__.replace("_", " ").title()
216
+
217
+ @staticmethod
218
+ def _is_path_parameter(param_name: str, path: str) -> bool:
219
+ """Check if a parameter name corresponds to a path parameter in the URL."""
220
+ return f"{{{param_name}}}" in path
221
+
222
+ @staticmethod
223
+ def _get_openapi_type(python_type: Type) -> str:
224
+ """Convert Python type to OpenAPI schema type."""
225
+ type_map: Dict[Type, str] = {
226
+ int: "integer",
227
+ str: "string",
228
+ bool: "boolean",
229
+ float: "number",
230
+ }
231
+ return type_map.get(python_type, "string")
232
+
233
+ @staticmethod
234
+ def _build_param_openapi_schema(python_type: Type) -> Dict[str, Any]:
235
+ """Build OpenAPI schema for parameter types, supporting Optional[T] and List[T]."""
236
+ import typing
237
+
238
+ origin = typing.get_origin(python_type)
239
+ args = typing.get_args(python_type)
240
+ nullable = False
241
+ # Optional[T]
242
+ if origin is Union and args:
243
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
244
+ if len(non_none) == 1:
245
+ python_type = non_none[0]
246
+ nullable = True
247
+ # List[T] (and List[Optional[T]])
248
+ origin = typing.get_origin(python_type)
249
+ args = typing.get_args(python_type)
250
+ if origin in (list, typing.List):
251
+ item_type = args[0] if args else str
252
+ # Unwrap Optional in items for List[Optional[T]]
253
+ item_origin = typing.get_origin(item_type)
254
+ item_args = typing.get_args(item_type)
255
+ item_nullable = False
256
+ if item_origin is Union and item_args:
257
+ item_non_none = [a for a in item_args if a is not type(None)] # noqa: E721
258
+ if len(item_non_none) == 1:
259
+ item_type = item_non_none[0]
260
+ item_nullable = True
261
+ schema = {
262
+ "type": "array",
263
+ "items": {"type": OpenAPIBuilder._get_openapi_type(item_type)},
264
+ }
265
+ if item_nullable:
266
+ schema["items"]["nullable"] = True
267
+ else:
268
+ schema = {"type": OpenAPIBuilder._get_openapi_type(python_type)}
269
+ if nullable:
270
+ schema["nullable"] = True
271
+ return schema
272
+
273
+ def setup_docs(self, app):
274
+ """
275
+ Setup OpenAPI documentation endpoints.
276
+
277
+ This method registers the routes for serving OpenAPI JSON schema,
278
+ Swagger UI, and ReDoc documentation interfaces.
279
+
280
+ Args:
281
+ app: The Tachyon application instance
282
+ """
283
+
284
+ # OpenAPI JSON schema endpoint
285
+ @app.get(self.openapi_config.openapi_url, include_in_schema=False)
286
+ def get_openapi_schema():
287
+ """Serve the OpenAPI JSON schema."""
288
+ return self.openapi_generator.get_openapi_schema()
289
+
290
+ # Scalar API Reference documentation endpoint (default for /docs)
291
+ @app.get(self.openapi_config.docs_url, include_in_schema=False)
292
+ def get_scalar_docs():
293
+ """Serve the Scalar API Reference documentation interface."""
294
+ html = self.openapi_generator.get_scalar_html(
295
+ self.openapi_config.openapi_url, self.openapi_config.info.title
296
+ )
297
+ return HTMLResponse(html)
298
+
299
+ # Swagger UI documentation endpoint (legacy support)
300
+ @app.get("/swagger", include_in_schema=False)
301
+ def get_swagger_ui():
302
+ """Serve the Swagger UI documentation interface."""
303
+ html = self.openapi_generator.get_swagger_ui_html(
304
+ self.openapi_config.openapi_url, self.openapi_config.info.title
305
+ )
306
+ return HTMLResponse(html)
307
+
308
+ # ReDoc documentation endpoint
309
+ @app.get(self.openapi_config.redoc_url, include_in_schema=False)
310
+ def get_redoc():
311
+ """Serve the ReDoc documentation interface."""
312
+ html = self.openapi_generator.get_redoc_html(
313
+ self.openapi_config.openapi_url, self.openapi_config.info.title
314
+ )
315
+ return HTMLResponse(html)
@@ -5,7 +5,7 @@ import uuid
5
5
  import typing
6
6
  import json
7
7
 
8
- from .models import Struct
8
+ from ..schemas.models import Struct
9
9
 
10
10
  # Type mapping from Python types to OpenAPI schema types
11
11
  TYPE_MAP = {int: "integer", str: "string", bool: "boolean", float: "number"}
@@ -0,0 +1,8 @@
1
+ """
2
+ Tachyon API request/response processing.
3
+ """
4
+
5
+ from .parameters import ParameterProcessor, _NotProcessed
6
+ from .responses import ResponseProcessor
7
+
8
+ __all__ = ["ParameterProcessor", "_NotProcessed", "ResponseProcessor"]
@@ -0,0 +1,308 @@
1
+ """
2
+ Parameter Processing System for Tachyon API
3
+
4
+ This module handles the processing and validation of different parameter types
5
+ (Body, Query, Path) for endpoint functions.
6
+ """
7
+
8
+ import inspect
9
+ import typing
10
+ from typing import Any, Union
11
+ from starlette.responses import JSONResponse
12
+
13
+ import msgspec
14
+
15
+ from ..schemas.models import Struct
16
+ from ..schemas.parameters import Body, Query, Path
17
+ from ..schemas.responses import validation_error_response
18
+ from ..utils.type_converter import TypeConverter
19
+ from ..utils.type_utils import TypeUtils
20
+
21
+
22
+ class _NotProcessed:
23
+ """Sentinel value to indicate that a parameter was not processed by ParameterProcessor."""
24
+
25
+ pass
26
+
27
+
28
+ class ParameterProcessor:
29
+ """
30
+ Handles processing and validation of endpoint parameters.
31
+
32
+ This class processes Body, Query, and Path parameters, performing type conversion,
33
+ validation, and error handling for each parameter type.
34
+ """
35
+
36
+ @staticmethod
37
+ async def process_body_parameter(
38
+ param, model_class, _raw_body, request
39
+ ) -> Union[Any, JSONResponse]:
40
+ """
41
+ Process a Body parameter from the request.
42
+
43
+ Args:
44
+ param: The parameter object from function signature
45
+ model_class: The expected model class (must be a Struct)
46
+ _raw_body: Cached raw body data
47
+ request: The Starlette request object
48
+
49
+ Returns:
50
+ Validated body data or JSONResponse with validation error
51
+ """
52
+ if not issubclass(model_class, Struct):
53
+ raise TypeError(
54
+ "Body type must be an instance of Tachyon_api.models.Struct"
55
+ )
56
+
57
+ decoder = msgspec.json.Decoder(model_class)
58
+ try:
59
+ if _raw_body is None:
60
+ _raw_body = await request.body()
61
+ validated_data = decoder.decode(_raw_body)
62
+ return validated_data
63
+ except msgspec.ValidationError as e:
64
+ # Attempt to build field errors map using e.path
65
+ field_errors = None
66
+ try:
67
+ path = getattr(e, "path", None)
68
+ if path:
69
+ # Choose last string-ish path element as field name
70
+ field_name = None
71
+ for p in reversed(path):
72
+ if isinstance(p, str):
73
+ field_name = p
74
+ break
75
+ if field_name:
76
+ field_errors = {field_name: [str(e)]}
77
+ except Exception:
78
+ field_errors = None
79
+ return validation_error_response(str(e), errors=field_errors)
80
+
81
+ @staticmethod
82
+ def process_query_parameter(param, query_params) -> Union[Any, JSONResponse, None]:
83
+ """
84
+ Process a Query parameter from the request.
85
+
86
+ Args:
87
+ param: The parameter object from function signature
88
+ query_params: The query parameters from the request
89
+
90
+ Returns:
91
+ Converted parameter value, None (for missing optional), or JSONResponse with error
92
+ """
93
+ query_info = param.default
94
+ param_name = param.name
95
+
96
+ # Determine typing for advanced cases
97
+ ann = param.annotation
98
+ origin = typing.get_origin(ann)
99
+ args = typing.get_args(ann)
100
+
101
+ # List[T] handling
102
+ if origin in (list, typing.List):
103
+ item_type = args[0] if args else str
104
+ values = []
105
+ # collect repeated params
106
+ if hasattr(query_params, "getlist"):
107
+ values = query_params.getlist(param_name)
108
+ # if not repeated, check for CSV in single value
109
+ if not values and param_name in query_params:
110
+ raw = query_params[param_name]
111
+ values = raw.split(",") if "," in raw else [raw]
112
+ # flatten CSV in any element
113
+ flat_values = []
114
+ for v in values:
115
+ if isinstance(v, str) and "," in v:
116
+ flat_values.extend(v.split(","))
117
+ else:
118
+ flat_values.append(v)
119
+ values = flat_values
120
+ if not values:
121
+ if query_info.default is not ...:
122
+ return query_info.default
123
+ return validation_error_response(
124
+ f"Missing required query parameter: {param_name}"
125
+ )
126
+ # Unwrap Optional for item type
127
+ base_item_type, item_is_opt = TypeUtils.unwrap_optional(item_type)
128
+ converted_list = []
129
+ for v in values:
130
+ if item_is_opt and (v == "" or v.lower() == "null"):
131
+ converted_list.append(None)
132
+ continue
133
+ converted_value = TypeConverter.convert_value(
134
+ v, base_item_type, param_name, is_path_param=False
135
+ )
136
+ if isinstance(converted_value, JSONResponse):
137
+ return converted_value
138
+ converted_list.append(converted_value)
139
+ return converted_list
140
+
141
+ # Optional[T] handling for single value
142
+ base_type, _is_opt = TypeUtils.unwrap_optional(ann)
143
+
144
+ if param_name in query_params:
145
+ value_str = query_params[param_name]
146
+ converted_value = TypeConverter.convert_value(
147
+ value_str, base_type, param_name, is_path_param=False
148
+ )
149
+ if isinstance(converted_value, JSONResponse):
150
+ return converted_value
151
+ return converted_value
152
+
153
+ elif query_info.default is not ...:
154
+ return query_info.default
155
+ else:
156
+ return validation_error_response(
157
+ f"Missing required query parameter: {param_name}"
158
+ )
159
+
160
+ @staticmethod
161
+ def process_explicit_path_parameter(param, path_params) -> Union[Any, JSONResponse]:
162
+ """
163
+ Process an explicit Path parameter (with Path() annotation).
164
+
165
+ Args:
166
+ param: The parameter object from function signature
167
+ path_params: The path parameters from the request
168
+
169
+ Returns:
170
+ Converted parameter value or JSONResponse with error
171
+ """
172
+ param_name = param.name
173
+ if param_name in path_params:
174
+ value_str = path_params[param_name]
175
+ # Support List[T] in path params via CSV
176
+ ann = param.annotation
177
+ origin = typing.get_origin(ann)
178
+ args = typing.get_args(ann)
179
+ if origin in (list, typing.List):
180
+ item_type = args[0] if args else str
181
+ parts = value_str.split(",") if value_str else []
182
+ # Unwrap Optional for item type
183
+ base_item_type, item_is_opt = TypeUtils.unwrap_optional(item_type)
184
+ converted_list = []
185
+ for v in parts:
186
+ if item_is_opt and (v == "" or v.lower() == "null"):
187
+ converted_list.append(None)
188
+ continue
189
+ converted_value = TypeConverter.convert_value(
190
+ v, base_item_type, param_name, is_path_param=True
191
+ )
192
+ if isinstance(converted_value, JSONResponse):
193
+ return converted_value
194
+ converted_list.append(converted_value)
195
+ return converted_list
196
+ else:
197
+ converted_value = TypeConverter.convert_value(
198
+ value_str, ann, param_name, is_path_param=True
199
+ )
200
+ # Return 404 if conversion failed
201
+ if isinstance(converted_value, JSONResponse):
202
+ return converted_value
203
+ return converted_value
204
+ else:
205
+ return JSONResponse({"detail": "Not Found"}, status_code=404)
206
+
207
+ @staticmethod
208
+ def process_implicit_path_parameter(
209
+ param, path_params
210
+ ) -> Union[Any, JSONResponse, None]:
211
+ """
212
+ Process an implicit Path parameter (URL path variable without Path()).
213
+
214
+ Args:
215
+ param: The parameter object from function signature
216
+ path_params: The path parameters from the request
217
+
218
+ Returns:
219
+ Converted parameter value, None (not processed), or JSONResponse with error
220
+ """
221
+ param_name = param.name
222
+ value_str = path_params[param_name]
223
+ # Support List[T] via CSV
224
+ ann = param.annotation
225
+ origin = typing.get_origin(ann)
226
+ args = typing.get_args(ann)
227
+ if origin in (list, typing.List):
228
+ item_type = args[0] if args else str
229
+ parts = value_str.split(",") if value_str else []
230
+ # Unwrap Optional for item type
231
+ base_item_type, item_is_opt = TypeUtils.unwrap_optional(item_type)
232
+ converted_list = []
233
+ for v in parts:
234
+ if item_is_opt and (v == "" or v.lower() == "null"):
235
+ converted_list.append(None)
236
+ continue
237
+ converted_value = TypeConverter.convert_value(
238
+ v, base_item_type, param_name, is_path_param=True
239
+ )
240
+ if isinstance(converted_value, JSONResponse):
241
+ return converted_value
242
+ converted_list.append(converted_value)
243
+ return converted_list
244
+ else:
245
+ converted_value = TypeConverter.convert_value(
246
+ value_str, ann, param_name, is_path_param=True
247
+ )
248
+ # Return 404 if conversion failed
249
+ if isinstance(converted_value, JSONResponse):
250
+ return converted_value
251
+ return converted_value
252
+
253
+ @classmethod
254
+ async def process_parameter(
255
+ cls,
256
+ param,
257
+ request,
258
+ path_params,
259
+ query_params,
260
+ _raw_body,
261
+ is_explicit_dependency,
262
+ is_implicit_dependency,
263
+ ) -> Union[Any, JSONResponse, None]:
264
+ """
265
+ Process a single parameter based on its type and annotations.
266
+
267
+ Args:
268
+ param: The parameter object from function signature
269
+ request: The Starlette request object
270
+ path_params: Path parameters from the request
271
+ query_params: Query parameters from the request
272
+ _raw_body: Cached raw body data
273
+ is_explicit_dependency: Whether this is an explicit dependency
274
+ is_implicit_dependency: Whether this is an implicit dependency
275
+
276
+ Returns:
277
+ Parameter value, JSONResponse (error), or None (not processed)
278
+ """
279
+ # Process Body parameters (JSON request body)
280
+ if isinstance(param.default, Body):
281
+ model_class = param.annotation
282
+ result = await cls.process_body_parameter(
283
+ param, model_class, _raw_body, request
284
+ )
285
+ return result
286
+
287
+ # Process Query parameters (URL query string)
288
+ elif isinstance(param.default, Query):
289
+ result = cls.process_query_parameter(param, query_params)
290
+ return result
291
+
292
+ # Process explicit Path parameters (with Path() annotation)
293
+ elif isinstance(param.default, Path):
294
+ result = cls.process_explicit_path_parameter(param, path_params)
295
+ return result
296
+
297
+ # Process implicit Path parameters (URL path variables without Path())
298
+ elif (
299
+ param.default is inspect.Parameter.empty
300
+ and param.name in path_params
301
+ and not is_explicit_dependency
302
+ and not is_implicit_dependency
303
+ ):
304
+ result = cls.process_implicit_path_parameter(param, path_params)
305
+ return result
306
+
307
+ # Parameter not processed by this processor
308
+ return _NotProcessed()