hexastack-fastapi 0.7.0__tar.gz → 0.8.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.8.0}/PKG-INFO +3 -1
  2. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/pyproject.toml +2 -1
  3. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/pyproject.toml.orig +4 -1
  4. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/__init__.py +8 -0
  5. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/dependencies.py +14 -9
  6. hexastack_fastapi-0.8.0/src/hexastack_fastapi/adapters/openziti.py +237 -0
  7. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/ratelimit.py +52 -7
  8. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/sse.py +37 -24
  9. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/websockets.py +6 -1
  10. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/domain/config.py +8 -0
  11. hexastack_fastapi-0.8.0/src/hexastack_fastapi/infra/exception_handlers.py +156 -0
  12. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/middleware/correlation.py +13 -12
  13. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/middleware/metrics.py +10 -2
  14. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/testing/recorder.py +2 -1
  15. hexastack_fastapi-0.7.0/src/hexastack_fastapi/infra/exception_handlers.py +0 -70
  16. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/README.md +0 -0
  17. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/__init__.py +0 -0
  18. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
  19. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
  20. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/health.py +0 -0
  21. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/metrics.py +0 -0
  22. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/adapters/routing.py +0 -0
  23. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/domain/__init__.py +0 -0
  24. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/__init__.py +0 -0
  25. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/app.py +0 -0
  26. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/autodiscovery.py +0 -0
  27. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/bootstrap.py +0 -0
  28. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/config.py +0 -0
  29. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/decorators.py +0 -0
  30. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/middleware/__init__.py +0 -0
  31. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
  32. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/py.typed +0 -0
  33. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
  34. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
  35. {hexastack_fastapi-0.7.0 → hexastack_fastapi-0.8.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.8.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.8.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.8.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,237 @@
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
+
20
+ __all__ = [
21
+ "openziti_bind",
22
+ "OpenZitiASGIAdapter",
23
+ "OpenZitiConfig",
24
+ ]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class OpenZitiConfig:
29
+ """Configuration for hosting an ASGI application over the OpenZiti overlay network.
30
+
31
+ Args:
32
+ identity_path: Path to the enrolled OpenZiti identity JSON credentials file.
33
+ service_name: Name of the OpenZiti service configured with Bind permissions.
34
+ bind_host: Virtual hostname or IP for the intercepted socket. Defaults to '127.0.0.1'.
35
+ bind_port: Virtual port for the intercepted socket. Defaults to 8000.
36
+ enabled: Whether OpenZiti overlay hosting is active. Defaults to True.
37
+
38
+ Notes/Architectural Intent:
39
+ The bind_host and bind_port do not open listening kernel ports on the host.
40
+ They serve as the socket binding hook intercepted by OpenZiti's monkeypatch layer.
41
+ """
42
+
43
+ identity_path: Path | str
44
+ service_name: str
45
+ bind_host: str = "127.0.0.1"
46
+ bind_port: int = 8000
47
+ enabled: bool = True
48
+
49
+ def resolved_identity_path(self) -> Path:
50
+ """Resolve and validate the OpenZiti identity credentials file path.
51
+
52
+ Returns:
53
+ Resolved Path object pointing to the identity file.
54
+
55
+ Raises:
56
+ FileNotFoundError: If the identity credentials file does not exist on disk.
57
+ """
58
+ path = Path(self.identity_path).resolve()
59
+ if not path.is_file():
60
+ raise FileNotFoundError(
61
+ f"OpenZiti identity credentials file not found: {self.identity_path}"
62
+ )
63
+ return path
64
+
65
+
66
+ class OpenZitiASGIAdapter:
67
+ """ASGI server adapter enabling zero-trust hosting on the OpenZiti overlay fabric.
68
+
69
+ Notes/Architectural Intent:
70
+ Wraps Python socket operations via OpenZiti's monkeypatching infrastructure to bind
71
+ ASGI servers (e.g. Uvicorn) directly to an OpenZiti service without opening listening
72
+ network sockets on the host OS.
73
+ """
74
+
75
+ def __init__(self, config: OpenZitiConfig) -> None:
76
+ """Initialize the OpenZiti ASGI adapter.
77
+
78
+ Args:
79
+ config: OpenZitiConfig specifying identity path, service name, and virtual bind target.
80
+ """
81
+ self.config = config
82
+
83
+ @staticmethod
84
+ def _require_openziti() -> Any:
85
+ """Dynamically load openziti module or raise MissingDependencyError.
86
+
87
+ Returns:
88
+ The imported openziti module.
89
+
90
+ Raises:
91
+ MissingDependencyError: If openziti is not installed.
92
+ """
93
+ try:
94
+ import openziti
95
+
96
+ return openziti
97
+ except ImportError as exc:
98
+ raise MissingDependencyError(
99
+ "openziti is required for OpenZiti zero-trust networking. "
100
+ "Install with 'pip install hexastack-fastapi[ziti]' or 'pip install openziti'."
101
+ ) from exc
102
+
103
+ def get_bindings(
104
+ self, host: str | None = None, port: int | None = None
105
+ ) -> dict[tuple[str, int], dict[str, Any]]:
106
+ """Construct the OpenZiti socket binding mapping.
107
+
108
+ Args:
109
+ host: Optional host address override. Defaults to config.bind_host.
110
+ port: Optional port number override. Defaults to config.bind_port.
111
+
112
+ Returns:
113
+ Dictionary mapping (host, port) to OpenZiti context and service options.
114
+
115
+ Raises:
116
+ FileNotFoundError: If the identity credentials file does not exist.
117
+ """
118
+ identity_file = self.config.resolved_identity_path()
119
+ target_host = host or self.config.bind_host
120
+ target_port = port if port is not None else self.config.bind_port
121
+ return {
122
+ (target_host, target_port): {
123
+ "ztx": str(identity_file),
124
+ "service": self.config.service_name,
125
+ }
126
+ }
127
+
128
+ @contextlib.contextmanager
129
+ def bind_context(
130
+ self, host: str | None = None, port: int | None = None
131
+ ) -> Generator[None]:
132
+ """Context manager activating OpenZiti overlay socket interception.
133
+
134
+ Args:
135
+ host: Optional bind host override.
136
+ port: Optional bind port override.
137
+
138
+ Yields:
139
+ None when socket interception is active.
140
+
141
+ Raises:
142
+ MissingDependencyError: If openziti is not installed.
143
+ FileNotFoundError: If the identity file does not exist.
144
+
145
+ Notes/Architectural Intent:
146
+ Activates openziti.monkeypatch with configured bindings upon entry,
147
+ restoring original Python socket behaviors upon exit.
148
+ """
149
+ if not self.config.enabled:
150
+ yield
151
+ return
152
+
153
+ openziti = self._require_openziti()
154
+ bindings = self.get_bindings(host=host, port=port)
155
+ with openziti.monkeypatch(bindings=bindings):
156
+ yield
157
+
158
+ def zitify(self, func: Callable[..., Any]) -> Callable[..., Any]:
159
+ """Decorate a server runner function to execute within the OpenZiti overlay.
160
+
161
+ Args:
162
+ func: Target callable to decorate (e.g. uvicorn.run or server startup).
163
+
164
+ Returns:
165
+ Decorated callable wrapped with OpenZiti overlay socket interception.
166
+ """
167
+
168
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
169
+ with self.bind_context():
170
+ return func(*args, **kwargs)
171
+
172
+ return wrapped
173
+
174
+ @staticmethod
175
+ def _require_uvicorn() -> Any:
176
+ """Dynamically load uvicorn module or raise MissingDependencyError.
177
+
178
+ Returns:
179
+ The imported uvicorn module.
180
+
181
+ Raises:
182
+ MissingDependencyError: If uvicorn is not installed.
183
+ """
184
+ try:
185
+ import uvicorn
186
+
187
+ return uvicorn
188
+ except ImportError as exc:
189
+ raise MissingDependencyError(
190
+ "uvicorn is required to run the ASGI server. "
191
+ "Install with 'pip install uvicorn[standard]'."
192
+ ) from exc
193
+
194
+ def run_uvicorn(self, app: Any, **uvicorn_kwargs: Any) -> None:
195
+ """Launch Uvicorn to serve the ASGI application over the OpenZiti overlay.
196
+
197
+ Args:
198
+ app: The FastAPI, Starlette, or ASGI application instance or import string.
199
+ **uvicorn_kwargs: Additional keyword arguments passed to uvicorn.run.
200
+
201
+ Raises:
202
+ MissingDependencyError: If openziti or uvicorn is not installed.
203
+ FileNotFoundError: If the identity file does not exist.
204
+ """
205
+ uvicorn = self._require_uvicorn()
206
+ host = uvicorn_kwargs.pop("host", self.config.bind_host)
207
+ port = uvicorn_kwargs.pop("port", self.config.bind_port)
208
+
209
+ with self.bind_context(host=host, port=port):
210
+ uvicorn.run(app, host=host, port=port, **uvicorn_kwargs)
211
+
212
+
213
+ def openziti_bind(
214
+ identity_path: Path | str,
215
+ service_name: str,
216
+ bind_host: str = "127.0.0.1",
217
+ bind_port: int = 8000,
218
+ ) -> contextlib.AbstractContextManager[None]:
219
+ """Convenience context manager to bind current process sockets to an OpenZiti service.
220
+
221
+ Args:
222
+ identity_path: Path to the enrolled OpenZiti identity credentials file.
223
+ service_name: Name of the OpenZiti service to host.
224
+ bind_host: Virtual bind host address. Defaults to '127.0.0.1'.
225
+ bind_port: Virtual bind port number. Defaults to 8000.
226
+
227
+ Returns:
228
+ Context manager that intercepts socket operations for the given host and port.
229
+ """
230
+ config = OpenZitiConfig(
231
+ identity_path=identity_path,
232
+ service_name=service_name,
233
+ bind_host=bind_host,
234
+ bind_port=bind_port,
235
+ )
236
+ adapter = OpenZitiASGIAdapter(config)
237
+ return adapter.bind_context()
@@ -1,7 +1,7 @@
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
 
@@ -10,28 +10,37 @@ from hexastack_core.ports.ratelimit import RateLimiterPort
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):
@@ -136,7 +145,10 @@ def _enforce_rate_limit(
136
145
  ) -> None:
137
146
  """Check rate limit quota against application rate limiter and raise HTTPException if exceeded."""
138
147
  if request is None:
139
- return
148
+ raise RuntimeError(
149
+ f"@rate_limit applied to '{fn_name}', but no Request instance was received. "
150
+ "Ensure the route declares 'request: Request' or FastAPI forwards the request."
151
+ )
140
152
  limiter_port: RateLimiterPort | None = getattr(
141
153
  request.app.state, "rate_limiter", None
142
154
  )
@@ -178,6 +190,11 @@ def rate_limit(
178
190
 
179
191
  def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
180
192
  fn_name = getattr(fn, "__name__", "endpoint")
193
+ sig = inspect.signature(fn)
194
+ has_req_param = any(
195
+ p.annotation is Request or p.name == "request"
196
+ for p in sig.parameters.values()
197
+ )
181
198
 
182
199
  if inspect.iscoroutinefunction(fn):
183
200
 
@@ -185,16 +202,44 @@ def rate_limit(
185
202
  async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
186
203
  req = _extract_request(args, kwargs)
187
204
  _enforce_rate_limit(req, fn_name, limit, key_extractor, detail)
205
+ if not has_req_param:
206
+ kwargs.pop("request", None)
188
207
  return await fn(*args, **kwargs)
189
208
 
209
+ if not has_req_param:
210
+ params = list(sig.parameters.values())
211
+ params.append(
212
+ inspect.Parameter(
213
+ "request",
214
+ inspect.Parameter.KEYWORD_ONLY,
215
+ annotation=Request,
216
+ )
217
+ )
218
+ cast("Any", async_wrapped).__signature__ = sig.replace(
219
+ parameters=params
220
+ )
221
+
190
222
  return async_wrapped
191
223
 
192
224
  @wraps(fn)
193
225
  def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
194
226
  req = _extract_request(args, kwargs)
195
227
  _enforce_rate_limit(req, fn_name, limit, key_extractor, detail)
228
+ if not has_req_param:
229
+ kwargs.pop("request", None)
196
230
  return fn(*args, **kwargs)
197
231
 
232
+ if not has_req_param:
233
+ params = list(sig.parameters.values())
234
+ params.append(
235
+ inspect.Parameter(
236
+ "request",
237
+ inspect.Parameter.KEYWORD_ONLY,
238
+ annotation=Request,
239
+ )
240
+ )
241
+ cast("Any", sync_wrapped).__signature__ = sig.replace(parameters=params)
242
+
198
243
  return sync_wrapped
199
244
 
200
245
  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
 
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from hexastack_core.domain.exceptions import MissingDependencyError
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 = """
@@ -220,7 +221,7 @@ class DemoNarrator:
220
221
  out_dir = Path("docs/assets/demos")
221
222
  out_dir.mkdir(parents=True, exist_ok=True)
222
223
  vtt_path = out_dir / f"{self.output_name or 'demo'}.vtt"
223
- vtt_path.write_text("\n".join(vtt_lines), encoding="utf-8")
224
+ atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
224
225
  return vtt_path
225
226
 
226
227
  @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)