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.
- tachyon_api/__init__.py +59 -0
- tachyon_api/app.py +699 -0
- tachyon_api/background.py +72 -0
- tachyon_api/cache.py +270 -0
- tachyon_api/cli/__init__.py +9 -0
- tachyon_api/cli/__main__.py +8 -0
- tachyon_api/cli/commands/__init__.py +5 -0
- tachyon_api/cli/commands/generate.py +190 -0
- tachyon_api/cli/commands/lint.py +186 -0
- tachyon_api/cli/commands/new.py +82 -0
- tachyon_api/cli/commands/openapi.py +128 -0
- tachyon_api/cli/main.py +69 -0
- tachyon_api/cli/templates/__init__.py +8 -0
- tachyon_api/cli/templates/project.py +194 -0
- tachyon_api/cli/templates/service.py +330 -0
- tachyon_api/core/__init__.py +12 -0
- tachyon_api/core/lifecycle.py +106 -0
- tachyon_api/core/websocket.py +92 -0
- tachyon_api/di.py +86 -0
- tachyon_api/exceptions.py +39 -0
- tachyon_api/files.py +14 -0
- tachyon_api/middlewares/__init__.py +4 -0
- tachyon_api/middlewares/core.py +40 -0
- tachyon_api/middlewares/cors.py +159 -0
- tachyon_api/middlewares/logger.py +123 -0
- tachyon_api/models.py +73 -0
- tachyon_api/openapi.py +419 -0
- tachyon_api/params.py +268 -0
- tachyon_api/processing/__init__.py +14 -0
- tachyon_api/processing/dependencies.py +172 -0
- tachyon_api/processing/parameters.py +484 -0
- tachyon_api/processing/response_processor.py +93 -0
- tachyon_api/responses.py +92 -0
- tachyon_api/router.py +161 -0
- tachyon_api/security.py +295 -0
- tachyon_api/testing.py +110 -0
- tachyon_api/utils/__init__.py +15 -0
- tachyon_api/utils/type_converter.py +113 -0
- tachyon_api/utils/type_utils.py +162 -0
- tachyon_api-0.9.0.dist-info/METADATA +291 -0
- tachyon_api-0.9.0.dist-info/RECORD +44 -0
- tachyon_api-0.9.0.dist-info/WHEEL +4 -0
- tachyon_api-0.9.0.dist-info/entry_points.txt +3 -0
- tachyon_api-0.9.0.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Internal core helpers for Tachyon middleware integration
|
|
2
|
+
from starlette.middleware import Middleware
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def apply_middleware_to_router(router_app, middleware_class, **options):
|
|
6
|
+
"""
|
|
7
|
+
Insert an ASGI middleware into Starlette's stack and rebuild the stack.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
router_app: internal Starlette instance (app._router)
|
|
11
|
+
middleware_class: ASGI middleware class
|
|
12
|
+
**options: kwargs passed to the middleware constructor
|
|
13
|
+
"""
|
|
14
|
+
router_app.user_middleware.insert(0, Middleware(middleware_class, **options))
|
|
15
|
+
router_app.middleware_stack = router_app.build_middleware_stack()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_decorated_middleware_class(middleware_func, middleware_type: str = "http"):
|
|
19
|
+
"""
|
|
20
|
+
Create an ASGI middleware class from a decorated function with the signature
|
|
21
|
+
(scope, receive, send, app).
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
middleware_func: middleware function provided to the decorator
|
|
25
|
+
middleware_type: type ("http" by default) or "*" for all
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Middleware class that invokes the decorated function
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
class DecoratedMiddleware:
|
|
32
|
+
def __init__(self, app):
|
|
33
|
+
self.app = app
|
|
34
|
+
|
|
35
|
+
async def __call__(self, scope, receive, send):
|
|
36
|
+
if scope.get("type") == middleware_type or middleware_type == "*":
|
|
37
|
+
return await middleware_func(scope, receive, send, self.app)
|
|
38
|
+
return await self.app(scope, receive, send)
|
|
39
|
+
|
|
40
|
+
return DecoratedMiddleware
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import Iterable, Optional, Sequence, Tuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CORSMiddleware:
|
|
5
|
+
"""
|
|
6
|
+
ASGI middleware for handling CORS.
|
|
7
|
+
|
|
8
|
+
Parameters:
|
|
9
|
+
- allow_origins: list of allowed origins (e.g., ["https://foo.com", "*"])
|
|
10
|
+
- allow_methods: list of allowed methods (default ["*"])
|
|
11
|
+
- allow_headers: list of allowed headers (default ["*"])
|
|
12
|
+
- allow_credentials: if True, adds Access-Control-Allow-Credentials: true
|
|
13
|
+
- expose_headers: list of headers to expose to the client app
|
|
14
|
+
- max_age: seconds to cache the preflight response
|
|
15
|
+
|
|
16
|
+
Behavior:
|
|
17
|
+
- If it's a preflight (OPTIONS + Access-Control-Request-Method), responds 200 with
|
|
18
|
+
the appropriate CORS headers.
|
|
19
|
+
- For normal requests, injects CORS headers into the response.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
app,
|
|
25
|
+
allow_origins: Iterable[str] = ("*",),
|
|
26
|
+
allow_methods: Iterable[str] = ("*",),
|
|
27
|
+
allow_headers: Iterable[str] = ("*",),
|
|
28
|
+
allow_credentials: bool = False,
|
|
29
|
+
expose_headers: Iterable[str] = (),
|
|
30
|
+
max_age: int = 600,
|
|
31
|
+
):
|
|
32
|
+
self.app = app
|
|
33
|
+
self.allow_origins = [o for o in allow_origins]
|
|
34
|
+
self.allow_methods = [m.upper() for m in allow_methods]
|
|
35
|
+
self.allow_headers = [h for h in allow_headers]
|
|
36
|
+
self.allow_credentials = allow_credentials
|
|
37
|
+
self.expose_headers = [h for h in expose_headers]
|
|
38
|
+
self.max_age = max_age
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _get_header(headers: Sequence[Tuple[bytes, bytes]], name: str) -> Optional[str]:
|
|
42
|
+
# Find a header in a case-insensitive manner and decode it
|
|
43
|
+
lower = name.lower().encode()
|
|
44
|
+
for k, v in headers or []:
|
|
45
|
+
if k.lower() == lower:
|
|
46
|
+
try:
|
|
47
|
+
return v.decode()
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _append_header(headers: list[Tuple[bytes, bytes]], name: str, value: str):
|
|
54
|
+
# Append header encoded as bytes as required by ASGI
|
|
55
|
+
headers.append((name.encode(), value.encode()))
|
|
56
|
+
|
|
57
|
+
def _origin_allowed(self, origin: Optional[str]) -> bool:
|
|
58
|
+
if origin is None:
|
|
59
|
+
return False
|
|
60
|
+
if "*" in self.allow_origins:
|
|
61
|
+
return True
|
|
62
|
+
return origin in self.allow_origins
|
|
63
|
+
|
|
64
|
+
async def __call__(self, scope, receive, send):
|
|
65
|
+
if scope.get("type") != "http":
|
|
66
|
+
return await self.app(scope, receive, send)
|
|
67
|
+
|
|
68
|
+
req_headers: Sequence[Tuple[bytes, bytes]] = scope.get("headers", []) or []
|
|
69
|
+
origin = self._get_header(req_headers, "origin")
|
|
70
|
+
method = scope.get("method", "").upper()
|
|
71
|
+
is_preflight = (
|
|
72
|
+
method == "OPTIONS"
|
|
73
|
+
and self._get_header(req_headers, "access-control-request-method")
|
|
74
|
+
is not None
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Build common CORS headers
|
|
78
|
+
def build_cors_headers() -> list[Tuple[bytes, bytes]]:
|
|
79
|
+
headers_out: list[Tuple[bytes, bytes]] = []
|
|
80
|
+
if not self._origin_allowed(origin):
|
|
81
|
+
return headers_out
|
|
82
|
+
|
|
83
|
+
# Access-Control-Allow-Origin
|
|
84
|
+
if "*" in self.allow_origins and not self.allow_credentials:
|
|
85
|
+
self._append_header(headers_out, "access-control-allow-origin", "*")
|
|
86
|
+
else:
|
|
87
|
+
self._append_header(headers_out, "access-control-allow-origin", origin)
|
|
88
|
+
# Necessary for proxies/caches when echoing the origin
|
|
89
|
+
self._append_header(headers_out, "vary", "Origin")
|
|
90
|
+
|
|
91
|
+
# Credentials
|
|
92
|
+
if self.allow_credentials:
|
|
93
|
+
self._append_header(
|
|
94
|
+
headers_out, "access-control-allow-credentials", "true"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return headers_out
|
|
98
|
+
|
|
99
|
+
# Handle preflight
|
|
100
|
+
if is_preflight:
|
|
101
|
+
resp_headers = build_cors_headers()
|
|
102
|
+
if resp_headers:
|
|
103
|
+
# Methods
|
|
104
|
+
allow_methods = (
|
|
105
|
+
", ".join(self.allow_methods)
|
|
106
|
+
if "*" not in self.allow_methods
|
|
107
|
+
else "*"
|
|
108
|
+
)
|
|
109
|
+
self._append_header(
|
|
110
|
+
resp_headers, "access-control-allow-methods", allow_methods
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Requested headers or wildcard
|
|
114
|
+
req_acrh = self._get_header(
|
|
115
|
+
req_headers, "access-control-request-headers"
|
|
116
|
+
)
|
|
117
|
+
if "*" in self.allow_headers:
|
|
118
|
+
allow_headers = req_acrh or "*"
|
|
119
|
+
else:
|
|
120
|
+
allow_headers = ", ".join(self.allow_headers)
|
|
121
|
+
if allow_headers:
|
|
122
|
+
self._append_header(
|
|
123
|
+
resp_headers, "access-control-allow-headers", allow_headers
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Max-Age
|
|
127
|
+
if self.max_age:
|
|
128
|
+
self._append_header(
|
|
129
|
+
resp_headers, "access-control-max-age", str(self.max_age)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Respond directly
|
|
133
|
+
await send(
|
|
134
|
+
{
|
|
135
|
+
"type": "http.response.start",
|
|
136
|
+
"status": 200,
|
|
137
|
+
"headers": resp_headers,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
await send({"type": "http.response.body", "body": b""})
|
|
141
|
+
return
|
|
142
|
+
# If origin not allowed, continue the chain (app will respond accordingly)
|
|
143
|
+
|
|
144
|
+
# Normal requests: inject CORS headers into the response
|
|
145
|
+
cors_headers = build_cors_headers()
|
|
146
|
+
|
|
147
|
+
# Expose-Headers
|
|
148
|
+
if cors_headers and self.expose_headers:
|
|
149
|
+
expose = ", ".join(self.expose_headers)
|
|
150
|
+
self._append_header(cors_headers, "access-control-expose-headers", expose)
|
|
151
|
+
|
|
152
|
+
async def send_wrapper(message):
|
|
153
|
+
if message.get("type") == "http.response.start" and cors_headers:
|
|
154
|
+
headers = list(message.get("headers", []) or [])
|
|
155
|
+
headers.extend(cors_headers)
|
|
156
|
+
message["headers"] = headers
|
|
157
|
+
return await send(message)
|
|
158
|
+
|
|
159
|
+
return await self.app(scope, receive, send_wrapper)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional, Sequence, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LoggerMiddleware:
|
|
7
|
+
"""
|
|
8
|
+
ASGI middleware for logging requests and responses.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
- logger: logging.Logger instance (defaults to "tachyon.logger")
|
|
12
|
+
- level: log level (defaults to logging.INFO)
|
|
13
|
+
- include_headers: if True, log request and response headers
|
|
14
|
+
- redact_headers: list of header names to redact
|
|
15
|
+
- log_request_body: if True, try to read and log the request body (HTTP)
|
|
16
|
+
|
|
17
|
+
Note: To keep it simple and non-intrusive, the body is only logged if it's
|
|
18
|
+
available in a single body message (typical in tests/sync). In production,
|
|
19
|
+
reading the body implies buffering receive/send, which we intentionally avoid
|
|
20
|
+
here to prevent side effects.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app,
|
|
26
|
+
logger: Optional[logging.Logger] = None,
|
|
27
|
+
level: int = logging.INFO,
|
|
28
|
+
include_headers: bool = False,
|
|
29
|
+
redact_headers: Optional[Sequence[str]] = None,
|
|
30
|
+
log_request_body: bool = False,
|
|
31
|
+
):
|
|
32
|
+
self.app = app
|
|
33
|
+
self.logger = logger or logging.getLogger("tachyon.logger")
|
|
34
|
+
self.level = level
|
|
35
|
+
self.include_headers = include_headers
|
|
36
|
+
self.redact_headers = {h.lower() for h in (redact_headers or [])}
|
|
37
|
+
self.log_request_body = log_request_body
|
|
38
|
+
|
|
39
|
+
async def __call__(self, scope, receive, send):
|
|
40
|
+
if scope.get("type") != "http":
|
|
41
|
+
return await self.app(scope, receive, send)
|
|
42
|
+
|
|
43
|
+
method: str = scope.get("method", "")
|
|
44
|
+
path: str = scope.get("path", "")
|
|
45
|
+
start = time.time()
|
|
46
|
+
status_code_holder = {"status": None}
|
|
47
|
+
response_headers_holder: list[Tuple[bytes, bytes]] = []
|
|
48
|
+
|
|
49
|
+
# Simple, non-intrusive request body preview
|
|
50
|
+
request_body_preview = None
|
|
51
|
+
if self.log_request_body:
|
|
52
|
+
# Non-intrusive attempt: peek the body if the first receive contains it fully
|
|
53
|
+
body_chunks = []
|
|
54
|
+
more_body = True
|
|
55
|
+
|
|
56
|
+
async def recv_wrapper():
|
|
57
|
+
nonlocal more_body
|
|
58
|
+
message = await receive()
|
|
59
|
+
if message.get("type") == "http.request":
|
|
60
|
+
body = message.get("body", b"")
|
|
61
|
+
if body:
|
|
62
|
+
body_chunks.append(body)
|
|
63
|
+
more_body = message.get("more_body", False)
|
|
64
|
+
return message
|
|
65
|
+
|
|
66
|
+
# Perform a one-time peek without fully consuming the stream
|
|
67
|
+
# If body is present and more_body is False, log a small preview
|
|
68
|
+
first_msg = await recv_wrapper()
|
|
69
|
+
|
|
70
|
+
async def receive_passthrough():
|
|
71
|
+
# Return the pre-received message first, then pass-through
|
|
72
|
+
nonlocal first_msg
|
|
73
|
+
if first_msg is not None:
|
|
74
|
+
m = first_msg
|
|
75
|
+
first_msg = None
|
|
76
|
+
return m
|
|
77
|
+
return await receive()
|
|
78
|
+
|
|
79
|
+
receive_to_use = receive_passthrough
|
|
80
|
+
if body_chunks and not more_body:
|
|
81
|
+
try:
|
|
82
|
+
request_body_preview = b"".join(body_chunks)[:2048].decode(
|
|
83
|
+
"utf-8", "replace"
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
request_body_preview = "<non-text body>"
|
|
87
|
+
else:
|
|
88
|
+
receive_to_use = receive
|
|
89
|
+
|
|
90
|
+
def _normalized_headers(headers: Sequence[Tuple[bytes, bytes]]):
|
|
91
|
+
out = []
|
|
92
|
+
for k, v in headers:
|
|
93
|
+
name = k.decode().lower()
|
|
94
|
+
if name in self.redact_headers:
|
|
95
|
+
out.append((name, "<redacted>"))
|
|
96
|
+
else:
|
|
97
|
+
out.append((name, v.decode(errors="replace")))
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
self.logger.log(self.level, f"--> {method} {path}")
|
|
101
|
+
if self.include_headers:
|
|
102
|
+
req_headers = _normalized_headers(scope.get("headers", []) or [])
|
|
103
|
+
self.logger.log(self.level, f" req headers: {req_headers}")
|
|
104
|
+
if request_body_preview is not None:
|
|
105
|
+
self.logger.log(self.level, f" req body: {request_body_preview}")
|
|
106
|
+
|
|
107
|
+
async def send_wrapper(message):
|
|
108
|
+
if message.get("type") == "http.response.start":
|
|
109
|
+
status_code_holder["status"] = message.get("status", 0)
|
|
110
|
+
response_headers_holder[:] = list(message.get("headers", []) or [])
|
|
111
|
+
return await send(message)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
await self.app(scope, receive_to_use, send_wrapper)
|
|
115
|
+
finally:
|
|
116
|
+
duration = time.time() - start
|
|
117
|
+
status = status_code_holder["status"] or 0
|
|
118
|
+
self.logger.log(
|
|
119
|
+
self.level, f"<-- {method} {path} {status} ({duration:.4f}s)"
|
|
120
|
+
)
|
|
121
|
+
if self.include_headers and response_headers_holder:
|
|
122
|
+
res_headers = _normalized_headers(response_headers_holder)
|
|
123
|
+
self.logger.log(self.level, f" res headers: {res_headers}")
|
tachyon_api/models.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tachyon Web Framework - Data Models Module
|
|
3
|
+
|
|
4
|
+
This module provides the base model class for request/response data validation
|
|
5
|
+
using msgspec for high-performance JSON serialization and validation.
|
|
6
|
+
The module enhances msgspec with orjson for even faster JSON processing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Dict, Type, TypeVar, Optional, Union
|
|
12
|
+
|
|
13
|
+
import msgspec
|
|
14
|
+
import orjson
|
|
15
|
+
from msgspec import Struct, Meta
|
|
16
|
+
|
|
17
|
+
__all__ = ["Struct", "Meta", "encode_json", "decode_json"]
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _orjson_default(obj: Any) -> Any:
|
|
23
|
+
"""Default function for orjson to serialize types it doesn't support natively."""
|
|
24
|
+
if isinstance(obj, (datetime.date, datetime.datetime)):
|
|
25
|
+
return obj.isoformat()
|
|
26
|
+
if isinstance(obj, uuid.UUID):
|
|
27
|
+
return str(obj)
|
|
28
|
+
if isinstance(obj, Struct):
|
|
29
|
+
return msgspec.to_builtins(obj)
|
|
30
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode_json(obj: Any, option: Optional[int] = None) -> bytes:
|
|
34
|
+
"""
|
|
35
|
+
Encode a Python object to JSON using orjson.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
obj: Object to encode (can be a Struct instance or any JSON-serializable object)
|
|
39
|
+
option: orjson option flags (e.g., orjson.OPT_INDENT_2)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
JSON-encoded bytes
|
|
43
|
+
"""
|
|
44
|
+
opts = (
|
|
45
|
+
option
|
|
46
|
+
or orjson.OPT_SERIALIZE_DATACLASS | orjson.OPT_SERIALIZE_UUID | orjson.OPT_UTC_Z
|
|
47
|
+
)
|
|
48
|
+
return orjson.dumps(obj, default=_orjson_default, option=opts)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_json(data: Union[bytes, str], type_: Type[T] = Dict[str, Any]) -> T:
|
|
52
|
+
"""
|
|
53
|
+
Decode JSON to a Python object using orjson.
|
|
54
|
+
If a Struct type is provided, the decoded data will be converted to that type.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: JSON data as bytes or string
|
|
58
|
+
type_: Target type (default is Dict[str, Any])
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Decoded object of the specified type
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(data, str):
|
|
64
|
+
data = data.encode("utf-8")
|
|
65
|
+
|
|
66
|
+
# First use orjson for fast JSON parsing
|
|
67
|
+
parsed_data = orjson.loads(data)
|
|
68
|
+
|
|
69
|
+
# If the target type is a Struct or similar msgspec type, use msgspec.convert
|
|
70
|
+
if isinstance(type_, type) and issubclass(type_, Struct):
|
|
71
|
+
return msgspec.convert(parsed_data, type_)
|
|
72
|
+
|
|
73
|
+
return parsed_data
|