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.
Files changed (35) hide show
  1. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/PKG-INFO +3 -1
  2. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/pyproject.toml +2 -1
  3. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/pyproject.toml.orig +4 -1
  4. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/__init__.py +8 -0
  5. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/dependencies.py +14 -9
  6. hexastack_fastapi-0.9.0/src/hexastack_fastapi/adapters/openziti.py +235 -0
  7. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/ratelimit.py +67 -16
  8. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/sse.py +37 -24
  9. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/websockets.py +6 -1
  10. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/domain/config.py +8 -0
  11. hexastack_fastapi-0.9.0/src/hexastack_fastapi/infra/exception_handlers.py +156 -0
  12. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/correlation.py +13 -12
  13. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/metrics.py +10 -2
  14. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/recorder.py +9 -9
  15. hexastack_fastapi-0.7.0/src/hexastack_fastapi/infra/exception_handlers.py +0 -70
  16. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/README.md +0 -0
  17. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/__init__.py +0 -0
  18. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
  19. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
  20. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/health.py +0 -0
  21. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/metrics.py +0 -0
  22. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/adapters/routing.py +0 -0
  23. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/domain/__init__.py +0 -0
  24. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/__init__.py +0 -0
  25. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/app.py +0 -0
  26. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/autodiscovery.py +0 -0
  27. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/bootstrap.py +0 -0
  28. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/config.py +0 -0
  29. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/decorators.py +0 -0
  30. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/__init__.py +0 -0
  31. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
  32. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/py.typed +0 -0
  33. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
  34. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.9.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
  35. {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.7.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
  ![hexastack-fastapi](../../docs/assets/static/logos/packages/hexastack_fastapi.png)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexastack-fastapi"
3
- version = "0.7.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.7.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"
@@ -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",
@@ -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 not None:
244
- key = key_extractor(request)
245
- if not limiter.hit(key, limit):
246
- reset_sec = limiter.get_reset_window(key, limit)
247
- raise HTTPException(
248
- status_code=status.HTTP_429_TOO_MANY_REQUESTS,
249
- detail=detail or f"Rate limit exceeded: {limit}.",
250
- headers={"Retry-After": str(reset_sec)},
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()
@@ -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
- if request.client and request.client.host:
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
- try:
72
- from limits.storage import storage_from_string
73
- from slowapi import Limiter
74
- except ImportError as e:
75
- raise MissingDependencyError(
76
- "slowapi and limits are required for SlowapiRateLimiterAdapter. "
77
- "Install with 'pip install hexastack-fastapi[ratelimit]'."
78
- ) from e
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
- return
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
- if isinstance(item, ServerSentEvent):
115
- await queue.put(item.encode())
116
- elif isinstance(item, bytes):
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
- if chunk is None:
147
- break
148
- yield chunk
158
+
159
+ if chunk is None:
160
+ break
161
+ if isinstance(chunk, Exception):
162
+ raise chunk
163
+ yield chunk
149
164
  finally:
150
- if not producer_task.done():
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."""
@@ -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": str(exc),
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
- raw_uid = header_map.get(self._user_header)
69
- raw_tid = header_map.get(self._tenant_header)
70
- user_token = None
71
- if raw_uid or raw_tid:
72
- user_context = UserContext(
73
- user_id=raw_uid.decode("latin1") if raw_uid else "anonymous",
74
- tenant_id=raw_tid.decode("latin1") if raw_tid else None,
75
- )
76
- user_token = set_user_context(user_context)
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
- if user_token is not None:
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": 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": path},
123
+ labels={"method": method, "path": metric_path},
116
124
  description="HTTP request duration distribution in seconds",
117
125
  )
118
126
 
@@ -7,7 +7,8 @@ import time
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from hexastack_core.domain.exceptions import MissingDependencyError
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
- try:
74
- import playwright # noqa: F401
75
- except ImportError as e:
76
- raise MissingDependencyError(
77
- "Playwright is required to record feature demos. "
78
- "Install with 'pip install hexastack-fastapi[testing]' or 'pip install playwright'."
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.write_text("\n".join(vtt_lines), encoding="utf-8")
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)