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/__init__.py +20 -0
- tachyon_api/app.py +385 -172
- tachyon_api/cache.py +270 -0
- tachyon_api/middlewares/__init__.py +0 -1
- tachyon_api/middlewares/cors.py +29 -12
- tachyon_api/middlewares/logger.py +6 -2
- tachyon_api/openapi.py +110 -28
- tachyon_api/responses.py +46 -3
- tachyon_api/utils/__init__.py +14 -0
- tachyon_api/utils/type_converter.py +113 -0
- tachyon_api/utils/type_utils.py +111 -0
- tachyon_api-0.5.9.dist-info/METADATA +146 -0
- tachyon_api-0.5.9.dist-info/RECORD +20 -0
- tachyon_api-0.5.5.dist-info/METADATA +0 -239
- tachyon_api-0.5.5.dist-info/RECORD +0 -16
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.9.dist-info}/LICENSE +0 -0
- {tachyon_api-0.5.5.dist-info → tachyon_api-0.5.9.dist-info}/WHEEL +0 -0
tachyon_api/cache.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache utilities: decorator with TTL and pluggable backends.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- BaseCacheBackend protocol and an in-memory implementation
|
|
6
|
+
- CacheConfig dataclass and helpers to set global/app config
|
|
7
|
+
- cache decorator usable on any sync/async function (including routes)
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
- Key builder defaults to a stable representation of function + args/kwargs
|
|
11
|
+
- Unless predicate allows opt-out per-call
|
|
12
|
+
- TTL can be provided per-decorator or falls back to global default
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
import asyncio
|
|
19
|
+
import hashlib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from functools import wraps
|
|
22
|
+
from typing import Any, Callable, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseCacheBackend:
|
|
26
|
+
"""Minimal cache backend interface."""
|
|
27
|
+
|
|
28
|
+
def get(self, key: str) -> Any: # pragma: no cover - interface
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
def set(
|
|
32
|
+
self, key: str, value: Any, ttl: Optional[float] = None
|
|
33
|
+
) -> None: # pragma: no cover - interface
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def delete(self, key: str) -> None: # pragma: no cover - interface
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def clear(self) -> None: # pragma: no cover - interface
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InMemoryCacheBackend(BaseCacheBackend):
|
|
44
|
+
"""Simple in-memory cache with TTL using wall-clock time."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._store: dict[str, Tuple[float | None, Any]] = {}
|
|
48
|
+
|
|
49
|
+
def _is_expired(self, expires_at: float | None) -> bool:
|
|
50
|
+
return expires_at is not None and time.time() >= expires_at
|
|
51
|
+
|
|
52
|
+
def get(self, key: str) -> Any:
|
|
53
|
+
item = self._store.get(key)
|
|
54
|
+
if not item:
|
|
55
|
+
return None
|
|
56
|
+
expires_at, value = item
|
|
57
|
+
if self._is_expired(expires_at):
|
|
58
|
+
# Lazy expiration
|
|
59
|
+
try:
|
|
60
|
+
del self._store[key]
|
|
61
|
+
except KeyError:
|
|
62
|
+
pass
|
|
63
|
+
return None
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
|
67
|
+
expires_at = time.time() + ttl if ttl and ttl > 0 else None
|
|
68
|
+
self._store[key] = (expires_at, value)
|
|
69
|
+
|
|
70
|
+
def delete(self, key: str) -> None:
|
|
71
|
+
self._store.pop(key, None)
|
|
72
|
+
|
|
73
|
+
def clear(self) -> None:
|
|
74
|
+
self._store.clear()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class CacheConfig:
|
|
79
|
+
backend: BaseCacheBackend
|
|
80
|
+
default_ttl: float = 60.0
|
|
81
|
+
key_builder: Optional[Callable[[Callable, tuple, dict], str]] = None
|
|
82
|
+
enabled: bool = True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_cache_config: Optional[CacheConfig] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _default_key_builder(func: Callable, args: tuple, kwargs: dict) -> str:
|
|
89
|
+
parts = [getattr(func, "__module__", ""), getattr(func, "__qualname__", ""), "|"]
|
|
90
|
+
# Stable kwargs order
|
|
91
|
+
items = [repr(a) for a in args] + [
|
|
92
|
+
f"{k}={repr(v)}" for k, v in sorted(kwargs.items())
|
|
93
|
+
]
|
|
94
|
+
raw_key = ":".join(parts + items)
|
|
95
|
+
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_cache_config(
|
|
99
|
+
backend: Optional[BaseCacheBackend] = None,
|
|
100
|
+
default_ttl: float = 60.0,
|
|
101
|
+
key_builder: Optional[Callable[[Callable, tuple, dict], str]] = None,
|
|
102
|
+
enabled: bool = True,
|
|
103
|
+
) -> CacheConfig:
|
|
104
|
+
"""Create and set the global cache configuration.
|
|
105
|
+
|
|
106
|
+
Returns the created CacheConfig and sets it as the active global config.
|
|
107
|
+
"""
|
|
108
|
+
global _cache_config
|
|
109
|
+
cfg = CacheConfig(
|
|
110
|
+
backend=backend or InMemoryCacheBackend(),
|
|
111
|
+
default_ttl=default_ttl,
|
|
112
|
+
key_builder=key_builder,
|
|
113
|
+
enabled=enabled,
|
|
114
|
+
)
|
|
115
|
+
_cache_config = cfg
|
|
116
|
+
return cfg
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_cache_config(config: CacheConfig) -> None:
|
|
120
|
+
"""Set the global cache configuration object."""
|
|
121
|
+
global _cache_config
|
|
122
|
+
_cache_config = config
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_cache_config() -> CacheConfig:
|
|
126
|
+
"""Get the current cache configuration, creating a default one if missing."""
|
|
127
|
+
global _cache_config
|
|
128
|
+
if _cache_config is None:
|
|
129
|
+
_cache_config = create_cache_config()
|
|
130
|
+
return _cache_config
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cache(
|
|
134
|
+
TTL: Optional[float] = None,
|
|
135
|
+
*,
|
|
136
|
+
key_builder: Optional[Callable[[Callable, tuple, dict], str]] = None,
|
|
137
|
+
unless: Optional[Callable[[tuple, dict], bool]] = None,
|
|
138
|
+
backend: Optional[BaseCacheBackend] = None,
|
|
139
|
+
):
|
|
140
|
+
"""Cache decorator with TTL and pluggable backend.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
TTL: Time-to-live in seconds for this decorator instance. Falls back to config.default_ttl.
|
|
144
|
+
key_builder: Optional custom function to build cache keys.
|
|
145
|
+
unless: Predicate receiving (args, kwargs). If returns True, skip cache for that call.
|
|
146
|
+
backend: Optional backend override for this decorator.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def decorator(func: Callable):
|
|
150
|
+
cfg = get_cache_config()
|
|
151
|
+
be = backend or cfg.backend
|
|
152
|
+
ttl_value = cfg.default_ttl if TTL is None else TTL
|
|
153
|
+
kb = (
|
|
154
|
+
key_builder
|
|
155
|
+
or cfg.key_builder
|
|
156
|
+
or (lambda f, a, kw: _default_key_builder(f, a, kw))
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if asyncio.iscoroutinefunction(func):
|
|
160
|
+
|
|
161
|
+
@wraps(func)
|
|
162
|
+
async def async_wrapper(*args, **kwargs):
|
|
163
|
+
if not cfg.enabled or (unless and unless(args, kwargs)):
|
|
164
|
+
return await func(*args, **kwargs)
|
|
165
|
+
key = kb(func, args, kwargs)
|
|
166
|
+
cached = be.get(key)
|
|
167
|
+
if cached is not None:
|
|
168
|
+
return cached
|
|
169
|
+
result = await func(*args, **kwargs)
|
|
170
|
+
try:
|
|
171
|
+
be.set(key, result, ttl_value)
|
|
172
|
+
except Exception:
|
|
173
|
+
# Backend errors should not break the app
|
|
174
|
+
pass
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
return async_wrapper
|
|
178
|
+
else:
|
|
179
|
+
|
|
180
|
+
@wraps(func)
|
|
181
|
+
def wrapper(*args, **kwargs):
|
|
182
|
+
if not cfg.enabled or (unless and unless(args, kwargs)):
|
|
183
|
+
return func(*args, **kwargs)
|
|
184
|
+
key = kb(func, args, kwargs)
|
|
185
|
+
cached = be.get(key)
|
|
186
|
+
if cached is not None:
|
|
187
|
+
return cached
|
|
188
|
+
result = func(*args, **kwargs)
|
|
189
|
+
try:
|
|
190
|
+
be.set(key, result, ttl_value)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
return wrapper
|
|
196
|
+
|
|
197
|
+
return decorator
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class RedisCacheBackend(BaseCacheBackend):
|
|
201
|
+
"""Adapter backend for Redis-like clients.
|
|
202
|
+
|
|
203
|
+
Expects a client with .get(key) -> bytes|str|None and .set(key, value, ex=ttl_seconds).
|
|
204
|
+
The stored values should be JSON-serializable or pickled externally by the user.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, client) -> None:
|
|
208
|
+
self.client = client
|
|
209
|
+
|
|
210
|
+
def get(self, key: str) -> Any:
|
|
211
|
+
value = self.client.get(key)
|
|
212
|
+
# Many clients return bytes; decode if possible
|
|
213
|
+
if isinstance(value, bytes):
|
|
214
|
+
try:
|
|
215
|
+
return value.decode("utf-8")
|
|
216
|
+
except Exception:
|
|
217
|
+
return value
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
|
221
|
+
# Use ex (expire seconds) if available
|
|
222
|
+
kwargs = {}
|
|
223
|
+
if ttl and ttl > 0:
|
|
224
|
+
kwargs["ex"] = int(ttl)
|
|
225
|
+
# Best effort set
|
|
226
|
+
self.client.set(key, value, **kwargs)
|
|
227
|
+
|
|
228
|
+
def delete(self, key: str) -> None:
|
|
229
|
+
try:
|
|
230
|
+
self.client.delete(key)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
def clear(self) -> None:
|
|
235
|
+
# Not standardized; no-op by default
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class MemcachedCacheBackend(BaseCacheBackend):
|
|
240
|
+
"""Adapter backend for Memcached-like clients.
|
|
241
|
+
|
|
242
|
+
Expects a client with .get(key) and .set(key, value, expire=ttl_seconds).
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, client) -> None:
|
|
246
|
+
self.client = client
|
|
247
|
+
|
|
248
|
+
def get(self, key: str) -> Any:
|
|
249
|
+
return self.client.get(key)
|
|
250
|
+
|
|
251
|
+
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
|
252
|
+
expire = int(ttl) if ttl and ttl > 0 else 0
|
|
253
|
+
try:
|
|
254
|
+
# pymemcache: set(key, value, expire=...)
|
|
255
|
+
self.client.set(key, value, expire=expire)
|
|
256
|
+
except TypeError:
|
|
257
|
+
# python-binary-memcached: set(key, value, time=...)
|
|
258
|
+
self.client.set(key, value, time=expire)
|
|
259
|
+
|
|
260
|
+
def delete(self, key: str) -> None:
|
|
261
|
+
try:
|
|
262
|
+
self.client.delete(key)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def clear(self) -> None:
|
|
267
|
+
try:
|
|
268
|
+
self.client.flush_all()
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
tachyon_api/middlewares/cors.py
CHANGED
|
@@ -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")
|
|
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(
|
|
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 =
|
|
102
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
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(
|
|
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}")
|
tachyon_api/openapi.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
from typing import Dict, Any, Optional, List, Type
|
|
1
|
+
from typing import Dict, Any, Optional, List, Type, get_origin, get_args
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
+
import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
import typing
|
|
6
|
+
import json
|
|
3
7
|
|
|
4
8
|
from .models import Struct
|
|
5
9
|
|
|
@@ -7,30 +11,114 @@ from .models import Struct
|
|
|
7
11
|
TYPE_MAP = {int: "integer", str: "string", bool: "boolean", float: "number"}
|
|
8
12
|
|
|
9
13
|
|
|
10
|
-
def
|
|
14
|
+
def _schema_for_python_type(
|
|
15
|
+
py_type: Type,
|
|
16
|
+
components: Dict[str, Dict[str, Any]],
|
|
17
|
+
visited: set[Type],
|
|
18
|
+
) -> Dict[str, Any]:
|
|
19
|
+
"""Return OpenAPI schema for a Python type, adding components for Structs if needed."""
|
|
20
|
+
origin = get_origin(py_type)
|
|
21
|
+
args = get_args(py_type)
|
|
22
|
+
|
|
23
|
+
# Optional[T] (Union[T, None])
|
|
24
|
+
if origin is typing.Union and args:
|
|
25
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
26
|
+
if len(non_none) == 1:
|
|
27
|
+
inner = non_none[0]
|
|
28
|
+
schema = _schema_for_python_type(inner, components, visited)
|
|
29
|
+
schema["nullable"] = True
|
|
30
|
+
return schema
|
|
31
|
+
|
|
32
|
+
# List[T]
|
|
33
|
+
if origin in (list, List):
|
|
34
|
+
item = args[0] if args else str
|
|
35
|
+
item_schema = _schema_for_python_type(item, components, visited)
|
|
36
|
+
return {"type": "array", "items": item_schema}
|
|
37
|
+
|
|
38
|
+
# Struct subclass
|
|
39
|
+
if isinstance(py_type, type) and issubclass(py_type, Struct):
|
|
40
|
+
name = py_type.__name__
|
|
41
|
+
if py_type not in visited:
|
|
42
|
+
visited.add(py_type)
|
|
43
|
+
components[name] = _generate_struct_schema(py_type, components, visited)
|
|
44
|
+
return {"$ref": f"#/components/schemas/{name}"}
|
|
45
|
+
|
|
46
|
+
# Special formats
|
|
47
|
+
if py_type is uuid.UUID:
|
|
48
|
+
return {"type": "string", "format": "uuid"}
|
|
49
|
+
if py_type is datetime.datetime:
|
|
50
|
+
return {"type": "string", "format": "date-time"}
|
|
51
|
+
if py_type is datetime.date:
|
|
52
|
+
return {"type": "string", "format": "date"}
|
|
53
|
+
|
|
54
|
+
# Scalars
|
|
55
|
+
return {"type": TYPE_MAP.get(py_type, "string")}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unwrap_optional(py_type: Type) -> tuple[Type, bool]:
|
|
59
|
+
origin = get_origin(py_type)
|
|
60
|
+
args = get_args(py_type)
|
|
61
|
+
if origin is typing.Union and args:
|
|
62
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
63
|
+
if len(non_none) == 1:
|
|
64
|
+
return non_none[0], True
|
|
65
|
+
return py_type, False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _generate_struct_schema(
|
|
69
|
+
struct_class: Type[Struct],
|
|
70
|
+
components: Dict[str, Dict[str, Any]],
|
|
71
|
+
visited: set[Type],
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
Generate a JSON Schema dictionary for a msgspec Struct, populating components for nested Structs.
|
|
11
75
|
"""
|
|
12
|
-
|
|
76
|
+
properties: Dict[str, Any] = {}
|
|
77
|
+
required: List[str] = []
|
|
13
78
|
|
|
14
|
-
|
|
15
|
-
|
|
79
|
+
annotations = getattr(struct_class, "__annotations__", {})
|
|
80
|
+
for field_name in getattr(struct_class, "__struct_fields__", annotations.keys()):
|
|
81
|
+
field_type = annotations.get(field_name, str)
|
|
82
|
+
base_type, is_opt = _unwrap_optional(field_type)
|
|
16
83
|
|
|
17
|
-
|
|
18
|
-
|
|
84
|
+
# Build property schema
|
|
85
|
+
prop_schema = _schema_for_python_type(base_type, components, visited)
|
|
86
|
+
if is_opt:
|
|
87
|
+
prop_schema["nullable"] = True
|
|
88
|
+
|
|
89
|
+
properties[field_name] = prop_schema
|
|
90
|
+
|
|
91
|
+
# Determine required: mark non-Optional fields as required
|
|
92
|
+
if not is_opt:
|
|
93
|
+
required.append(field_name)
|
|
94
|
+
|
|
95
|
+
schema: Dict[str, Any] = {"type": "object", "properties": properties}
|
|
96
|
+
if required:
|
|
97
|
+
schema["required"] = required
|
|
98
|
+
return schema
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_components_for_struct(
|
|
102
|
+
struct_class: Type[Struct],
|
|
103
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
19
104
|
"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# For now, assume all fields are required (can be enhanced later)
|
|
31
|
-
required.append(field_name)
|
|
105
|
+
Build components schemas for the given Struct and all nested Structs.
|
|
106
|
+
|
|
107
|
+
Returns a dict mapping component name to schema, including the top-level struct.
|
|
108
|
+
"""
|
|
109
|
+
components: Dict[str, Dict[str, Any]] = {}
|
|
110
|
+
visited: set[Type] = set()
|
|
111
|
+
name = struct_class.__name__
|
|
112
|
+
components[name] = _generate_struct_schema(struct_class, components, visited)
|
|
113
|
+
return components
|
|
114
|
+
|
|
32
115
|
|
|
33
|
-
|
|
116
|
+
def _generate_schema_for_struct(struct_class: Type[Struct]) -> Dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Backward-compatible API: generate only the schema for the provided Struct (no nested components registration).
|
|
119
|
+
"""
|
|
120
|
+
comps = build_components_for_struct(struct_class)
|
|
121
|
+
return comps[struct_class.__name__]
|
|
34
122
|
|
|
35
123
|
|
|
36
124
|
@dataclass
|
|
@@ -175,13 +263,8 @@ class OpenAPIGenerator:
|
|
|
175
263
|
"""Generate HTML for Swagger UI"""
|
|
176
264
|
swagger_ui_parameters = self.config.swagger_ui_parameters or {}
|
|
177
265
|
|
|
178
|
-
#
|
|
179
|
-
params_json = (
|
|
180
|
-
str(swagger_ui_parameters)
|
|
181
|
-
.replace("'", '"')
|
|
182
|
-
.replace("True", "true")
|
|
183
|
-
.replace("False", "false")
|
|
184
|
-
)
|
|
266
|
+
# Serialize parameters to JSON safely
|
|
267
|
+
params_json = json.dumps(swagger_ui_parameters)
|
|
185
268
|
|
|
186
269
|
html = f"""<!DOCTYPE html>
|
|
187
270
|
<html>
|
|
@@ -268,7 +351,6 @@ class OpenAPIGenerator:
|
|
|
268
351
|
"""
|
|
269
352
|
if self._openapi_schema is None:
|
|
270
353
|
self._openapi_schema = self.config.to_openapi_dict()
|
|
271
|
-
|
|
272
354
|
if path not in self._openapi_schema["paths"]:
|
|
273
355
|
self._openapi_schema["paths"][path] = {}
|
|
274
356
|
|
tachyon_api/responses.py
CHANGED
|
@@ -6,12 +6,23 @@ with Starlette responses.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from starlette.responses import JSONResponse, HTMLResponse # noqa
|
|
9
|
+
from .models import encode_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TachyonJSONResponse(JSONResponse):
|
|
13
|
+
"""High-performance JSON response using orjson for serialization."""
|
|
14
|
+
|
|
15
|
+
media_type = "application/json"
|
|
16
|
+
|
|
17
|
+
def render(self, content) -> bytes: # type: ignore[override]
|
|
18
|
+
# Use centralized encoder to support Struct, UUID, date, datetime, etc.
|
|
19
|
+
return encode_json(content)
|
|
9
20
|
|
|
10
21
|
|
|
11
22
|
# Simple helper functions for common response patterns
|
|
12
23
|
def success_response(data=None, message="Success", status_code=200):
|
|
13
24
|
"""Create a success response with consistent structure"""
|
|
14
|
-
return
|
|
25
|
+
return TachyonJSONResponse(
|
|
15
26
|
{"success": True, "message": message, "data": data}, status_code=status_code
|
|
16
27
|
)
|
|
17
28
|
|
|
@@ -22,7 +33,7 @@ def error_response(error, status_code=400, code=None):
|
|
|
22
33
|
if code:
|
|
23
34
|
response_data["code"] = code
|
|
24
35
|
|
|
25
|
-
return
|
|
36
|
+
return TachyonJSONResponse(response_data, status_code=status_code)
|
|
26
37
|
|
|
27
38
|
|
|
28
39
|
def not_found_response(error="Resource not found"):
|
|
@@ -41,7 +52,39 @@ def validation_error_response(error="Validation failed", errors=None):
|
|
|
41
52
|
if errors:
|
|
42
53
|
response_data["errors"] = errors
|
|
43
54
|
|
|
44
|
-
return
|
|
55
|
+
return TachyonJSONResponse(response_data, status_code=422)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def response_validation_error_response(error="Response validation error"):
|
|
59
|
+
"""Create a 500 response validation error response"""
|
|
60
|
+
# Normalize message with prefix and include 'detail' for backward compatibility
|
|
61
|
+
msg = str(error)
|
|
62
|
+
if not msg.lower().startswith("response validation error"):
|
|
63
|
+
msg = f"Response validation error: {msg}"
|
|
64
|
+
return TachyonJSONResponse(
|
|
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
|
+
},
|
|
86
|
+
status_code=500,
|
|
87
|
+
)
|
|
45
88
|
|
|
46
89
|
|
|
47
90
|
# Re-export Starlette responses for convenience
|
|
@@ -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
|
+
]
|