hexastack-fastapi 0.7.0__tar.gz → 0.9.0__tar.gz
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.
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/PKG-INFO +3 -1
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/pyproject.toml +2 -1
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/pyproject.toml.orig +4 -1
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/__init__.py +8 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/dependencies.py +14 -9
- hexastack_fastapi-0.9.0/src/hexastack_fastapi/adapters/openziti.py +235 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/ratelimit.py +67 -16
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/sse.py +37 -24
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/websockets.py +6 -1
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/domain/config.py +8 -0
- hexastack_fastapi-0.9.0/src/hexastack_fastapi/infra/exception_handlers.py +156 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/correlation.py +13 -12
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/metrics.py +10 -2
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/recorder.py +9 -9
- hexastack_fastapi-0.7.0/src/hexastack_fastapi/infra/exception_handlers.py +0 -70
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/README.md +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/__init__.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/health.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/metrics.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/routing.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/domain/__init__.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/__init__.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/app.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/autodiscovery.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/bootstrap.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/config.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/decorators.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/__init__.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/py.typed +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
- {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexastack-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: FastAPI presentation adapter for Hexastack
|
|
5
5
|
Author: Richard West
|
|
6
6
|
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
@@ -14,11 +14,13 @@ Requires-Dist: limits>=5.8.0 ; extra == 'ratelimit'
|
|
|
14
14
|
Requires-Dist: slowapi>=0.1.10 ; extra == 'ratelimit'
|
|
15
15
|
Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
|
|
16
16
|
Requires-Dist: schemathesis>=3.39.0 ; extra == 'testing'
|
|
17
|
+
Requires-Dist: openziti>=1.0.0 ; extra == 'ziti'
|
|
17
18
|
Requires-Python: >=3.13
|
|
18
19
|
Provides-Extra: auth
|
|
19
20
|
Provides-Extra: docs
|
|
20
21
|
Provides-Extra: ratelimit
|
|
21
22
|
Provides-Extra: testing
|
|
23
|
+
Provides-Extra: ziti
|
|
22
24
|
Description-Content-Type: text/markdown
|
|
23
25
|
|
|
24
26
|

|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-fastapi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "FastAPI presentation adapter for Hexastack"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -26,6 +26,7 @@ testing = [
|
|
|
26
26
|
"playwright>=1.49.0",
|
|
27
27
|
"schemathesis>=3.39.0",
|
|
28
28
|
]
|
|
29
|
+
ziti = ["openziti>=1.0.0"]
|
|
29
30
|
|
|
30
31
|
[project.entry-points."hexastack.bootstrappers"]
|
|
31
32
|
fastapi = "hexastack_fastapi.infra.bootstrap:FastApiBootstrapper"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-fastapi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "FastAPI presentation adapter for Hexastack"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -29,6 +29,9 @@ testing = [
|
|
|
29
29
|
"playwright>=1.49.0",
|
|
30
30
|
"schemathesis>=3.39.0",
|
|
31
31
|
]
|
|
32
|
+
ziti = [
|
|
33
|
+
"openziti>=1.0.0",
|
|
34
|
+
]
|
|
32
35
|
|
|
33
36
|
[project.entry-points."hexastack.bootstrappers"]
|
|
34
37
|
fastapi = "hexastack_fastapi.infra.bootstrap:FastApiBootstrapper"
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/__init__.py
RENAMED
|
@@ -14,6 +14,11 @@ from hexastack_fastapi.adapters.docs import (
|
|
|
14
14
|
)
|
|
15
15
|
from hexastack_fastapi.adapters.health import create_health_router
|
|
16
16
|
from hexastack_fastapi.adapters.metrics import create_metrics_router
|
|
17
|
+
from hexastack_fastapi.adapters.openziti import (
|
|
18
|
+
OpenZitiASGIAdapter,
|
|
19
|
+
OpenZitiConfig,
|
|
20
|
+
openziti_bind,
|
|
21
|
+
)
|
|
17
22
|
from hexastack_fastapi.adapters.ratelimit import (
|
|
18
23
|
SlowapiRateLimiterAdapter,
|
|
19
24
|
get_remote_address,
|
|
@@ -45,6 +50,9 @@ __all__ = [
|
|
|
45
50
|
"get_remote_address",
|
|
46
51
|
"get_user_or_ip_key",
|
|
47
52
|
"mount_zensical_docs",
|
|
53
|
+
"openziti_bind",
|
|
54
|
+
"OpenZitiASGIAdapter",
|
|
55
|
+
"OpenZitiConfig",
|
|
48
56
|
"rate_limit",
|
|
49
57
|
"require_feature",
|
|
50
58
|
"require_rate_limit",
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/dependencies.py
RENAMED
|
@@ -240,14 +240,19 @@ def require_rate_limit(
|
|
|
240
240
|
|
|
241
241
|
async def _dependency(request: Request) -> None:
|
|
242
242
|
limiter = get_rate_limiter(request)
|
|
243
|
-
if limiter is
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
243
|
+
if limiter is None:
|
|
244
|
+
from hexastack_core.adapters.ratelimit import InMemoryRateLimiter
|
|
245
|
+
|
|
246
|
+
limiter = InMemoryRateLimiter()
|
|
247
|
+
request.app.state.rate_limiter = limiter
|
|
248
|
+
|
|
249
|
+
key = key_extractor(request)
|
|
250
|
+
if not limiter.hit(key, limit):
|
|
251
|
+
reset_sec = limiter.get_reset_window(key, limit)
|
|
252
|
+
raise HTTPException(
|
|
253
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
254
|
+
detail=detail or f"Rate limit exceeded: {limit}.",
|
|
255
|
+
headers={"Retry-After": str(reset_sec)},
|
|
256
|
+
)
|
|
252
257
|
|
|
253
258
|
return _dependency
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Zero-Trust Dark Microservice ASGI presentation adapter using OpenZiti.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Enables FastAPI/Starlette applications to host services directly on an OpenZiti overlay
|
|
5
|
+
network fabric without opening listening TCP sockets or exposed host ports.
|
|
6
|
+
Uses outbound-only mTLS tunnels so services remain dark and invisible to LAN scans
|
|
7
|
+
and the public internet.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
from collections.abc import Callable, Generator
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
19
|
+
from hexastack_core.utils import require_dependency
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"openziti_bind",
|
|
23
|
+
"OpenZitiASGIAdapter",
|
|
24
|
+
"OpenZitiConfig",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class OpenZitiConfig:
|
|
30
|
+
"""Configuration for hosting an ASGI application over the OpenZiti overlay network.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
identity_path: Path to the enrolled OpenZiti identity JSON credentials file.
|
|
34
|
+
service_name: Name of the OpenZiti service configured with Bind permissions.
|
|
35
|
+
bind_host: Virtual hostname or IP for the intercepted socket. Defaults to '127.0.0.1'.
|
|
36
|
+
bind_port: Virtual port for the intercepted socket. Defaults to 8000.
|
|
37
|
+
enabled: Whether OpenZiti overlay hosting is active. Defaults to True.
|
|
38
|
+
|
|
39
|
+
Notes/Architectural Intent:
|
|
40
|
+
The bind_host and bind_port do not open listening kernel ports on the host.
|
|
41
|
+
They serve as the socket binding hook intercepted by OpenZiti's monkeypatch layer.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
identity_path: Path | str
|
|
45
|
+
service_name: str
|
|
46
|
+
bind_host: str = "127.0.0.1"
|
|
47
|
+
bind_port: int = 8000
|
|
48
|
+
enabled: bool = True
|
|
49
|
+
|
|
50
|
+
def resolved_identity_path(self) -> Path:
|
|
51
|
+
"""Resolve and validate the OpenZiti identity credentials file path.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Resolved Path object pointing to the identity file.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
FileNotFoundError: If the identity credentials file does not exist on disk.
|
|
58
|
+
"""
|
|
59
|
+
path = Path(self.identity_path).resolve()
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
raise FileNotFoundError(
|
|
62
|
+
f"OpenZiti identity credentials file not found: {self.identity_path}"
|
|
63
|
+
)
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OpenZitiASGIAdapter:
|
|
68
|
+
"""ASGI server adapter enabling zero-trust hosting on the OpenZiti overlay fabric.
|
|
69
|
+
|
|
70
|
+
Notes/Architectural Intent:
|
|
71
|
+
Wraps Python socket operations via OpenZiti's monkeypatching infrastructure to bind
|
|
72
|
+
ASGI servers (e.g. Uvicorn) directly to an OpenZiti service without opening listening
|
|
73
|
+
network sockets on the host OS.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config: OpenZitiConfig) -> None:
|
|
77
|
+
"""Initialize the OpenZiti ASGI adapter.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
config: OpenZitiConfig specifying identity path, service name, and virtual bind target.
|
|
81
|
+
"""
|
|
82
|
+
self.config = config
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _require_openziti() -> Any:
|
|
86
|
+
"""Dynamically load openziti module or raise MissingDependencyError.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The imported openziti module.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
MissingDependencyError: If openziti is not installed.
|
|
93
|
+
"""
|
|
94
|
+
return require_dependency(
|
|
95
|
+
"openziti",
|
|
96
|
+
extra="ziti",
|
|
97
|
+
package="hexastack-fastapi",
|
|
98
|
+
feature_name="OpenZiti Zero-Trust Network",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def get_bindings(
|
|
102
|
+
self, host: str | None = None, port: int | None = None
|
|
103
|
+
) -> dict[tuple[str, int], dict[str, Any]]:
|
|
104
|
+
"""Construct the OpenZiti socket binding mapping.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
host: Optional host address override. Defaults to config.bind_host.
|
|
108
|
+
port: Optional port number override. Defaults to config.bind_port.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dictionary mapping (host, port) to OpenZiti context and service options.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
FileNotFoundError: If the identity credentials file does not exist.
|
|
115
|
+
"""
|
|
116
|
+
identity_file = self.config.resolved_identity_path()
|
|
117
|
+
target_host = host or self.config.bind_host
|
|
118
|
+
target_port = port if port is not None else self.config.bind_port
|
|
119
|
+
return {
|
|
120
|
+
(target_host, target_port): {
|
|
121
|
+
"ztx": str(identity_file),
|
|
122
|
+
"service": self.config.service_name,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@contextlib.contextmanager
|
|
127
|
+
def bind_context(
|
|
128
|
+
self, host: str | None = None, port: int | None = None
|
|
129
|
+
) -> Generator[None]:
|
|
130
|
+
"""Context manager activating OpenZiti overlay socket interception.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
host: Optional bind host override.
|
|
134
|
+
port: Optional bind port override.
|
|
135
|
+
|
|
136
|
+
Yields:
|
|
137
|
+
None when socket interception is active.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
MissingDependencyError: If openziti is not installed.
|
|
141
|
+
FileNotFoundError: If the identity file does not exist.
|
|
142
|
+
|
|
143
|
+
Notes/Architectural Intent:
|
|
144
|
+
Activates openziti.monkeypatch with configured bindings upon entry,
|
|
145
|
+
restoring original Python socket behaviors upon exit.
|
|
146
|
+
"""
|
|
147
|
+
if not self.config.enabled:
|
|
148
|
+
yield
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
openziti = self._require_openziti()
|
|
152
|
+
bindings = self.get_bindings(host=host, port=port)
|
|
153
|
+
with openziti.monkeypatch(bindings=bindings):
|
|
154
|
+
yield
|
|
155
|
+
|
|
156
|
+
def zitify(self, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
157
|
+
"""Decorate a server runner function to execute within the OpenZiti overlay.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
func: Target callable to decorate (e.g. uvicorn.run or server startup).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Decorated callable wrapped with OpenZiti overlay socket interception.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
167
|
+
with self.bind_context():
|
|
168
|
+
return func(*args, **kwargs)
|
|
169
|
+
|
|
170
|
+
return wrapped
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _require_uvicorn() -> Any:
|
|
174
|
+
"""Dynamically load uvicorn module or raise MissingDependencyError.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The imported uvicorn module.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
MissingDependencyError: If uvicorn is not installed.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
import uvicorn
|
|
184
|
+
|
|
185
|
+
return uvicorn
|
|
186
|
+
except ImportError as exc:
|
|
187
|
+
raise MissingDependencyError(
|
|
188
|
+
"uvicorn is required to run the ASGI server. "
|
|
189
|
+
"Install with 'pip install uvicorn[standard]'."
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
def run_uvicorn(self, app: Any, **uvicorn_kwargs: Any) -> None:
|
|
193
|
+
"""Launch Uvicorn to serve the ASGI application over the OpenZiti overlay.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
app: The FastAPI, Starlette, or ASGI application instance or import string.
|
|
197
|
+
**uvicorn_kwargs: Additional keyword arguments passed to uvicorn.run.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
MissingDependencyError: If openziti or uvicorn is not installed.
|
|
201
|
+
FileNotFoundError: If the identity file does not exist.
|
|
202
|
+
"""
|
|
203
|
+
uvicorn = self._require_uvicorn()
|
|
204
|
+
host = uvicorn_kwargs.pop("host", self.config.bind_host)
|
|
205
|
+
port = uvicorn_kwargs.pop("port", self.config.bind_port)
|
|
206
|
+
|
|
207
|
+
with self.bind_context(host=host, port=port):
|
|
208
|
+
uvicorn.run(app, host=host, port=port, **uvicorn_kwargs)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def openziti_bind(
|
|
212
|
+
identity_path: Path | str,
|
|
213
|
+
service_name: str,
|
|
214
|
+
bind_host: str = "127.0.0.1",
|
|
215
|
+
bind_port: int = 8000,
|
|
216
|
+
) -> contextlib.AbstractContextManager[None]:
|
|
217
|
+
"""Convenience context manager to bind current process sockets to an OpenZiti service.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
identity_path: Path to the enrolled OpenZiti identity credentials file.
|
|
221
|
+
service_name: Name of the OpenZiti service to host.
|
|
222
|
+
bind_host: Virtual bind host address. Defaults to '127.0.0.1'.
|
|
223
|
+
bind_port: Virtual bind port number. Defaults to 8000.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Context manager that intercepts socket operations for the given host and port.
|
|
227
|
+
"""
|
|
228
|
+
config = OpenZitiConfig(
|
|
229
|
+
identity_path=identity_path,
|
|
230
|
+
service_name=service_name,
|
|
231
|
+
bind_host=bind_host,
|
|
232
|
+
bind_port=bind_port,
|
|
233
|
+
)
|
|
234
|
+
adapter = OpenZitiASGIAdapter(config)
|
|
235
|
+
return adapter.bind_context()
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/ratelimit.py
RENAMED
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
from collections.abc import Callable
|
|
3
3
|
from functools import wraps
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
from fastapi import HTTPException, Request, status
|
|
7
7
|
|
|
8
|
-
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
9
8
|
from hexastack_core.ports.ratelimit import RateLimiterPort
|
|
9
|
+
from hexastack_core.utils import require_dependency
|
|
10
10
|
from hexastack_core.utils.context import get_user_context
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def get_remote_address(request: Request) -> str:
|
|
13
|
+
def get_remote_address(request: Request, trust_proxy: bool = False) -> str:
|
|
14
14
|
"""Extract client IP address from FastAPI request headers or client socket.
|
|
15
15
|
|
|
16
16
|
Args:
|
|
17
17
|
request: Incoming FastAPI Request object.
|
|
18
|
+
trust_proxy: Whether to trust X-Forwarded-For headers from upstream proxies.
|
|
18
19
|
|
|
19
20
|
Returns:
|
|
20
21
|
Client IP string.
|
|
21
22
|
"""
|
|
23
|
+
if trust_proxy:
|
|
24
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
25
|
+
if forwarded:
|
|
26
|
+
return forwarded.split(",")[0].strip()
|
|
27
|
+
|
|
28
|
+
if request.client and request.client.host:
|
|
29
|
+
return request.client.host
|
|
30
|
+
|
|
22
31
|
forwarded = request.headers.get("X-Forwarded-For")
|
|
23
32
|
if forwarded:
|
|
24
33
|
return forwarded.split(",")[0].strip()
|
|
25
|
-
|
|
26
|
-
return request.client.host
|
|
34
|
+
|
|
27
35
|
return "127.0.0.1"
|
|
28
36
|
|
|
29
37
|
|
|
30
|
-
def get_user_or_ip_key(request: Request) -> str:
|
|
38
|
+
def get_user_or_ip_key(request: Request, trust_proxy: bool = False) -> str:
|
|
31
39
|
"""Extract rate limit bucket key from ambient UserContext or fallback to client IP.
|
|
32
40
|
|
|
33
41
|
Args:
|
|
34
42
|
request: Incoming FastAPI Request object.
|
|
43
|
+
trust_proxy: Whether to trust X-Forwarded-For headers from upstream proxies.
|
|
35
44
|
|
|
36
45
|
Returns:
|
|
37
46
|
Rate limit identifier string (e.g. 'user:usr_123', 'tenant:t_456', or 'ip:1.2.3.4').
|
|
@@ -41,7 +50,7 @@ def get_user_or_ip_key(request: Request) -> str:
|
|
|
41
50
|
return f"user:{user_ctx.user_id}"
|
|
42
51
|
if user_ctx and user_ctx.tenant_id:
|
|
43
52
|
return f"tenant:{user_ctx.tenant_id}"
|
|
44
|
-
return f"ip:{get_remote_address(request)}"
|
|
53
|
+
return f"ip:{get_remote_address(request, trust_proxy=trust_proxy)}"
|
|
45
54
|
|
|
46
55
|
|
|
47
56
|
class SlowapiRateLimiterAdapter(RateLimiterPort):
|
|
@@ -68,14 +77,20 @@ class SlowapiRateLimiterAdapter(RateLimiterPort):
|
|
|
68
77
|
Raises:
|
|
69
78
|
MissingDependencyError: If slowapi or limits is not installed.
|
|
70
79
|
"""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
limits_storage = require_dependency(
|
|
81
|
+
"limits.storage",
|
|
82
|
+
extra="ratelimit",
|
|
83
|
+
package="hexastack-fastapi",
|
|
84
|
+
feature_name="Slowapi Rate Limiter Storage",
|
|
85
|
+
)
|
|
86
|
+
slowapi = require_dependency(
|
|
87
|
+
"slowapi",
|
|
88
|
+
extra="ratelimit",
|
|
89
|
+
package="hexastack-fastapi",
|
|
90
|
+
feature_name="Slowapi Rate Limiter",
|
|
91
|
+
)
|
|
92
|
+
storage_from_string = limits_storage.storage_from_string
|
|
93
|
+
Limiter = slowapi.Limiter
|
|
79
94
|
|
|
80
95
|
self._key_func = key_func or get_user_or_ip_key
|
|
81
96
|
self._default_limits: list[Any] = list(default_limits or ["100/minute"])
|
|
@@ -136,7 +151,10 @@ def _enforce_rate_limit(
|
|
|
136
151
|
) -> None:
|
|
137
152
|
"""Check rate limit quota against application rate limiter and raise HTTPException if exceeded."""
|
|
138
153
|
if request is None:
|
|
139
|
-
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"@rate_limit applied to '{fn_name}', but no Request instance was received. "
|
|
156
|
+
"Ensure the route declares 'request: Request' or FastAPI forwards the request."
|
|
157
|
+
)
|
|
140
158
|
limiter_port: RateLimiterPort | None = getattr(
|
|
141
159
|
request.app.state, "rate_limiter", None
|
|
142
160
|
)
|
|
@@ -178,6 +196,11 @@ def rate_limit(
|
|
|
178
196
|
|
|
179
197
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
180
198
|
fn_name = getattr(fn, "__name__", "endpoint")
|
|
199
|
+
sig = inspect.signature(fn)
|
|
200
|
+
has_req_param = any(
|
|
201
|
+
p.annotation is Request or p.name == "request"
|
|
202
|
+
for p in sig.parameters.values()
|
|
203
|
+
)
|
|
181
204
|
|
|
182
205
|
if inspect.iscoroutinefunction(fn):
|
|
183
206
|
|
|
@@ -185,16 +208,44 @@ def rate_limit(
|
|
|
185
208
|
async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
186
209
|
req = _extract_request(args, kwargs)
|
|
187
210
|
_enforce_rate_limit(req, fn_name, limit, key_extractor, detail)
|
|
211
|
+
if not has_req_param:
|
|
212
|
+
kwargs.pop("request", None)
|
|
188
213
|
return await fn(*args, **kwargs)
|
|
189
214
|
|
|
215
|
+
if not has_req_param:
|
|
216
|
+
params = list(sig.parameters.values())
|
|
217
|
+
params.append(
|
|
218
|
+
inspect.Parameter(
|
|
219
|
+
"request",
|
|
220
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
221
|
+
annotation=Request,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
cast("Any", async_wrapped).__signature__ = sig.replace(
|
|
225
|
+
parameters=params
|
|
226
|
+
)
|
|
227
|
+
|
|
190
228
|
return async_wrapped
|
|
191
229
|
|
|
192
230
|
@wraps(fn)
|
|
193
231
|
def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
194
232
|
req = _extract_request(args, kwargs)
|
|
195
233
|
_enforce_rate_limit(req, fn_name, limit, key_extractor, detail)
|
|
234
|
+
if not has_req_param:
|
|
235
|
+
kwargs.pop("request", None)
|
|
196
236
|
return fn(*args, **kwargs)
|
|
197
237
|
|
|
238
|
+
if not has_req_param:
|
|
239
|
+
params = list(sig.parameters.values())
|
|
240
|
+
params.append(
|
|
241
|
+
inspect.Parameter(
|
|
242
|
+
"request",
|
|
243
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
244
|
+
annotation=Request,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
cast("Any", sync_wrapped).__signature__ = sig.replace(parameters=params)
|
|
248
|
+
|
|
198
249
|
return sync_wrapped
|
|
199
250
|
|
|
200
251
|
return decorator
|
|
@@ -12,7 +12,7 @@ from starlette.background import BackgroundTask
|
|
|
12
12
|
from starlette.types import Receive, Scope, Send
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
@dataclass(frozen=True)
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
16
|
class ServerSentEvent:
|
|
17
17
|
"""Represents an individual Server-Sent Event frame.
|
|
18
18
|
|
|
@@ -60,6 +60,30 @@ class ServerSentEvent:
|
|
|
60
60
|
return "".join(buffer).encode("utf-8")
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def _encode_sse_item(item: Any) -> bytes:
|
|
64
|
+
"""Encode an SSE stream item to bytes."""
|
|
65
|
+
if isinstance(item, ServerSentEvent):
|
|
66
|
+
return item.encode()
|
|
67
|
+
if isinstance(item, bytes):
|
|
68
|
+
return item
|
|
69
|
+
if isinstance(item, str):
|
|
70
|
+
return ServerSentEvent(data=item).encode()
|
|
71
|
+
import json
|
|
72
|
+
|
|
73
|
+
return ServerSentEvent(data=json.dumps(item)).encode()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _cleanup_producer(producer_task: asyncio.Task[None]) -> None:
|
|
77
|
+
"""Safely cancel or check producer task on stream termination."""
|
|
78
|
+
if not producer_task.done():
|
|
79
|
+
producer_task.cancel()
|
|
80
|
+
_ = await asyncio.gather(producer_task, return_exceptions=True)
|
|
81
|
+
elif not producer_task.cancelled():
|
|
82
|
+
exc = producer_task.exception()
|
|
83
|
+
if exc is not None:
|
|
84
|
+
raise exc
|
|
85
|
+
|
|
86
|
+
|
|
63
87
|
class EventSourceResponse(Response):
|
|
64
88
|
"""FastAPI/Starlette Response subclass streaming Server-Sent Events over HTTP.
|
|
65
89
|
|
|
@@ -106,24 +130,14 @@ class EventSourceResponse(Response):
|
|
|
106
130
|
|
|
107
131
|
async def _stream_with_ping(self) -> AsyncIterator[bytes]:
|
|
108
132
|
"""Wrap body_iterator with periodic keep-alive pings."""
|
|
109
|
-
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
133
|
+
queue: asyncio.Queue[bytes | Exception | None] = asyncio.Queue(maxsize=100)
|
|
110
134
|
|
|
111
135
|
async def _producer() -> None:
|
|
112
136
|
try:
|
|
113
137
|
async for item in self.body_iterator:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
await queue.put(item)
|
|
118
|
-
elif isinstance(item, str):
|
|
119
|
-
await queue.put(ServerSentEvent(data=item).encode())
|
|
120
|
-
else:
|
|
121
|
-
import json
|
|
122
|
-
|
|
123
|
-
await queue.put(ServerSentEvent(data=json.dumps(item)).encode())
|
|
124
|
-
except Exception:
|
|
125
|
-
await queue.put(None)
|
|
126
|
-
raise
|
|
138
|
+
await queue.put(_encode_sse_item(item))
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
await queue.put(exc)
|
|
127
141
|
finally:
|
|
128
142
|
await queue.put(None)
|
|
129
143
|
|
|
@@ -136,20 +150,19 @@ class EventSourceResponse(Response):
|
|
|
136
150
|
chunk = await asyncio.wait_for(
|
|
137
151
|
queue.get(), timeout=self.ping_interval
|
|
138
152
|
)
|
|
139
|
-
if chunk is None:
|
|
140
|
-
break
|
|
141
|
-
yield chunk
|
|
142
153
|
except TimeoutError:
|
|
143
154
|
yield ServerSentEvent(comment="ping").encode()
|
|
155
|
+
continue
|
|
144
156
|
else:
|
|
145
157
|
chunk = await queue.get()
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
|
|
159
|
+
if chunk is None:
|
|
160
|
+
break
|
|
161
|
+
if isinstance(chunk, Exception):
|
|
162
|
+
raise chunk
|
|
163
|
+
yield chunk
|
|
149
164
|
finally:
|
|
150
|
-
|
|
151
|
-
producer_task.cancel()
|
|
152
|
-
_ = await asyncio.gather(producer_task, return_exceptions=True)
|
|
165
|
+
await _cleanup_producer(producer_task)
|
|
153
166
|
|
|
154
167
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
155
168
|
"""ASGI response streaming callable."""
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/websockets.py
RENAMED
|
@@ -195,11 +195,16 @@ class WebSocketCqrsBridge:
|
|
|
195
195
|
if asyncio.iscoroutine(cb_res):
|
|
196
196
|
await cb_res
|
|
197
197
|
except Exception as exc:
|
|
198
|
+
err_msg = (
|
|
199
|
+
str(exc)
|
|
200
|
+
if isinstance(exc, (ValueError, TypeError, KeyError))
|
|
201
|
+
else "Internal server error."
|
|
202
|
+
)
|
|
198
203
|
await websocket.send_json(
|
|
199
204
|
{
|
|
200
205
|
"status": "error",
|
|
201
206
|
"type": type_tag,
|
|
202
|
-
"error":
|
|
207
|
+
"error": err_msg,
|
|
203
208
|
}
|
|
204
209
|
)
|
|
205
210
|
except WebSocketDisconnect:
|
|
@@ -64,6 +64,10 @@ class RateLimitConfig(BaseModel):
|
|
|
64
64
|
default="memory://",
|
|
65
65
|
description="Backend storage URI: 'memory://' or 'redis://localhost:6379'.",
|
|
66
66
|
)
|
|
67
|
+
trust_proxy_headers: bool = Field(
|
|
68
|
+
default=False,
|
|
69
|
+
description="Whether to trust X-Forwarded-For headers for client IP extraction.",
|
|
70
|
+
)
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
class ZensicalDocsConfig(BaseModel):
|
|
@@ -96,6 +100,10 @@ class HexastackFastApiConfig(BaseModel):
|
|
|
96
100
|
correlation_header: str = Field(default="X-Correlation-ID")
|
|
97
101
|
user_header: str = Field(default="X-User-ID")
|
|
98
102
|
tenant_header: str = Field(default="X-Tenant-ID")
|
|
103
|
+
trust_identity_headers: bool = Field(
|
|
104
|
+
default=False,
|
|
105
|
+
description="Whether to trust incoming user and tenant identity headers from upstream clients.",
|
|
106
|
+
)
|
|
99
107
|
cors: CorsConfig = Field(default_factory=CorsConfig)
|
|
100
108
|
health: HealthConfig = Field(default_factory=HealthConfig)
|
|
101
109
|
logging: RequestLoggingConfig = Field(default_factory=RequestLoggingConfig)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
from hexastack_core.domain import HexastackError
|
|
8
|
+
from hexastack_core.infra.registries.exception import ExceptionRegistry
|
|
9
|
+
from hexastack_core.ports.logging import LoggingPort
|
|
10
|
+
from hexastack_core.utils.context import get_correlation_id
|
|
11
|
+
|
|
12
|
+
_fallback_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"register_exception_handlers",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _infer_status_code(exc: HexastackError) -> int:
|
|
20
|
+
"""Infer HTTP status code from domain exception class name.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
exc: Domain error instance.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Standard HTTP status code matching error semantics.
|
|
27
|
+
"""
|
|
28
|
+
exc_name = exc.__class__.__name__.lower()
|
|
29
|
+
if "notfound" in exc_name:
|
|
30
|
+
return 404
|
|
31
|
+
if "conflict" in exc_name or "duplicate" in exc_name:
|
|
32
|
+
return 409
|
|
33
|
+
if any(k in exc_name for k in ("permission", "forbidden", "accessdenied")):
|
|
34
|
+
return 403
|
|
35
|
+
if any(
|
|
36
|
+
k in exc_name for k in ("unauthorized", "unauthenticated", "invalidcredential")
|
|
37
|
+
):
|
|
38
|
+
return 401
|
|
39
|
+
if "validation" in exc_name:
|
|
40
|
+
return 422
|
|
41
|
+
return 400
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_logger(app: FastAPI, explicit: LoggingPort | None) -> LoggingPort | None:
|
|
45
|
+
"""Resolve LoggingPort from explicit argument or FastAPI application container.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
app: Target FastAPI application.
|
|
49
|
+
explicit: Optional explicitly supplied LoggingPort instance.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Resolved LoggingPort or None.
|
|
53
|
+
"""
|
|
54
|
+
if explicit is not None:
|
|
55
|
+
return explicit
|
|
56
|
+
container = getattr(app.state, "container", None)
|
|
57
|
+
if container is not None and LoggingPort in container:
|
|
58
|
+
return container.resolve(LoggingPort)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _forward_to_sentry(
|
|
63
|
+
request: Request,
|
|
64
|
+
exc: Exception,
|
|
65
|
+
correlation_id: str | None,
|
|
66
|
+
active_logger: LoggingPort | None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Forward unhandled exception context to Sentry SDK if installed and configured.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request: FastAPI HTTP request instance.
|
|
72
|
+
exc: Unhandled exception.
|
|
73
|
+
correlation_id: Optional correlation trace identifier.
|
|
74
|
+
active_logger: Optional LoggingPort for diagnostic telemetry.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
import sentry_sdk
|
|
78
|
+
|
|
79
|
+
with sentry_sdk.push_scope() as scope:
|
|
80
|
+
scope.set_tag("path", request.url.path)
|
|
81
|
+
scope.set_tag("method", request.method)
|
|
82
|
+
if correlation_id:
|
|
83
|
+
scope.set_tag("correlation_id", correlation_id)
|
|
84
|
+
sentry_sdk.capture_exception(exc)
|
|
85
|
+
except Exception as sentry_exc: # noqa: BLE001
|
|
86
|
+
if active_logger is not None:
|
|
87
|
+
active_logger.debug(f"Failed to forward exception to Sentry: {sentry_exc}")
|
|
88
|
+
else:
|
|
89
|
+
_fallback_logger.debug(
|
|
90
|
+
"Failed to forward exception to Sentry: %s", sentry_exc
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def register_exception_handlers(
|
|
95
|
+
app: FastAPI,
|
|
96
|
+
exception_registry: ExceptionRegistry | None = None,
|
|
97
|
+
logger: LoggingPort | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Register unified exception handlers translating domain errors into HTTP responses.
|
|
100
|
+
|
|
101
|
+
Notes/Architectural Intent:
|
|
102
|
+
Intercepts HexastackError domain exceptions and unhandled system errors.
|
|
103
|
+
Maps domain errors to standardized HTTP status codes (400, 401, 403, 404, 409, 422)
|
|
104
|
+
and forwards unhandled 500 exceptions with correlation ID and request tags to Sentry if active.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
app: Target FastAPI application instance.
|
|
108
|
+
exception_registry: Optional ExceptionRegistry for custom exception mappings.
|
|
109
|
+
logger: Optional LoggingPort instance to receive exception forwarding diagnostics.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
None.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
None.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
@app.exception_handler(HexastackError)
|
|
119
|
+
async def hexastack_error_handler(
|
|
120
|
+
request: Request, exc: HexastackError
|
|
121
|
+
) -> JSONResponse:
|
|
122
|
+
"""Handle HexastackError subclasses with status code inference and correlation metadata."""
|
|
123
|
+
if exception_registry is not None and type(exc) in exception_registry:
|
|
124
|
+
mapped = exception_registry.handle(exc)
|
|
125
|
+
if isinstance(mapped, dict) and "status_code" in mapped:
|
|
126
|
+
status = mapped.pop("status_code")
|
|
127
|
+
return JSONResponse(status_code=status, content=mapped)
|
|
128
|
+
return JSONResponse(status_code=400, content=mapped)
|
|
129
|
+
|
|
130
|
+
status_code = _infer_status_code(exc)
|
|
131
|
+
content: dict[str, Any] = {
|
|
132
|
+
"error": str(exc),
|
|
133
|
+
"error_type": exc.__class__.__name__,
|
|
134
|
+
"correlation_id": get_correlation_id(),
|
|
135
|
+
}
|
|
136
|
+
return JSONResponse(status_code=status_code, content=content)
|
|
137
|
+
|
|
138
|
+
@app.exception_handler(Exception)
|
|
139
|
+
async def unhandled_exception_handler(
|
|
140
|
+
request: Request, exc: Exception
|
|
141
|
+
) -> JSONResponse:
|
|
142
|
+
"""Handle unhandled server exceptions, push context to Sentry if available, and return 500."""
|
|
143
|
+
correlation_id = get_correlation_id()
|
|
144
|
+
active_logger = _resolve_logger(app, logger)
|
|
145
|
+
if active_logger is not None:
|
|
146
|
+
active_logger.error(f"Unhandled server exception: {exc}", exc=exc)
|
|
147
|
+
else:
|
|
148
|
+
_fallback_logger.error("Unhandled server exception: %s", exc, exc_info=exc)
|
|
149
|
+
_forward_to_sentry(request, exc, correlation_id, active_logger)
|
|
150
|
+
|
|
151
|
+
content: dict[str, Any] = {
|
|
152
|
+
"error": "Internal server error",
|
|
153
|
+
"error_type": exc.__class__.__name__,
|
|
154
|
+
"correlation_id": correlation_id,
|
|
155
|
+
}
|
|
156
|
+
return JSONResponse(status_code=500, content=content)
|
|
@@ -37,6 +37,7 @@ class CorrelationHttpMiddleware:
|
|
|
37
37
|
self._correlation_header_name = cfg.correlation_header
|
|
38
38
|
self._user_header = cfg.user_header.lower().encode("latin1")
|
|
39
39
|
self._tenant_header = cfg.tenant_header.lower().encode("latin1")
|
|
40
|
+
self._trust_identity_headers = cfg.trust_identity_headers
|
|
40
41
|
|
|
41
42
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
42
43
|
"""Process incoming ASGI request, inject context tokens, and wrap response.
|
|
@@ -64,16 +65,17 @@ class CorrelationHttpMiddleware:
|
|
|
64
65
|
correlation_id = raw_cid.decode("latin1") if raw_cid else new_correlation_id()
|
|
65
66
|
corr_token = set_correlation_id(correlation_id)
|
|
66
67
|
|
|
67
|
-
# 2. Extract User / Tenant context
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
# 2. Extract User / Tenant context (only if explicitly trusted upstream)
|
|
69
|
+
user_context = None
|
|
70
|
+
if self._trust_identity_headers:
|
|
71
|
+
raw_uid = header_map.get(self._user_header)
|
|
72
|
+
raw_tid = header_map.get(self._tenant_header)
|
|
73
|
+
if raw_uid or raw_tid:
|
|
74
|
+
user_context = UserContext(
|
|
75
|
+
user_id=raw_uid.decode("latin1") if raw_uid else "anonymous",
|
|
76
|
+
tenant_id=raw_tid.decode("latin1") if raw_tid else None,
|
|
77
|
+
)
|
|
78
|
+
user_token = set_user_context(user_context)
|
|
77
79
|
|
|
78
80
|
# 3. Intercept response to inject correlation header
|
|
79
81
|
async def send_wrapper(message: Message) -> None:
|
|
@@ -91,8 +93,7 @@ class CorrelationHttpMiddleware:
|
|
|
91
93
|
await self._app(scope, receive, send_wrapper)
|
|
92
94
|
finally:
|
|
93
95
|
correlation_id_ctx.reset(corr_token)
|
|
94
|
-
|
|
95
|
-
user_ctx.reset(user_token)
|
|
96
|
+
user_ctx.reset(user_token)
|
|
96
97
|
|
|
97
98
|
|
|
98
99
|
__all__ = [
|
|
@@ -98,9 +98,17 @@ class HttpMetricsMiddleware:
|
|
|
98
98
|
await self._app(scope, receive, send_wrapper)
|
|
99
99
|
finally:
|
|
100
100
|
duration = time.perf_counter() - start_time
|
|
101
|
+
route = scope.get("route")
|
|
102
|
+
if route is not None and hasattr(route, "path_format"):
|
|
103
|
+
metric_path = route.path_format
|
|
104
|
+
elif status_code == 404:
|
|
105
|
+
metric_path = "unmatched"
|
|
106
|
+
else:
|
|
107
|
+
metric_path = path
|
|
108
|
+
|
|
101
109
|
labels = {
|
|
102
110
|
"method": method,
|
|
103
|
-
"path":
|
|
111
|
+
"path": metric_path,
|
|
104
112
|
"status_code": str(status_code),
|
|
105
113
|
}
|
|
106
114
|
metrics.increment_counter(
|
|
@@ -112,7 +120,7 @@ class HttpMetricsMiddleware:
|
|
|
112
120
|
metrics.record_histogram(
|
|
113
121
|
"http_request_duration_seconds",
|
|
114
122
|
value=duration,
|
|
115
|
-
labels={"method": method, "path":
|
|
123
|
+
labels={"method": method, "path": metric_path},
|
|
116
124
|
description="HTTP request duration distribution in seconds",
|
|
117
125
|
)
|
|
118
126
|
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/recorder.py
RENAMED
|
@@ -7,7 +7,8 @@ import time
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from hexastack_core.
|
|
10
|
+
from hexastack_core.utils import require_dependency
|
|
11
|
+
from hexastack_core.utils.fs import atomic_write_text
|
|
11
12
|
from hexastack_fastapi.testing.cursor import VIRTUAL_CURSOR_SCRIPT
|
|
12
13
|
|
|
13
14
|
CAPTION_BANNER_SCRIPT = """
|
|
@@ -70,13 +71,12 @@ class DemoNarrator:
|
|
|
70
71
|
Raises:
|
|
71
72
|
MissingDependencyError: If `playwright` is not installed.
|
|
72
73
|
"""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
) from e
|
|
74
|
+
require_dependency(
|
|
75
|
+
"playwright",
|
|
76
|
+
extra="testing",
|
|
77
|
+
package="hexastack-fastapi",
|
|
78
|
+
feature_name="Feature Demo Recording",
|
|
79
|
+
)
|
|
80
80
|
|
|
81
81
|
self.page = page
|
|
82
82
|
self.output_name = output_name
|
|
@@ -220,7 +220,7 @@ class DemoNarrator:
|
|
|
220
220
|
out_dir = Path("docs/assets/demos")
|
|
221
221
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
222
222
|
vtt_path = out_dir / f"{self.output_name or 'demo'}.vtt"
|
|
223
|
-
vtt_path
|
|
223
|
+
atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
|
|
224
224
|
return vtt_path
|
|
225
225
|
|
|
226
226
|
@staticmethod
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
from fastapi import FastAPI, Request
|
|
4
|
-
from fastapi.responses import JSONResponse
|
|
5
|
-
|
|
6
|
-
from hexastack_core.domain import HexastackError
|
|
7
|
-
from hexastack_core.infra.registries.exception import ExceptionRegistry
|
|
8
|
-
from hexastack_core.utils.context import get_correlation_id
|
|
9
|
-
|
|
10
|
-
__all__ = [
|
|
11
|
-
"register_exception_handlers",
|
|
12
|
-
]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def register_exception_handlers(
|
|
16
|
-
app: FastAPI,
|
|
17
|
-
exception_registry: ExceptionRegistry | None = None,
|
|
18
|
-
) -> None:
|
|
19
|
-
"""Register unified exception handlers translating domain errors into HTTP responses.
|
|
20
|
-
|
|
21
|
-
Args:
|
|
22
|
-
app: Target FastAPI application instance.
|
|
23
|
-
exception_registry: Optional ExceptionRegistry for custom exception mappings.
|
|
24
|
-
|
|
25
|
-
Returns:
|
|
26
|
-
None.
|
|
27
|
-
|
|
28
|
-
Raises:
|
|
29
|
-
None.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
@app.exception_handler(HexastackError)
|
|
33
|
-
async def hexastack_error_handler(
|
|
34
|
-
request: Request, exc: HexastackError
|
|
35
|
-
) -> JSONResponse:
|
|
36
|
-
"""Handle HexastackError subclasses with status code inference and correlation metadata."""
|
|
37
|
-
if exception_registry is not None and type(exc) in exception_registry:
|
|
38
|
-
mapped = exception_registry.handle(exc)
|
|
39
|
-
if isinstance(mapped, dict) and "status_code" in mapped:
|
|
40
|
-
status = mapped.pop("status_code")
|
|
41
|
-
return JSONResponse(status_code=status, content=mapped)
|
|
42
|
-
return JSONResponse(status_code=400, content=mapped)
|
|
43
|
-
|
|
44
|
-
status_code = 400
|
|
45
|
-
exc_name = exc.__class__.__name__.lower()
|
|
46
|
-
if "notfound" in exc_name:
|
|
47
|
-
status_code = 404
|
|
48
|
-
elif "conflict" in exc_name or "duplicate" in exc_name:
|
|
49
|
-
status_code = 409
|
|
50
|
-
elif (
|
|
51
|
-
"permission" in exc_name
|
|
52
|
-
or "forbidden" in exc_name
|
|
53
|
-
or "accessdenied" in exc_name
|
|
54
|
-
):
|
|
55
|
-
status_code = 403
|
|
56
|
-
elif (
|
|
57
|
-
"unauthorized" in exc_name
|
|
58
|
-
or "unauthenticated" in exc_name
|
|
59
|
-
or "invalidcredential" in exc_name
|
|
60
|
-
):
|
|
61
|
-
status_code = 401
|
|
62
|
-
elif "validation" in exc_name:
|
|
63
|
-
status_code = 422
|
|
64
|
-
|
|
65
|
-
content: dict[str, Any] = {
|
|
66
|
-
"error": str(exc),
|
|
67
|
-
"error_type": exc.__class__.__name__,
|
|
68
|
-
"correlation_id": get_correlation_id(),
|
|
69
|
-
}
|
|
70
|
-
return JSONResponse(status_code=status_code, content=content)
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/db_session.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/health.py
RENAMED
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/metrics.py
RENAMED
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/routing.py
RENAMED
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/domain/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/autodiscovery.py
RENAMED
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/bootstrap.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/decorators.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|