hexastack-fastapi 0.2.0__tar.gz → 0.3.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.2.0 → hexastack_fastapi-0.3.0}/PKG-INFO +12 -7
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/README.md +11 -6
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/pyproject.toml +2 -2
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/pyproject.toml.orig +2 -2
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/__init__.py +14 -1
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/health.py +20 -7
- hexastack_fastapi-0.3.0/src/hexastack_fastapi/adapters/metrics.py +44 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/routing.py +107 -1
- hexastack_fastapi-0.3.0/src/hexastack_fastapi/adapters/sse.py +190 -0
- hexastack_fastapi-0.3.0/src/hexastack_fastapi/adapters/websockets.py +212 -0
- {hexastack_fastapi-0.2.0/src/hexastack_fastapi/infra → hexastack_fastapi-0.3.0/src/hexastack_fastapi/domain}/config.py +1 -21
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/__init__.py +2 -0
- {hexastack_fastapi-0.2.0/src/hexastack_fastapi/adapters → hexastack_fastapi-0.3.0/src/hexastack_fastapi/infra}/app.py +15 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/bootstrap.py +1 -1
- hexastack_fastapi-0.3.0/src/hexastack_fastapi/infra/config.py +31 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/middleware/__init__.py +4 -0
- hexastack_fastapi-0.3.0/src/hexastack_fastapi/infra/middleware/metrics.py +122 -0
- hexastack_fastapi-0.3.0/src/hexastack_fastapi/py.typed +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/__init__.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/dependencies.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/ratelimit.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/ui.py +0 -0
- /hexastack_fastapi-0.2.0/src/hexastack_fastapi/py.typed → /hexastack_fastapi-0.3.0/src/hexastack_fastapi/domain/__init__.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/autodiscovery.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/decorators.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/exception_handlers.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/middleware/correlation.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/testing/recorder.py +0 -0
- {hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.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.3.0
|
|
4
4
|
Summary: FastAPI presentation adapter for Hexastack
|
|
5
5
|
Author: Richard West
|
|
6
6
|
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
@@ -40,28 +40,32 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
## 1. Overview & Capabilities
|
|
41
41
|
|
|
42
42
|
- **Automatic CQRS Routing & Feature Gating**: Map HTTP endpoints directly to `CommandBusPort` or `QueryBusPort` using `@api_command` and `@api_query`, with native feature flag route guards (`@feature_flag_route` and `require_feature(...)`).
|
|
43
|
+
- **Server-Sent Events (SSE) Real-Time Streaming**: Native `ServerSentEvent` formatting and `EventSourceResponse` supporting chunked transfer encoding, keep-alive heartbeat pings, and direct streaming queries on `CqrsRouter.add_streaming_query`.
|
|
44
|
+
- **WebSockets Real-Time Channel Manager & CQRS Bridge**: `WebSocketConnectionManager` managing connection pools, room/channel groupings, and broadcasts, alongside `WebSocketCqrsBridge` mapping incoming socket JSON actions into CQRS Commands and Queries.
|
|
43
45
|
- **Decoupled Database Session Middleware**: `DbSessionMiddleware` (sync) and `AsyncDbSessionMiddleware` (async) manage session-per-request lifecycles by consuming sessionmakers from DI without a hard dependency on `hexastack-db`.
|
|
44
46
|
- **Standardized Exception Handlers**: Automatically translates domain exceptions (`EntityNotFoundError`, `UniqueConstraintViolationError`, `HexastackError`) into appropriate HTTP status codes (404, 409, 500) and structured JSON error envelopes.
|
|
45
|
-
- **Observability & Correlation Middleware**: Injects `X-Correlation-ID` headers
|
|
47
|
+
- **Observability & Correlation Middleware**: Injects `X-Correlation-ID` headers, logs HTTP request lifecycles, and captures HTTP RED metrics (`HttpMetricsMiddleware`) exposing a standard `/metrics` endpoint.
|
|
46
48
|
- **Built-in Health Checks**: Configurable `/health` and `/ready` endpoints verifying container and subsystem readiness.
|
|
47
49
|
- **NiceGUI Reactive UI & DevTools Dashboard**: Optional `[ui]` presentation adapter providing `ui_page`, `dispatch_command`, `dispatch_query`, and a zero-config interactive developer console (`mount_devtools_dashboard`) for inspecting CQRS buses, feature flags, and DI services.
|
|
48
50
|
|
|
51
|
+
|
|
49
52
|
---
|
|
50
53
|
|
|
51
54
|
## 2. Package Anatomy & Key Components
|
|
52
55
|
|
|
53
56
|
```
|
|
54
57
|
hexastack_fastapi/
|
|
55
|
-
├── domain/ # HealthStatus, HTTP error envelope models
|
|
56
|
-
├── adapters/ #
|
|
57
|
-
└── infra/ # FastApiBootstrapper (order=30), exception handlers, correlation/logging middlewares
|
|
58
|
+
├── domain/ # HealthStatus, HTTP error envelope models, HexastackFastApiConfig
|
|
59
|
+
├── adapters/ # routing decorators, SSE streaming, WebSockets, health endpoints, db_session middleware, dependencies, NiceGUI UI adapter
|
|
60
|
+
└── infra/ # create_fastapi_app, FastApiBootstrapper (order=30), exception handlers, correlation/logging middlewares
|
|
58
61
|
```
|
|
59
62
|
|
|
63
|
+
|
|
60
64
|
### Key Exports
|
|
61
65
|
|
|
62
66
|
| Category | Exports |
|
|
63
67
|
|---|---|
|
|
64
|
-
| **Application Factory** | `
|
|
68
|
+
| **Application Factory** | `create_fastapi_app`, `FastApiBootstrapper` (order=30) |
|
|
65
69
|
| **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `@rate_limit`, `RouteMetadata` |
|
|
66
70
|
| **Dependencies** | `get_container`, `get_pipeline`, `get_feature_flags`, `require_feature`, `get_rate_limiter`, `require_rate_limit` |
|
|
67
71
|
| **Documentation Mounting** | `mount_zensical_docs`, `DocumentationNotFoundError`, `ZensicalDocsConfig` |
|
|
@@ -69,12 +73,13 @@ hexastack_fastapi/
|
|
|
69
73
|
| **Middlewares** | `DbSessionMiddleware`, `AsyncDbSessionMiddleware`, `add_db_session_middleware`, `CorrelationMiddleware`, `HttpLoggingMiddleware` |
|
|
70
74
|
| **Rate Limiting** | `SlowapiRateLimiterAdapter`, `get_remote_address`, `get_user_or_ip_key`, `RateLimitConfig` |
|
|
71
75
|
| **Reactive UI (NiceGUI)** | `dispatch_command`, `dispatch_query`, `mount_devtools_dashboard`, `mount_ui_app`, `ui_page` |
|
|
76
|
+
| **Real-Time Streaming (SSE & WebSockets)** | `EventSourceResponse`, `ServerSentEvent`, `WebSocketConnectionManager`, `WebSocketCqrsBridge` |
|
|
72
77
|
| **Routing** | `CqrsRouter`, `autodiscover_routes` |
|
|
73
78
|
| **Testing, E2E & Demo Narration** | `create_test_client`, `check_openapi_conformance`, `EphemeralServer`, `ephemeral_server`, `find_free_port`, `smart_click`, `DemoNarrator`, `VIRTUAL_CURSOR_SCRIPT` |
|
|
74
79
|
|
|
75
|
-
|
|
76
80
|
---
|
|
77
81
|
|
|
82
|
+
|
|
78
83
|
## 3. Monorepo & Sibling Relationships
|
|
79
84
|
|
|
80
85
|
```mermaid
|
|
@@ -15,28 +15,32 @@
|
|
|
15
15
|
## 1. Overview & Capabilities
|
|
16
16
|
|
|
17
17
|
- **Automatic CQRS Routing & Feature Gating**: Map HTTP endpoints directly to `CommandBusPort` or `QueryBusPort` using `@api_command` and `@api_query`, with native feature flag route guards (`@feature_flag_route` and `require_feature(...)`).
|
|
18
|
+
- **Server-Sent Events (SSE) Real-Time Streaming**: Native `ServerSentEvent` formatting and `EventSourceResponse` supporting chunked transfer encoding, keep-alive heartbeat pings, and direct streaming queries on `CqrsRouter.add_streaming_query`.
|
|
19
|
+
- **WebSockets Real-Time Channel Manager & CQRS Bridge**: `WebSocketConnectionManager` managing connection pools, room/channel groupings, and broadcasts, alongside `WebSocketCqrsBridge` mapping incoming socket JSON actions into CQRS Commands and Queries.
|
|
18
20
|
- **Decoupled Database Session Middleware**: `DbSessionMiddleware` (sync) and `AsyncDbSessionMiddleware` (async) manage session-per-request lifecycles by consuming sessionmakers from DI without a hard dependency on `hexastack-db`.
|
|
19
21
|
- **Standardized Exception Handlers**: Automatically translates domain exceptions (`EntityNotFoundError`, `UniqueConstraintViolationError`, `HexastackError`) into appropriate HTTP status codes (404, 409, 500) and structured JSON error envelopes.
|
|
20
|
-
- **Observability & Correlation Middleware**: Injects `X-Correlation-ID` headers
|
|
22
|
+
- **Observability & Correlation Middleware**: Injects `X-Correlation-ID` headers, logs HTTP request lifecycles, and captures HTTP RED metrics (`HttpMetricsMiddleware`) exposing a standard `/metrics` endpoint.
|
|
21
23
|
- **Built-in Health Checks**: Configurable `/health` and `/ready` endpoints verifying container and subsystem readiness.
|
|
22
24
|
- **NiceGUI Reactive UI & DevTools Dashboard**: Optional `[ui]` presentation adapter providing `ui_page`, `dispatch_command`, `dispatch_query`, and a zero-config interactive developer console (`mount_devtools_dashboard`) for inspecting CQRS buses, feature flags, and DI services.
|
|
23
25
|
|
|
26
|
+
|
|
24
27
|
---
|
|
25
28
|
|
|
26
29
|
## 2. Package Anatomy & Key Components
|
|
27
30
|
|
|
28
31
|
```
|
|
29
32
|
hexastack_fastapi/
|
|
30
|
-
├── domain/ # HealthStatus, HTTP error envelope models
|
|
31
|
-
├── adapters/ #
|
|
32
|
-
└── infra/ # FastApiBootstrapper (order=30), exception handlers, correlation/logging middlewares
|
|
33
|
+
├── domain/ # HealthStatus, HTTP error envelope models, HexastackFastApiConfig
|
|
34
|
+
├── adapters/ # routing decorators, SSE streaming, WebSockets, health endpoints, db_session middleware, dependencies, NiceGUI UI adapter
|
|
35
|
+
└── infra/ # create_fastapi_app, FastApiBootstrapper (order=30), exception handlers, correlation/logging middlewares
|
|
33
36
|
```
|
|
34
37
|
|
|
38
|
+
|
|
35
39
|
### Key Exports
|
|
36
40
|
|
|
37
41
|
| Category | Exports |
|
|
38
42
|
|---|---|
|
|
39
|
-
| **Application Factory** | `
|
|
43
|
+
| **Application Factory** | `create_fastapi_app`, `FastApiBootstrapper` (order=30) |
|
|
40
44
|
| **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `@rate_limit`, `RouteMetadata` |
|
|
41
45
|
| **Dependencies** | `get_container`, `get_pipeline`, `get_feature_flags`, `require_feature`, `get_rate_limiter`, `require_rate_limit` |
|
|
42
46
|
| **Documentation Mounting** | `mount_zensical_docs`, `DocumentationNotFoundError`, `ZensicalDocsConfig` |
|
|
@@ -44,12 +48,13 @@ hexastack_fastapi/
|
|
|
44
48
|
| **Middlewares** | `DbSessionMiddleware`, `AsyncDbSessionMiddleware`, `add_db_session_middleware`, `CorrelationMiddleware`, `HttpLoggingMiddleware` |
|
|
45
49
|
| **Rate Limiting** | `SlowapiRateLimiterAdapter`, `get_remote_address`, `get_user_or_ip_key`, `RateLimitConfig` |
|
|
46
50
|
| **Reactive UI (NiceGUI)** | `dispatch_command`, `dispatch_query`, `mount_devtools_dashboard`, `mount_ui_app`, `ui_page` |
|
|
51
|
+
| **Real-Time Streaming (SSE & WebSockets)** | `EventSourceResponse`, `ServerSentEvent`, `WebSocketConnectionManager`, `WebSocketCqrsBridge` |
|
|
47
52
|
| **Routing** | `CqrsRouter`, `autodiscover_routes` |
|
|
48
53
|
| **Testing, E2E & Demo Narration** | `create_test_client`, `check_openapi_conformance`, `EphemeralServer`, `ephemeral_server`, `find_free_port`, `smart_click`, `DemoNarrator`, `VIRTUAL_CURSOR_SCRIPT` |
|
|
49
54
|
|
|
50
|
-
|
|
51
55
|
---
|
|
52
56
|
|
|
57
|
+
|
|
53
58
|
## 3. Monorepo & Sibling Relationships
|
|
54
59
|
|
|
55
60
|
```mermaid
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-fastapi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "FastAPI presentation adapter for Hexastack"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -52,8 +52,8 @@ name = "Hexagonal architecture layer hierarchy"
|
|
|
52
52
|
type = "layers"
|
|
53
53
|
containers = ["hexastack_fastapi"]
|
|
54
54
|
layers = [
|
|
55
|
-
"adapters",
|
|
56
55
|
"infra",
|
|
56
|
+
"adapters",
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
[[tool.importlinter.contracts]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-fastapi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "FastAPI presentation adapter for Hexastack"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -54,8 +54,8 @@ name = "Hexagonal architecture layer hierarchy"
|
|
|
54
54
|
type = "layers"
|
|
55
55
|
containers = ["hexastack_fastapi"]
|
|
56
56
|
layers = [
|
|
57
|
-
"adapters",
|
|
58
57
|
"infra",
|
|
58
|
+
"adapters",
|
|
59
59
|
]
|
|
60
60
|
|
|
61
61
|
[[tool.importlinter.contracts]]
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/__init__.py
RENAMED
|
@@ -13,6 +13,7 @@ from hexastack_fastapi.adapters.docs import (
|
|
|
13
13
|
mount_zensical_docs,
|
|
14
14
|
)
|
|
15
15
|
from hexastack_fastapi.adapters.health import create_health_router
|
|
16
|
+
from hexastack_fastapi.adapters.metrics import create_metrics_router
|
|
16
17
|
from hexastack_fastapi.adapters.ratelimit import (
|
|
17
18
|
SlowapiRateLimiterAdapter,
|
|
18
19
|
get_remote_address,
|
|
@@ -20,6 +21,10 @@ from hexastack_fastapi.adapters.ratelimit import (
|
|
|
20
21
|
rate_limit,
|
|
21
22
|
)
|
|
22
23
|
from hexastack_fastapi.adapters.routing import CqrsRouter
|
|
24
|
+
from hexastack_fastapi.adapters.sse import (
|
|
25
|
+
EventSourceResponse,
|
|
26
|
+
ServerSentEvent,
|
|
27
|
+
)
|
|
23
28
|
from hexastack_fastapi.adapters.ui import (
|
|
24
29
|
dispatch_command,
|
|
25
30
|
dispatch_query,
|
|
@@ -27,16 +32,21 @@ from hexastack_fastapi.adapters.ui import (
|
|
|
27
32
|
mount_ui_app,
|
|
28
33
|
ui_page,
|
|
29
34
|
)
|
|
35
|
+
from hexastack_fastapi.adapters.websockets import (
|
|
36
|
+
WebSocketConnectionManager,
|
|
37
|
+
WebSocketCqrsBridge,
|
|
38
|
+
)
|
|
30
39
|
|
|
31
40
|
__all__ = [
|
|
32
41
|
"check_openapi_conformance",
|
|
33
42
|
"CqrsRouter",
|
|
34
|
-
"create_fastapi_app",
|
|
35
43
|
"create_health_router",
|
|
44
|
+
"create_metrics_router",
|
|
36
45
|
"create_test_client",
|
|
37
46
|
"dispatch_command",
|
|
38
47
|
"dispatch_query",
|
|
39
48
|
"DocumentationNotFoundError",
|
|
49
|
+
"EventSourceResponse",
|
|
40
50
|
"get_container",
|
|
41
51
|
"get_feature_flags",
|
|
42
52
|
"get_pipeline",
|
|
@@ -49,6 +59,9 @@ __all__ = [
|
|
|
49
59
|
"rate_limit",
|
|
50
60
|
"require_feature",
|
|
51
61
|
"require_rate_limit",
|
|
62
|
+
"ServerSentEvent",
|
|
52
63
|
"SlowapiRateLimiterAdapter",
|
|
53
64
|
"ui_page",
|
|
65
|
+
"WebSocketConnectionManager",
|
|
66
|
+
"WebSocketCqrsBridge",
|
|
54
67
|
]
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/health.py
RENAMED
|
@@ -6,7 +6,6 @@ from fastapi.responses import JSONResponse
|
|
|
6
6
|
from rodi import Container
|
|
7
7
|
|
|
8
8
|
from hexastack_core.utils.context import get_correlation_id
|
|
9
|
-
from hexastack_fastapi.infra.config import HealthConfig
|
|
10
9
|
|
|
11
10
|
__all__ = [
|
|
12
11
|
"create_health_router",
|
|
@@ -15,17 +14,22 @@ __all__ = [
|
|
|
15
14
|
|
|
16
15
|
def create_health_router(
|
|
17
16
|
container: Container | None = None,
|
|
18
|
-
config:
|
|
17
|
+
config: Any | None = None,
|
|
18
|
+
health_path: str = "/health",
|
|
19
|
+
ready_path: str = "/ready",
|
|
19
20
|
) -> APIRouter:
|
|
20
21
|
"""Create APIRouter with standard liveness and readiness probe endpoints.
|
|
21
22
|
|
|
22
23
|
Notes/Architectural Intent:
|
|
23
24
|
Exposes lightweight /health (liveness) and /ready (readiness) endpoints for orchestration
|
|
24
|
-
platforms like Kubernetes and container engines.
|
|
25
|
+
platforms like Kubernetes and container engines. Decoupled from infra configuration schemas
|
|
26
|
+
by accepting primitives or duck-typed config objects.
|
|
25
27
|
|
|
26
28
|
Args:
|
|
27
29
|
container: Optional rodi Container instance to check dependency health.
|
|
28
|
-
config: Optional
|
|
30
|
+
config: Optional configuration object specifying custom URL paths (e.g. HealthConfig).
|
|
31
|
+
health_path: Default liveness probe path (used if config not provided).
|
|
32
|
+
ready_path: Default readiness probe path (used if config not provided).
|
|
29
33
|
|
|
30
34
|
Returns:
|
|
31
35
|
APIRouter mounted with health endpoints.
|
|
@@ -33,10 +37,19 @@ def create_health_router(
|
|
|
33
37
|
Raises:
|
|
34
38
|
None.
|
|
35
39
|
"""
|
|
36
|
-
|
|
40
|
+
resolved_health_path = (
|
|
41
|
+
getattr(config, "health_path", None) or health_path
|
|
42
|
+
if config is not None
|
|
43
|
+
else health_path
|
|
44
|
+
)
|
|
45
|
+
resolved_ready_path = (
|
|
46
|
+
getattr(config, "ready_path", None) or ready_path
|
|
47
|
+
if config is not None
|
|
48
|
+
else ready_path
|
|
49
|
+
)
|
|
37
50
|
router = APIRouter(tags=["Health"])
|
|
38
51
|
|
|
39
|
-
@router.get(
|
|
52
|
+
@router.get(resolved_health_path, summary="Liveness Probe")
|
|
40
53
|
async def liveness() -> dict[str, Any]:
|
|
41
54
|
return {
|
|
42
55
|
"status": "ok",
|
|
@@ -44,7 +57,7 @@ def create_health_router(
|
|
|
44
57
|
"correlation_id": get_correlation_id(),
|
|
45
58
|
}
|
|
46
59
|
|
|
47
|
-
@router.get(
|
|
60
|
+
@router.get(resolved_ready_path, summary="Readiness Probe")
|
|
48
61
|
async def readiness() -> JSONResponse:
|
|
49
62
|
checks: dict[str, str] = {
|
|
50
63
|
"container": "ok" if container is not None else "unconfigured",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Prometheus Metrics Endpoint Router for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Response
|
|
6
|
+
from rodi import Container
|
|
7
|
+
|
|
8
|
+
from hexastack_core.ports.metrics import MetricsPort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_metrics_router(container: Container | None = None) -> APIRouter:
|
|
12
|
+
"""Create a FastAPI APIRouter mounting the /metrics Prometheus scraper endpoint.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
container: Active rodi Container to resolve MetricsPort.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
APIRouter exposing GET /metrics.
|
|
19
|
+
"""
|
|
20
|
+
router = APIRouter(tags=["Observability"])
|
|
21
|
+
|
|
22
|
+
@router.get("/metrics", include_in_schema=False)
|
|
23
|
+
async def get_metrics() -> Response:
|
|
24
|
+
if container is not None and MetricsPort in container:
|
|
25
|
+
metrics_port = container.resolve(MetricsPort)
|
|
26
|
+
body = metrics_port.generate_metrics_text()
|
|
27
|
+
else:
|
|
28
|
+
try:
|
|
29
|
+
from prometheus_client import generate_latest
|
|
30
|
+
|
|
31
|
+
body = generate_latest()
|
|
32
|
+
except ImportError:
|
|
33
|
+
body = b"# Prometheus metrics not configured"
|
|
34
|
+
|
|
35
|
+
return Response(
|
|
36
|
+
content=body, media_type="text/plain; version=0.0.4; charset=utf-8"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return router
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"create_metrics_router",
|
|
44
|
+
]
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/routing.py
RENAMED
|
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends
|
|
|
8
8
|
from hexastack_core.domain import Command, Query
|
|
9
9
|
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
10
10
|
from hexastack_fastapi.adapters.dependencies import get_pipeline
|
|
11
|
+
from hexastack_fastapi.adapters.sse import EventSourceResponse
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class CqrsRouter(APIRouter):
|
|
@@ -90,7 +91,7 @@ class CqrsRouter(APIRouter):
|
|
|
90
91
|
Args:
|
|
91
92
|
path: URL path for the route.
|
|
92
93
|
query_cls: Pydantic Query model class.
|
|
93
|
-
method: HTTP method ('GET' or 'POST').
|
|
94
|
+
method: HTTP method (e.g., 'GET' or 'POST').
|
|
94
95
|
status_code: Success HTTP status code.
|
|
95
96
|
output_format: Optional presenter format (e.g., 'json').
|
|
96
97
|
summary: Optional OpenAPI summary string.
|
|
@@ -126,6 +127,55 @@ class CqrsRouter(APIRouter):
|
|
|
126
127
|
response_model=None,
|
|
127
128
|
)
|
|
128
129
|
|
|
130
|
+
def add_streaming_query[TQuery: Query](
|
|
131
|
+
self,
|
|
132
|
+
path: str,
|
|
133
|
+
query_cls: type[TQuery],
|
|
134
|
+
*,
|
|
135
|
+
method: str = "GET",
|
|
136
|
+
summary: str | None = None,
|
|
137
|
+
tags: list[str | Enum] | None = None,
|
|
138
|
+
feature_flag: str | None = None,
|
|
139
|
+
rate_limit: str | None = None,
|
|
140
|
+
ping_interval: float | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Register a Server-Sent Events (SSE) streaming endpoint for a Query model.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
path: URL path for the route.
|
|
146
|
+
query_cls: Pydantic Query model class.
|
|
147
|
+
method: HTTP method (e.g. 'GET' or 'POST').
|
|
148
|
+
summary: Optional OpenAPI summary string.
|
|
149
|
+
tags: Optional OpenAPI tags list.
|
|
150
|
+
feature_flag: Optional feature flag key required for endpoint execution.
|
|
151
|
+
rate_limit: Optional rate limit string (e.g. '10/minute').
|
|
152
|
+
ping_interval: Optional keep-alive ping interval in seconds.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
None.
|
|
156
|
+
"""
|
|
157
|
+
endpoint = _create_streaming_query_endpoint(query_cls, method, ping_interval)
|
|
158
|
+
dependencies = []
|
|
159
|
+
if feature_flag:
|
|
160
|
+
from hexastack_fastapi.adapters.dependencies import require_feature
|
|
161
|
+
|
|
162
|
+
dependencies.append(Depends(require_feature(feature_flag)))
|
|
163
|
+
if rate_limit:
|
|
164
|
+
from hexastack_fastapi.adapters.dependencies import require_rate_limit
|
|
165
|
+
|
|
166
|
+
dependencies.append(Depends(require_rate_limit(rate_limit)))
|
|
167
|
+
|
|
168
|
+
self.add_api_route(
|
|
169
|
+
path=path,
|
|
170
|
+
endpoint=endpoint,
|
|
171
|
+
methods=[method.upper()],
|
|
172
|
+
status_code=200,
|
|
173
|
+
summary=summary or f"Stream {query_cls.__name__}",
|
|
174
|
+
tags=tags,
|
|
175
|
+
dependencies=dependencies if dependencies else None,
|
|
176
|
+
response_class=EventSourceResponse,
|
|
177
|
+
)
|
|
178
|
+
|
|
129
179
|
|
|
130
180
|
__all__ = [
|
|
131
181
|
"CqrsRouter",
|
|
@@ -213,3 +263,59 @@ def _create_query_endpoint(
|
|
|
213
263
|
]
|
|
214
264
|
cast("Any", post_endpoint).__signature__ = sig.replace(parameters=params)
|
|
215
265
|
return post_endpoint
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _create_streaming_query_endpoint(
|
|
269
|
+
query_cls: type[Query], method: str, ping_interval: float | None
|
|
270
|
+
) -> Callable[..., Any]:
|
|
271
|
+
"""Dynamically construct an SSE streaming FastAPI endpoint for a Query model."""
|
|
272
|
+
if method.upper() == "GET":
|
|
273
|
+
|
|
274
|
+
async def get_stream_endpoint(
|
|
275
|
+
qry: Any,
|
|
276
|
+
pipeline: Annotated[ExecutionPipeline, Depends(get_pipeline)],
|
|
277
|
+
) -> EventSourceResponse:
|
|
278
|
+
gen = pipeline.execute(qry)
|
|
279
|
+
return EventSourceResponse(gen, ping_interval=ping_interval)
|
|
280
|
+
|
|
281
|
+
sig = inspect.signature(get_stream_endpoint)
|
|
282
|
+
params = [
|
|
283
|
+
inspect.Parameter(
|
|
284
|
+
"qry",
|
|
285
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
286
|
+
annotation=query_cls,
|
|
287
|
+
default=Depends(query_cls),
|
|
288
|
+
),
|
|
289
|
+
inspect.Parameter(
|
|
290
|
+
"pipeline",
|
|
291
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
292
|
+
annotation=ExecutionPipeline,
|
|
293
|
+
default=Depends(get_pipeline),
|
|
294
|
+
),
|
|
295
|
+
]
|
|
296
|
+
cast("Any", get_stream_endpoint).__signature__ = sig.replace(parameters=params)
|
|
297
|
+
return get_stream_endpoint
|
|
298
|
+
|
|
299
|
+
async def post_stream_endpoint(
|
|
300
|
+
qry: Any,
|
|
301
|
+
pipeline: Annotated[ExecutionPipeline, Depends(get_pipeline)],
|
|
302
|
+
) -> EventSourceResponse:
|
|
303
|
+
gen = pipeline.execute(qry)
|
|
304
|
+
return EventSourceResponse(gen, ping_interval=ping_interval)
|
|
305
|
+
|
|
306
|
+
sig = inspect.signature(post_stream_endpoint)
|
|
307
|
+
params = [
|
|
308
|
+
inspect.Parameter(
|
|
309
|
+
"qry",
|
|
310
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
311
|
+
annotation=query_cls,
|
|
312
|
+
),
|
|
313
|
+
inspect.Parameter(
|
|
314
|
+
"pipeline",
|
|
315
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
316
|
+
annotation=ExecutionPipeline,
|
|
317
|
+
default=Depends(get_pipeline),
|
|
318
|
+
),
|
|
319
|
+
]
|
|
320
|
+
cast("Any", post_stream_endpoint).__signature__ = sig.replace(parameters=params)
|
|
321
|
+
return post_stream_endpoint
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Server-Sent Events (SSE) Streaming Response Adapters for FastAPI & Starlette."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from collections.abc import AsyncIterable, AsyncIterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi.responses import Response
|
|
12
|
+
from starlette.background import BackgroundTask
|
|
13
|
+
from starlette.types import Receive, Scope, Send
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ServerSentEvent:
|
|
18
|
+
"""Represents an individual Server-Sent Event frame.
|
|
19
|
+
|
|
20
|
+
Notes/Architectural Intent:
|
|
21
|
+
Standardizes the W3C Server-Sent Events wire format.
|
|
22
|
+
Supports structured JSON data, event type naming, event IDs, retry intervals,
|
|
23
|
+
and keep-alive comments.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
data: Any = None
|
|
27
|
+
event: str | None = None
|
|
28
|
+
id: str | None = None
|
|
29
|
+
retry: int | None = None
|
|
30
|
+
comment: str | None = None
|
|
31
|
+
|
|
32
|
+
def encode(self) -> bytes:
|
|
33
|
+
"""Encode the Server-Sent Event into bytes conforming to the text/event-stream spec.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Encoded bytes terminated with double newlines.
|
|
37
|
+
"""
|
|
38
|
+
buffer: list[str] = []
|
|
39
|
+
|
|
40
|
+
if self.comment is not None:
|
|
41
|
+
for line in self.comment.splitlines():
|
|
42
|
+
buffer.append(f": {line}\n")
|
|
43
|
+
|
|
44
|
+
if self.id is not None:
|
|
45
|
+
buffer.append(f"id: {self.id}\n")
|
|
46
|
+
|
|
47
|
+
if self.event is not None:
|
|
48
|
+
buffer.append(f"event: {self.event}\n")
|
|
49
|
+
|
|
50
|
+
if self.retry is not None:
|
|
51
|
+
buffer.append(f"retry: {self.retry}\n")
|
|
52
|
+
|
|
53
|
+
if self.data is not None:
|
|
54
|
+
raw_data = str(self.data) if not isinstance(self.data, str) else self.data
|
|
55
|
+
for line in raw_data.splitlines():
|
|
56
|
+
buffer.append(f"data: {line}\n")
|
|
57
|
+
elif self.comment is None and self.event is None and self.id is None:
|
|
58
|
+
buffer.append("data:\n")
|
|
59
|
+
|
|
60
|
+
buffer.append("\n")
|
|
61
|
+
return "".join(buffer).encode("utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EventSourceResponse(Response):
|
|
65
|
+
"""FastAPI/Starlette Response subclass streaming Server-Sent Events over HTTP.
|
|
66
|
+
|
|
67
|
+
Notes/Architectural Intent:
|
|
68
|
+
Implements chunked transfer encoding over HTTP/1.1 or standard HTTP/2/3 streams
|
|
69
|
+
with media type 'text/event-stream'. Supports optional background ping keep-alives
|
|
70
|
+
to prevent reverse proxy timeouts (e.g. NGINX, Cloudflare).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
media_type = "text/event-stream"
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
content: AsyncIterable[ServerSentEvent | str | bytes | dict[str, Any]],
|
|
78
|
+
status_code: int = 200,
|
|
79
|
+
headers: dict[str, str] | None = None,
|
|
80
|
+
media_type: str | None = None,
|
|
81
|
+
background: BackgroundTask | None = None,
|
|
82
|
+
ping_interval: float | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Initialize EventSourceResponse.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
content: Async generator or iterable yielding ServerSentEvent or raw data.
|
|
88
|
+
status_code: HTTP status code (default 200).
|
|
89
|
+
headers: Optional HTTP headers dictionary.
|
|
90
|
+
media_type: Content-Type override (defaults to text/event-stream).
|
|
91
|
+
background: Optional Starlette BackgroundTask.
|
|
92
|
+
ping_interval: Optional interval in seconds to yield keep-alive comment pings.
|
|
93
|
+
"""
|
|
94
|
+
self.body_iterator = content
|
|
95
|
+
self.ping_interval = ping_interval
|
|
96
|
+
headers = headers or {}
|
|
97
|
+
headers.setdefault("Cache-Control", "no-cache")
|
|
98
|
+
headers.setdefault("Connection", "keep-alive")
|
|
99
|
+
headers.setdefault("X-Accel-Buffering", "no")
|
|
100
|
+
super().__init__(
|
|
101
|
+
content=b"",
|
|
102
|
+
status_code=status_code,
|
|
103
|
+
headers=headers,
|
|
104
|
+
media_type=media_type or self.media_type,
|
|
105
|
+
background=background,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def _stream_with_ping(self) -> AsyncIterator[bytes]:
|
|
109
|
+
"""Wrap body_iterator with periodic keep-alive pings."""
|
|
110
|
+
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
111
|
+
|
|
112
|
+
async def _producer() -> None:
|
|
113
|
+
try:
|
|
114
|
+
async for item in self.body_iterator:
|
|
115
|
+
if isinstance(item, ServerSentEvent):
|
|
116
|
+
await queue.put(item.encode())
|
|
117
|
+
elif isinstance(item, bytes):
|
|
118
|
+
await queue.put(item)
|
|
119
|
+
elif isinstance(item, str):
|
|
120
|
+
await queue.put(ServerSentEvent(data=item).encode())
|
|
121
|
+
else:
|
|
122
|
+
import json
|
|
123
|
+
|
|
124
|
+
await queue.put(ServerSentEvent(data=json.dumps(item)).encode())
|
|
125
|
+
except Exception:
|
|
126
|
+
await queue.put(None)
|
|
127
|
+
raise
|
|
128
|
+
finally:
|
|
129
|
+
await queue.put(None)
|
|
130
|
+
|
|
131
|
+
producer_task = asyncio.create_task(_producer())
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
while True:
|
|
135
|
+
if self.ping_interval and self.ping_interval > 0:
|
|
136
|
+
try:
|
|
137
|
+
chunk = await asyncio.wait_for(
|
|
138
|
+
queue.get(), timeout=self.ping_interval
|
|
139
|
+
)
|
|
140
|
+
if chunk is None:
|
|
141
|
+
break
|
|
142
|
+
yield chunk
|
|
143
|
+
except TimeoutError:
|
|
144
|
+
yield ServerSentEvent(comment="ping").encode()
|
|
145
|
+
else:
|
|
146
|
+
chunk = await queue.get()
|
|
147
|
+
if chunk is None:
|
|
148
|
+
break
|
|
149
|
+
yield chunk
|
|
150
|
+
finally:
|
|
151
|
+
if not producer_task.done():
|
|
152
|
+
producer_task.cancel()
|
|
153
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
154
|
+
_res = await producer_task
|
|
155
|
+
|
|
156
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
157
|
+
"""ASGI response streaming callable."""
|
|
158
|
+
await send(
|
|
159
|
+
{
|
|
160
|
+
"type": "http.response.start",
|
|
161
|
+
"status": self.status_code,
|
|
162
|
+
"headers": self.raw_headers,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async for chunk in self._stream_with_ping():
|
|
167
|
+
await send(
|
|
168
|
+
{
|
|
169
|
+
"type": "http.response.body",
|
|
170
|
+
"body": chunk,
|
|
171
|
+
"more_body": True,
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
await send(
|
|
176
|
+
{
|
|
177
|
+
"type": "http.response.body",
|
|
178
|
+
"body": b"",
|
|
179
|
+
"more_body": False,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if self.background is not None:
|
|
184
|
+
await self.background()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"EventSourceResponse",
|
|
189
|
+
"ServerSentEvent",
|
|
190
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""WebSocket Connection Manager and Channel Router Adapters for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
12
|
+
|
|
13
|
+
from hexastack_core.domain import Command, Query
|
|
14
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebSocketConnectionManager:
|
|
18
|
+
"""Manages active WebSocket connections, grouping by room and client identity.
|
|
19
|
+
|
|
20
|
+
Notes/Architectural Intent:
|
|
21
|
+
Encapsulates connection lifecycle (connect, disconnect, broadcast) and room grouping.
|
|
22
|
+
Maintains an in-memory connection registry protected by asyncio concurrency semantics.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
"""Initialize WebSocketConnectionManager with empty connection pools."""
|
|
27
|
+
self._active_connections: set[Any] = set()
|
|
28
|
+
self._rooms: dict[str, set[Any]] = defaultdict(set)
|
|
29
|
+
self._lock = asyncio.Lock()
|
|
30
|
+
|
|
31
|
+
async def connect(self, websocket: Any, room: str | None = None) -> None:
|
|
32
|
+
"""Accept an incoming WebSocket connection and register in pools.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
websocket: Incoming FastAPI WebSocket instance or mock.
|
|
36
|
+
room: Optional room/channel identifier to subscribe to.
|
|
37
|
+
"""
|
|
38
|
+
await websocket.accept()
|
|
39
|
+
async with self._lock:
|
|
40
|
+
self._active_connections.add(websocket)
|
|
41
|
+
if room:
|
|
42
|
+
self._rooms[room].add(websocket)
|
|
43
|
+
|
|
44
|
+
async def disconnect(self, websocket: Any, room: str | None = None) -> None:
|
|
45
|
+
"""Remove a WebSocket connection from active pools and rooms.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
websocket: Terminated WebSocket instance.
|
|
49
|
+
room: Optional specific room from which to unsubscribe.
|
|
50
|
+
"""
|
|
51
|
+
async with self._lock:
|
|
52
|
+
self._active_connections.discard(websocket)
|
|
53
|
+
if room and room in self._rooms:
|
|
54
|
+
self._rooms[room].discard(websocket)
|
|
55
|
+
if not self._rooms[room]:
|
|
56
|
+
del self._rooms[room]
|
|
57
|
+
else:
|
|
58
|
+
for r_name, members in list(self._rooms.items()):
|
|
59
|
+
members.discard(websocket)
|
|
60
|
+
if not members:
|
|
61
|
+
del self._rooms[r_name]
|
|
62
|
+
|
|
63
|
+
async def send_personal_message(self, message: Any, websocket: Any) -> None:
|
|
64
|
+
"""Send a message directly to a single connected WebSocket client.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
message: Text, dict, or JSON-serializable payload.
|
|
68
|
+
websocket: Target WebSocket connection.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(message, str):
|
|
71
|
+
await websocket.send_text(message)
|
|
72
|
+
elif isinstance(message, bytes):
|
|
73
|
+
await websocket.send_bytes(message)
|
|
74
|
+
else:
|
|
75
|
+
await websocket.send_json(message)
|
|
76
|
+
|
|
77
|
+
async def broadcast(self, message: Any, room: str | None = None) -> None:
|
|
78
|
+
"""Broadcast a message to all connected clients or a specific room.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
message: Text, dict, or JSON-serializable payload.
|
|
82
|
+
room: Optional room identifier. If None, broadcasts to all active connections.
|
|
83
|
+
"""
|
|
84
|
+
async with self._lock:
|
|
85
|
+
targets = list(
|
|
86
|
+
self._rooms.get(room, set()) if room else self._active_connections
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for connection in targets:
|
|
90
|
+
try:
|
|
91
|
+
if isinstance(message, str):
|
|
92
|
+
await connection.send_text(message)
|
|
93
|
+
elif isinstance(message, bytes):
|
|
94
|
+
await connection.send_bytes(message)
|
|
95
|
+
else:
|
|
96
|
+
await connection.send_json(message)
|
|
97
|
+
except Exception:
|
|
98
|
+
await self.disconnect(connection, room)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def active_connections_count(self) -> int:
|
|
102
|
+
"""Return total count of active WebSocket connections."""
|
|
103
|
+
return len(self._active_connections)
|
|
104
|
+
|
|
105
|
+
def room_members_count(self, room: str) -> int:
|
|
106
|
+
"""Return count of active connections in a specific room."""
|
|
107
|
+
return len(self._rooms.get(room, set()))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WebSocketCqrsBridge:
|
|
111
|
+
"""Dispatches inbound WebSocket JSON payloads into CQRS ExecutionPipeline.
|
|
112
|
+
|
|
113
|
+
Notes/Architectural Intent:
|
|
114
|
+
Bridges real-time bidirectional WebSocket sessions to domain Commands and Queries,
|
|
115
|
+
returning execution results over the socket.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
pipeline: ExecutionPipeline,
|
|
121
|
+
manager: WebSocketConnectionManager | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Initialize WebSocketCqrsBridge.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
pipeline: CQRS ExecutionPipeline instance.
|
|
127
|
+
manager: Optional WebSocketConnectionManager instance.
|
|
128
|
+
"""
|
|
129
|
+
self.pipeline = pipeline
|
|
130
|
+
self.manager = manager or WebSocketConnectionManager()
|
|
131
|
+
self._message_types: dict[str, type[Command | Query]] = {}
|
|
132
|
+
|
|
133
|
+
def register_type(self, type_name: str, message_cls: type[Command | Query]) -> None:
|
|
134
|
+
"""Register a mapping from message type tag to Command/Query model.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
type_name: String discriminator (e.g. 'CreateTask', 'GetStatus').
|
|
138
|
+
message_cls: Model class to instantiate from payload.
|
|
139
|
+
"""
|
|
140
|
+
self._message_types[type_name] = message_cls
|
|
141
|
+
|
|
142
|
+
async def handle_connection(
|
|
143
|
+
self,
|
|
144
|
+
websocket: WebSocket,
|
|
145
|
+
room: str | None = None,
|
|
146
|
+
on_message: Callable[[Any], Any] | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Manage the lifecycle of a WebSocket connection dispatching CQRS messages.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
websocket: Connected WebSocket.
|
|
152
|
+
room: Optional room to join.
|
|
153
|
+
on_message: Optional custom callback invoked per processed message.
|
|
154
|
+
"""
|
|
155
|
+
await self.manager.connect(websocket, room=room)
|
|
156
|
+
try:
|
|
157
|
+
while True:
|
|
158
|
+
data = await websocket.receive_text()
|
|
159
|
+
try:
|
|
160
|
+
payload = json.loads(data)
|
|
161
|
+
except json.JSONDecodeError:
|
|
162
|
+
await websocket.send_json(
|
|
163
|
+
{"status": "error", "error": "Invalid JSON payload"}
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
type_tag = payload.get("type")
|
|
168
|
+
if not type_tag or type_tag not in self._message_types:
|
|
169
|
+
await websocket.send_json(
|
|
170
|
+
{
|
|
171
|
+
"status": "error",
|
|
172
|
+
"error": f"Unknown message type: {type_tag}",
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
msg_cls = self._message_types[type_tag]
|
|
178
|
+
params = payload.get("payload", {})
|
|
179
|
+
try:
|
|
180
|
+
instance = msg_cls(**params)
|
|
181
|
+
result = self.pipeline.execute(instance)
|
|
182
|
+
if asyncio.iscoroutine(result):
|
|
183
|
+
result = await result
|
|
184
|
+
|
|
185
|
+
response = {
|
|
186
|
+
"status": "ok",
|
|
187
|
+
"type": type_tag,
|
|
188
|
+
"data": result
|
|
189
|
+
if isinstance(result, (dict, list, str, int, float, bool))
|
|
190
|
+
else str(result),
|
|
191
|
+
}
|
|
192
|
+
await websocket.send_json(response)
|
|
193
|
+
if on_message:
|
|
194
|
+
cb_res = on_message(result)
|
|
195
|
+
if asyncio.iscoroutine(cb_res):
|
|
196
|
+
await cb_res
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
await websocket.send_json(
|
|
199
|
+
{
|
|
200
|
+
"status": "error",
|
|
201
|
+
"type": type_tag,
|
|
202
|
+
"error": str(exc),
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
except WebSocketDisconnect:
|
|
206
|
+
await self.manager.disconnect(websocket, room=room)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = [
|
|
210
|
+
"WebSocketConnectionManager",
|
|
211
|
+
"WebSocketCqrsBridge",
|
|
212
|
+
]
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
from pydantic import BaseModel, Field
|
|
2
2
|
|
|
3
|
-
from hexastack_core.infra.decorators import config_section
|
|
4
|
-
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
5
|
-
|
|
6
3
|
|
|
7
4
|
class CorsConfig(BaseModel):
|
|
8
5
|
"""Configuration schema for Cross-Origin Resource Sharing (CORS).
|
|
@@ -74,7 +71,7 @@ class ZensicalDocsConfig(BaseModel):
|
|
|
74
71
|
|
|
75
72
|
Notes/Architectural Intent:
|
|
76
73
|
Controls automatic mounting of Zensical static documentation site.
|
|
77
|
-
Default mount path is
|
|
74
|
+
Default mount path is to prevent collision with OpenAPI Swagger UI at .
|
|
78
75
|
"""
|
|
79
76
|
|
|
80
77
|
enable: bool = Field(default=False)
|
|
@@ -82,7 +79,6 @@ class ZensicalDocsConfig(BaseModel):
|
|
|
82
79
|
site_dir: str = Field(default="site")
|
|
83
80
|
|
|
84
81
|
|
|
85
|
-
@config_section("fastapi")
|
|
86
82
|
class HexastackFastApiConfig(BaseModel):
|
|
87
83
|
"""Configuration schema for FastAPI HTTP presentation adapter.
|
|
88
84
|
|
|
@@ -114,22 +110,6 @@ __all__ = [
|
|
|
114
110
|
"HealthConfig",
|
|
115
111
|
"HexastackFastApiConfig",
|
|
116
112
|
"RateLimitConfig",
|
|
117
|
-
"register_fastapi_config",
|
|
118
113
|
"RequestLoggingConfig",
|
|
119
114
|
"ZensicalDocsConfig",
|
|
120
115
|
]
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def register_fastapi_config(registry: ConfigRegistry) -> None:
|
|
124
|
-
"""Register FastAPI configuration schema with a ConfigRegistry under 'fastapi'.
|
|
125
|
-
|
|
126
|
-
Args:
|
|
127
|
-
registry: Target ConfigRegistry instance.
|
|
128
|
-
|
|
129
|
-
Returns:
|
|
130
|
-
None.
|
|
131
|
-
|
|
132
|
-
Raises:
|
|
133
|
-
None.
|
|
134
|
-
"""
|
|
135
|
-
registry.register_config_section("fastapi", HexastackFastApiConfig)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from hexastack_fastapi.infra.app import create_fastapi_app
|
|
1
2
|
from hexastack_fastapi.infra.autodiscovery import (
|
|
2
3
|
autodiscover_routes,
|
|
3
4
|
create_route_visitor,
|
|
@@ -29,6 +30,7 @@ __all__ = [
|
|
|
29
30
|
"autodiscover_routes",
|
|
30
31
|
"CorrelationHttpMiddleware",
|
|
31
32
|
"CorsConfig",
|
|
33
|
+
"create_fastapi_app",
|
|
32
34
|
"create_route_visitor",
|
|
33
35
|
"FastApiBootstrapper",
|
|
34
36
|
"HealthConfig",
|
|
@@ -9,6 +9,7 @@ from hexastack_core.ports.logging import LoggingPort
|
|
|
9
9
|
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
10
10
|
from hexastack_fastapi.adapters.docs import mount_zensical_docs
|
|
11
11
|
from hexastack_fastapi.adapters.health import create_health_router
|
|
12
|
+
from hexastack_fastapi.adapters.metrics import create_metrics_router
|
|
12
13
|
from hexastack_fastapi.infra.config import HexastackFastApiConfig
|
|
13
14
|
from hexastack_fastapi.infra.exception_handlers import (
|
|
14
15
|
register_exception_handlers,
|
|
@@ -19,6 +20,9 @@ from hexastack_fastapi.infra.middleware.correlation import (
|
|
|
19
20
|
from hexastack_fastapi.infra.middleware.logging import (
|
|
20
21
|
RequestLoggingHttpMiddleware,
|
|
21
22
|
)
|
|
23
|
+
from hexastack_fastapi.infra.middleware.metrics import (
|
|
24
|
+
HttpMetricsMiddleware,
|
|
25
|
+
)
|
|
22
26
|
|
|
23
27
|
__all__ = [
|
|
24
28
|
"create_fastapi_app",
|
|
@@ -122,6 +126,13 @@ def create_fastapi_app(
|
|
|
122
126
|
container=container,
|
|
123
127
|
)
|
|
124
128
|
|
|
129
|
+
# Attach HTTP metrics middleware
|
|
130
|
+
app.add_middleware(
|
|
131
|
+
HttpMetricsMiddleware,
|
|
132
|
+
config=cfg,
|
|
133
|
+
container=container,
|
|
134
|
+
)
|
|
135
|
+
|
|
125
136
|
# Attach correlation middleware
|
|
126
137
|
app.add_middleware(CorrelationHttpMiddleware, config=cfg)
|
|
127
138
|
|
|
@@ -133,6 +144,10 @@ def create_fastapi_app(
|
|
|
133
144
|
health_router = create_health_router(container=container, config=cfg.health)
|
|
134
145
|
app.include_router(health_router)
|
|
135
146
|
|
|
147
|
+
# Mount metrics endpoint
|
|
148
|
+
metrics_router = create_metrics_router(container=container)
|
|
149
|
+
app.include_router(metrics_router)
|
|
150
|
+
|
|
136
151
|
# Mount Zensical documentation site if enabled
|
|
137
152
|
if cfg.zensical.enable:
|
|
138
153
|
mount_zensical_docs(
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/bootstrap.py
RENAMED
|
@@ -35,8 +35,8 @@ class FastApiBootstrapper(BootstrapperPort):
|
|
|
35
35
|
Raises:
|
|
36
36
|
None.
|
|
37
37
|
"""
|
|
38
|
-
from hexastack_fastapi.adapters.app import create_fastapi_app
|
|
39
38
|
from hexastack_fastapi.adapters.routing import CqrsRouter
|
|
39
|
+
from hexastack_fastapi.infra.app import create_fastapi_app
|
|
40
40
|
from hexastack_fastapi.infra.autodiscovery import create_route_visitor
|
|
41
41
|
|
|
42
42
|
cfg = context.get_config("fastapi", HexastackFastApiConfig)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from hexastack_core.infra.decorators import config_section
|
|
2
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
3
|
+
from hexastack_fastapi.domain.config import (
|
|
4
|
+
CorsConfig,
|
|
5
|
+
HealthConfig,
|
|
6
|
+
HexastackFastApiConfig,
|
|
7
|
+
RateLimitConfig,
|
|
8
|
+
RequestLoggingConfig,
|
|
9
|
+
ZensicalDocsConfig,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
config_section("fastapi")(HexastackFastApiConfig)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CorsConfig",
|
|
16
|
+
"HealthConfig",
|
|
17
|
+
"HexastackFastApiConfig",
|
|
18
|
+
"RateLimitConfig",
|
|
19
|
+
"register_fastapi_config",
|
|
20
|
+
"RequestLoggingConfig",
|
|
21
|
+
"ZensicalDocsConfig",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_fastapi_config(registry: ConfigRegistry) -> None:
|
|
26
|
+
"""Register FastAPI configuration schema with a ConfigRegistry under 'fastapi'.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
registry: Target ConfigRegistry instance.
|
|
30
|
+
"""
|
|
31
|
+
registry.register_config_section("fastapi", HexastackFastApiConfig)
|
|
@@ -4,8 +4,12 @@ from hexastack_fastapi.infra.middleware.correlation import (
|
|
|
4
4
|
from hexastack_fastapi.infra.middleware.logging import (
|
|
5
5
|
RequestLoggingHttpMiddleware,
|
|
6
6
|
)
|
|
7
|
+
from hexastack_fastapi.infra.middleware.metrics import (
|
|
8
|
+
HttpMetricsMiddleware,
|
|
9
|
+
)
|
|
7
10
|
|
|
8
11
|
__all__ = [
|
|
9
12
|
"CorrelationHttpMiddleware",
|
|
13
|
+
"HttpMetricsMiddleware",
|
|
10
14
|
"RequestLoggingHttpMiddleware",
|
|
11
15
|
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""ASGI middleware for capturing HTTP request rate and duration metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from rodi import Container
|
|
8
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from hexastack_core.domain.feature_flags import EvaluationContext
|
|
11
|
+
from hexastack_core.ports.feature_flags import FeatureFlagPort
|
|
12
|
+
from hexastack_core.ports.metrics import MetricsPort
|
|
13
|
+
from hexastack_fastapi.infra.config import HexastackFastApiConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HttpMetricsMiddleware:
|
|
17
|
+
"""ASGI middleware recording HTTP RED (Rate, Errors, Duration) metrics.
|
|
18
|
+
|
|
19
|
+
Notes/Architectural Intent:
|
|
20
|
+
Intercepts incoming HTTP transactions, measures duration, and updates
|
|
21
|
+
MetricsPort counters and histograms. Guards metric emission via FeatureFlagPort.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
app: ASGIApp,
|
|
27
|
+
config: HexastackFastApiConfig | None = None,
|
|
28
|
+
container: Container | None = None,
|
|
29
|
+
metrics: MetricsPort | None = None,
|
|
30
|
+
flags: FeatureFlagPort | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize HTTP metrics middleware.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
app: Downstream ASGI application.
|
|
36
|
+
config: Optional HexastackFastApiConfig instance.
|
|
37
|
+
container: Optional rodi Container to dynamically resolve MetricsPort / FeatureFlagPort.
|
|
38
|
+
metrics: Optional pre-configured MetricsPort instance.
|
|
39
|
+
flags: Optional FeatureFlagPort instance.
|
|
40
|
+
"""
|
|
41
|
+
self._app = app
|
|
42
|
+
self._cfg = config or HexastackFastApiConfig()
|
|
43
|
+
self._container = container
|
|
44
|
+
self._metrics = metrics
|
|
45
|
+
self._flags = flags
|
|
46
|
+
self._exclude_paths = set(self._cfg.logging.exclude_paths)
|
|
47
|
+
|
|
48
|
+
def _get_metrics(self) -> MetricsPort | None:
|
|
49
|
+
if self._metrics is not None:
|
|
50
|
+
return self._metrics
|
|
51
|
+
if self._container is not None and MetricsPort in self._container:
|
|
52
|
+
return self._container.resolve(MetricsPort)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def _get_flags(self) -> FeatureFlagPort | None:
|
|
56
|
+
if self._flags is not None:
|
|
57
|
+
return self._flags
|
|
58
|
+
if self._container is not None and FeatureFlagPort in self._container:
|
|
59
|
+
return self._container.resolve(FeatureFlagPort)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
63
|
+
"""Intercept request, measure duration, and record metrics."""
|
|
64
|
+
if scope["type"] != "http":
|
|
65
|
+
await self._app(scope, receive, send)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
path = scope.get("path", "/")
|
|
69
|
+
if path in self._exclude_paths or path == "/metrics":
|
|
70
|
+
await self._app(scope, receive, send)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
metrics = self._get_metrics()
|
|
74
|
+
if metrics is None:
|
|
75
|
+
await self._app(scope, receive, send)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
flags = self._get_flags()
|
|
79
|
+
if flags is not None:
|
|
80
|
+
eval_ctx = EvaluationContext.from_current_context()
|
|
81
|
+
if not flags.is_enabled(
|
|
82
|
+
"features.metrics.http", default=True, context=eval_ctx
|
|
83
|
+
):
|
|
84
|
+
await self._app(scope, receive, send)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
method = scope.get("method", "GET")
|
|
88
|
+
status_code = 500
|
|
89
|
+
start_time = time.perf_counter()
|
|
90
|
+
|
|
91
|
+
async def send_wrapper(message: Message) -> None:
|
|
92
|
+
nonlocal status_code
|
|
93
|
+
if message["type"] == "http.response.start":
|
|
94
|
+
status_code = message.get("status", 500)
|
|
95
|
+
await send(message)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
await self._app(scope, receive, send_wrapper)
|
|
99
|
+
finally:
|
|
100
|
+
duration = time.perf_counter() - start_time
|
|
101
|
+
labels = {
|
|
102
|
+
"method": method,
|
|
103
|
+
"path": path,
|
|
104
|
+
"status_code": str(status_code),
|
|
105
|
+
}
|
|
106
|
+
metrics.increment_counter(
|
|
107
|
+
"http_requests_total",
|
|
108
|
+
value=1.0,
|
|
109
|
+
labels=labels,
|
|
110
|
+
description="Total HTTP requests processed",
|
|
111
|
+
)
|
|
112
|
+
metrics.record_histogram(
|
|
113
|
+
"http_request_duration_seconds",
|
|
114
|
+
value=duration,
|
|
115
|
+
labels={"method": method, "path": path},
|
|
116
|
+
description="HTTP request duration distribution in seconds",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"HttpMetricsMiddleware",
|
|
122
|
+
]
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/db_session.py
RENAMED
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/dependencies.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/adapters/ratelimit.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/autodiscovery.py
RENAMED
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/infra/decorators.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/testing/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.2.0 → hexastack_fastapi-0.3.0}/src/hexastack_fastapi/testing/recorder.py
RENAMED
|
File without changes
|
|
File without changes
|