hexastack-fastapi 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hexastack-fastapi might be problematic. Click here for more details.
- hexastack_fastapi/__init__.py +6 -0
- hexastack_fastapi/adapters/__init__.py +41 -0
- hexastack_fastapi/adapters/app.py +118 -0
- hexastack_fastapi/adapters/db_session.py +160 -0
- hexastack_fastapi/adapters/dependencies.py +190 -0
- hexastack_fastapi/adapters/docs.py +80 -0
- hexastack_fastapi/adapters/health.py +62 -0
- hexastack_fastapi/adapters/routing.py +203 -0
- hexastack_fastapi/adapters/ui.py +491 -0
- hexastack_fastapi/infra/__init__.py +41 -0
- hexastack_fastapi/infra/autodiscovery.py +103 -0
- hexastack_fastapi/infra/bootstrap.py +85 -0
- hexastack_fastapi/infra/config.py +113 -0
- hexastack_fastapi/infra/decorators.py +199 -0
- hexastack_fastapi/infra/exception_handlers.py +60 -0
- hexastack_fastapi/infra/middleware/__init__.py +11 -0
- hexastack_fastapi/infra/middleware/correlation.py +100 -0
- hexastack_fastapi/infra/middleware/logging.py +95 -0
- hexastack_fastapi/py.typed +0 -0
- hexastack_fastapi/testing/__init__.py +18 -0
- hexastack_fastapi/testing/cursor.py +115 -0
- hexastack_fastapi/testing/recorder.py +238 -0
- hexastack_fastapi/testing/server.py +120 -0
- hexastack_fastapi-0.0.0.dist-info/METADATA +169 -0
- hexastack_fastapi-0.0.0.dist-info/RECORD +27 -0
- hexastack_fastapi-0.0.0.dist-info/WHEEL +4 -0
- hexastack_fastapi-0.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from hexastack_fastapi.adapters.app import create_fastapi_app
|
|
2
|
+
from hexastack_fastapi.adapters.dependencies import (
|
|
3
|
+
check_openapi_conformance,
|
|
4
|
+
create_test_client,
|
|
5
|
+
get_container,
|
|
6
|
+
get_feature_flags,
|
|
7
|
+
get_pipeline,
|
|
8
|
+
require_feature,
|
|
9
|
+
)
|
|
10
|
+
from hexastack_fastapi.adapters.docs import (
|
|
11
|
+
DocumentationNotFoundError,
|
|
12
|
+
mount_zensical_docs,
|
|
13
|
+
)
|
|
14
|
+
from hexastack_fastapi.adapters.health import create_health_router
|
|
15
|
+
from hexastack_fastapi.adapters.routing import CqrsRouter
|
|
16
|
+
from hexastack_fastapi.adapters.ui import (
|
|
17
|
+
dispatch_command,
|
|
18
|
+
dispatch_query,
|
|
19
|
+
mount_devtools_dashboard,
|
|
20
|
+
mount_ui_app,
|
|
21
|
+
ui_page,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"check_openapi_conformance",
|
|
26
|
+
"CqrsRouter",
|
|
27
|
+
"create_fastapi_app",
|
|
28
|
+
"create_health_router",
|
|
29
|
+
"create_test_client",
|
|
30
|
+
"dispatch_command",
|
|
31
|
+
"dispatch_query",
|
|
32
|
+
"DocumentationNotFoundError",
|
|
33
|
+
"get_container",
|
|
34
|
+
"get_feature_flags",
|
|
35
|
+
"get_pipeline",
|
|
36
|
+
"mount_devtools_dashboard",
|
|
37
|
+
"mount_ui_app",
|
|
38
|
+
"mount_zensical_docs",
|
|
39
|
+
"require_feature",
|
|
40
|
+
"ui_page",
|
|
41
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
from rodi import Container
|
|
6
|
+
|
|
7
|
+
from hexastack_core.infra.registries.exception import ExceptionRegistry
|
|
8
|
+
from hexastack_core.ports.logging import LoggingPort
|
|
9
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
10
|
+
from hexastack_fastapi.adapters.docs import mount_zensical_docs
|
|
11
|
+
from hexastack_fastapi.adapters.health import create_health_router
|
|
12
|
+
from hexastack_fastapi.infra.config import HexastackFastApiConfig
|
|
13
|
+
from hexastack_fastapi.infra.exception_handlers import (
|
|
14
|
+
register_exception_handlers,
|
|
15
|
+
)
|
|
16
|
+
from hexastack_fastapi.infra.middleware.correlation import (
|
|
17
|
+
CorrelationHttpMiddleware,
|
|
18
|
+
)
|
|
19
|
+
from hexastack_fastapi.infra.middleware.logging import (
|
|
20
|
+
RequestLoggingHttpMiddleware,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"create_fastapi_app",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_fastapi_app(
|
|
29
|
+
config: HexastackFastApiConfig | None = None,
|
|
30
|
+
container: Container | None = None,
|
|
31
|
+
pipeline: ExecutionPipeline | None = None,
|
|
32
|
+
exception_registry: ExceptionRegistry | None = None,
|
|
33
|
+
) -> FastAPI:
|
|
34
|
+
"""Factory creating and configuring a FastAPI application integrated with Hexastack.
|
|
35
|
+
|
|
36
|
+
Notes/Architectural Intent:
|
|
37
|
+
Assembles FastAPI instance with OpenAPI documentation metadata, CORS middleware,
|
|
38
|
+
Correlation ID ASGI middleware, access logging, health check probes, Zensical docs mounting,
|
|
39
|
+
unified exception mapping, and DI container lifecycle management.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: Optional HexastackFastApiConfig instance.
|
|
43
|
+
container: Optional rodi Container instance.
|
|
44
|
+
pipeline: Optional ExecutionPipeline instance.
|
|
45
|
+
exception_registry: Optional ExceptionRegistry for domain error mapping.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Configured FastAPI application instance.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
None.
|
|
52
|
+
"""
|
|
53
|
+
cfg = config or HexastackFastApiConfig()
|
|
54
|
+
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def lifespan(app: FastAPI):
|
|
57
|
+
yield
|
|
58
|
+
# Teardown background resources
|
|
59
|
+
if container is not None and LoggingPort in container:
|
|
60
|
+
logger = container.resolve(LoggingPort)
|
|
61
|
+
close_fn = getattr(logger, "close", None)
|
|
62
|
+
if callable(close_fn):
|
|
63
|
+
close_fn()
|
|
64
|
+
|
|
65
|
+
app = FastAPI(
|
|
66
|
+
title=cfg.title,
|
|
67
|
+
version=cfg.version,
|
|
68
|
+
description=cfg.description,
|
|
69
|
+
docs_url=cfg.docs_url,
|
|
70
|
+
redoc_url=cfg.redoc_url,
|
|
71
|
+
openapi_url=cfg.openapi_url,
|
|
72
|
+
lifespan=lifespan,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Attach container and pipeline to application state
|
|
76
|
+
if container is not None:
|
|
77
|
+
app.state.container = container
|
|
78
|
+
if pipeline is not None:
|
|
79
|
+
app.state.pipeline = pipeline
|
|
80
|
+
|
|
81
|
+
# Attach CORS middleware if enabled
|
|
82
|
+
if cfg.cors.enable:
|
|
83
|
+
app.add_middleware(
|
|
84
|
+
CORSMiddleware,
|
|
85
|
+
allow_origins=cfg.cors.allow_origins,
|
|
86
|
+
allow_credentials=cfg.cors.allow_credentials,
|
|
87
|
+
allow_methods=cfg.cors.allow_methods,
|
|
88
|
+
allow_headers=cfg.cors.allow_headers,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Attach request logging middleware if enabled
|
|
92
|
+
if cfg.logging.enable:
|
|
93
|
+
app.add_middleware(
|
|
94
|
+
RequestLoggingHttpMiddleware,
|
|
95
|
+
config=cfg,
|
|
96
|
+
container=container,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Attach correlation middleware
|
|
100
|
+
app.add_middleware(CorrelationHttpMiddleware, config=cfg)
|
|
101
|
+
|
|
102
|
+
# Register unified exception handlers
|
|
103
|
+
register_exception_handlers(app, exception_registry=exception_registry)
|
|
104
|
+
|
|
105
|
+
# Mount health and readiness endpoints if enabled
|
|
106
|
+
if cfg.health.enable:
|
|
107
|
+
health_router = create_health_router(container=container, config=cfg.health)
|
|
108
|
+
app.include_router(health_router)
|
|
109
|
+
|
|
110
|
+
# Mount Zensical documentation site if enabled
|
|
111
|
+
if cfg.zensical.enable:
|
|
112
|
+
mount_zensical_docs(
|
|
113
|
+
app=app,
|
|
114
|
+
path=cfg.zensical.path,
|
|
115
|
+
site_dir=cfg.zensical.site_dir,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return app
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""SQLAlchemy session-per-request middleware for FastAPI.
|
|
2
|
+
|
|
3
|
+
Decoupling strategy:
|
|
4
|
+
This module depends only on SQLAlchemy (already a transitive dep via hexastack-db)
|
|
5
|
+
and Starlette (already a direct dep via FastAPI). It never imports from hexastack-db.
|
|
6
|
+
Session factories are resolved from the DI container by SQLAlchemy type at runtime,
|
|
7
|
+
so the two packages remain fully independent — the DI container is the only coupling.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# In your app setup (after bootstrapping both DatabaseBootstrapper + FastApiBootstrapper):
|
|
11
|
+
from hexastack_fastapi.adapters.db_session import (
|
|
12
|
+
DbSessionMiddleware,
|
|
13
|
+
AsyncDbSessionMiddleware,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Sync engine:
|
|
17
|
+
app.add_middleware(DbSessionMiddleware, session_factory=session_factory)
|
|
18
|
+
|
|
19
|
+
# Async engine:
|
|
20
|
+
app.add_middleware(AsyncDbSessionMiddleware, session_factory=async_session_factory)
|
|
21
|
+
|
|
22
|
+
# In a handler or dependency:
|
|
23
|
+
def my_endpoint(request: Request):
|
|
24
|
+
session = request.state.db_session # Session | AsyncSession
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
from starlette.responses import Response
|
|
35
|
+
from starlette.types import ASGIApp
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DbSessionMiddleware(BaseHTTPMiddleware):
|
|
39
|
+
"""Synchronous SQLAlchemy session-per-request Starlette middleware.
|
|
40
|
+
|
|
41
|
+
Notes/Architectural Intent:
|
|
42
|
+
Creates a new Session for every HTTP request and exposes it as
|
|
43
|
+
request.state.db_session. The session is always closed in the
|
|
44
|
+
finally block regardless of response outcome. No commit is issued
|
|
45
|
+
automatically — handlers own transaction boundaries via UnitOfWork.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
app: ASGIApp,
|
|
51
|
+
session_factory: Callable[[], Any],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialise with a synchronous sessionmaker callable.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
app: The ASGI application to wrap.
|
|
57
|
+
session_factory: A callable returning a SQLAlchemy Session
|
|
58
|
+
(e.g. the sessionmaker instance returned by create_session_factory).
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(app)
|
|
61
|
+
self._session_factory = session_factory
|
|
62
|
+
|
|
63
|
+
async def dispatch(
|
|
64
|
+
self, request: Request, call_next: Callable[..., Any]
|
|
65
|
+
) -> Response:
|
|
66
|
+
"""Attach a fresh session to each request; close it after response.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
request: Incoming Starlette Request.
|
|
70
|
+
call_next: Next middleware / route handler in the chain.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The HTTP Response from the downstream handler.
|
|
74
|
+
"""
|
|
75
|
+
session = self._session_factory()
|
|
76
|
+
request.state.db_session = session
|
|
77
|
+
try:
|
|
78
|
+
response = await call_next(request)
|
|
79
|
+
finally:
|
|
80
|
+
session.close()
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AsyncDbSessionMiddleware(BaseHTTPMiddleware):
|
|
85
|
+
"""Asynchronous SQLAlchemy session-per-request Starlette middleware.
|
|
86
|
+
|
|
87
|
+
Notes/Architectural Intent:
|
|
88
|
+
Creates a new AsyncSession for every HTTP request via an async_sessionmaker,
|
|
89
|
+
exposing it as request.state.db_session. Handlers own commit/rollback via
|
|
90
|
+
AsyncSqlAlchemyUnitOfWork — this middleware only manages lifecycle (open/close).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
app: ASGIApp,
|
|
96
|
+
session_factory: Callable[[], Any],
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Initialise with an async_sessionmaker callable.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
app: The ASGI application to wrap.
|
|
102
|
+
session_factory: A callable returning an AsyncSession
|
|
103
|
+
(e.g. the async_sessionmaker returned by create_async_session_factory).
|
|
104
|
+
"""
|
|
105
|
+
super().__init__(app)
|
|
106
|
+
self._session_factory = session_factory
|
|
107
|
+
|
|
108
|
+
async def dispatch(
|
|
109
|
+
self, request: Request, call_next: Callable[..., Any]
|
|
110
|
+
) -> Response:
|
|
111
|
+
"""Attach a fresh AsyncSession to each request; close it after response.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
request: Incoming Starlette Request.
|
|
115
|
+
call_next: Next middleware / route handler in the chain.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The HTTP Response from the downstream handler.
|
|
119
|
+
"""
|
|
120
|
+
session = self._session_factory()
|
|
121
|
+
request.state.db_session = session
|
|
122
|
+
try:
|
|
123
|
+
response = await call_next(request)
|
|
124
|
+
finally:
|
|
125
|
+
await session.close()
|
|
126
|
+
return response
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
"add_db_session_middleware",
|
|
131
|
+
"AsyncDbSessionMiddleware",
|
|
132
|
+
"DbSessionMiddleware",
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def add_db_session_middleware(
|
|
137
|
+
app: Any,
|
|
138
|
+
session_factory: Callable[[], Any],
|
|
139
|
+
*,
|
|
140
|
+
async_mode: bool = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Convenience helper to attach the correct session middleware to a FastAPI app.
|
|
143
|
+
|
|
144
|
+
Notes/Architectural Intent:
|
|
145
|
+
Selects DbSessionMiddleware or AsyncDbSessionMiddleware based on async_mode.
|
|
146
|
+
Intended to be called from the FastAPI bootstrapper or app factory when
|
|
147
|
+
a session_factory is present in the DI container.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
app: FastAPI / Starlette application instance.
|
|
151
|
+
session_factory: Sessionmaker callable (sync or async).
|
|
152
|
+
async_mode: If True, use AsyncDbSessionMiddleware.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
None.
|
|
156
|
+
"""
|
|
157
|
+
if async_mode:
|
|
158
|
+
app.add_middleware(AsyncDbSessionMiddleware, session_factory=session_factory)
|
|
159
|
+
else:
|
|
160
|
+
app.add_middleware(DbSessionMiddleware, session_factory=session_factory)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, Request, status
|
|
4
|
+
from rodi import Container
|
|
5
|
+
|
|
6
|
+
from hexastack_core.adapters.feature_flags.config import ConfigFeatureFlagAdapter
|
|
7
|
+
from hexastack_core.adapters.feature_flags.in_memory import InMemoryFeatureFlagAdapter
|
|
8
|
+
from hexastack_core.domain.exceptions import (
|
|
9
|
+
DependencyResolutionError,
|
|
10
|
+
MissingDependencyError,
|
|
11
|
+
)
|
|
12
|
+
from hexastack_core.domain.feature_flags import EvaluationContext
|
|
13
|
+
from hexastack_core.ports.feature_flags import FeatureFlagPort
|
|
14
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"check_openapi_conformance",
|
|
18
|
+
"create_test_client",
|
|
19
|
+
"get_container",
|
|
20
|
+
"get_feature_flags",
|
|
21
|
+
"get_pipeline",
|
|
22
|
+
"require_feature",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_openapi_conformance(
|
|
27
|
+
app: Any,
|
|
28
|
+
*,
|
|
29
|
+
schema_url: str = "/openapi.json",
|
|
30
|
+
validate_schema: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Run Schemathesis contract conformance and schema validation checks against a FastAPI app.
|
|
33
|
+
|
|
34
|
+
Notes/Architectural Intent:
|
|
35
|
+
Automatically tests that all exposed FastAPI endpoints conform to the
|
|
36
|
+
derived OpenAPI schema, validate properly, and have complete route definitions.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
app: The FastAPI application instance.
|
|
40
|
+
schema_url: URL path where the OpenAPI JSON is served (defaults to '/openapi.json').
|
|
41
|
+
validate_schema: Whether to validate the OpenAPI specification structure itself.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
MissingDependencyError: If `schemathesis` is not installed.
|
|
45
|
+
AssertionError: If any API contract violation or unhandled error is detected.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
import schemathesis
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
raise MissingDependencyError(
|
|
51
|
+
"schemathesis is required for check_openapi_conformance. "
|
|
52
|
+
"Install with 'pip install schemathesis' or 'pip install hexastack[testing]'."
|
|
53
|
+
) from e
|
|
54
|
+
|
|
55
|
+
schema = schemathesis.openapi.from_asgi(schema_url, app)
|
|
56
|
+
|
|
57
|
+
if validate_schema:
|
|
58
|
+
schema.validate()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_test_client(
|
|
62
|
+
app: Any,
|
|
63
|
+
*,
|
|
64
|
+
flags: dict[str, Any] | None = None,
|
|
65
|
+
**test_client_kwargs: Any,
|
|
66
|
+
) -> Any:
|
|
67
|
+
"""Create a FastAPI TestClient configured with an in-memory feature flag adapter.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
app: The FastAPI application instance.
|
|
71
|
+
flags: Optional initial flag states.
|
|
72
|
+
**test_client_kwargs: Extra keyword arguments forwarded to TestClient.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Configured starlette / fastapi TestClient.
|
|
76
|
+
"""
|
|
77
|
+
from fastapi.testclient import TestClient
|
|
78
|
+
|
|
79
|
+
if flags is not None:
|
|
80
|
+
container = getattr(app.state, "container", None)
|
|
81
|
+
flags_adapter = InMemoryFeatureFlagAdapter(flags=flags)
|
|
82
|
+
if container is not None:
|
|
83
|
+
container.add_instance(flags_adapter, declared_class=FeatureFlagPort)
|
|
84
|
+
|
|
85
|
+
return TestClient(app, **test_client_kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_container(request: Request) -> Container:
|
|
89
|
+
"""FastAPI dependency provider resolving the active rodi Container instance.
|
|
90
|
+
|
|
91
|
+
Notes/Architectural Intent:
|
|
92
|
+
Enables endpoint routes to inject and resolve registered domain ports and services.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
request: Incoming FastAPI Request object.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The rodi Container attached to the application state.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
DependencyResolutionError: If Container has not been attached to app.state.
|
|
102
|
+
"""
|
|
103
|
+
container = getattr(request.app.state, "container", None)
|
|
104
|
+
if container is None or not isinstance(container, Container):
|
|
105
|
+
raise DependencyResolutionError(
|
|
106
|
+
"rodi.Container is not configured on request.app.state.container."
|
|
107
|
+
)
|
|
108
|
+
return container
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_feature_flags(request: Request) -> FeatureFlagPort:
|
|
112
|
+
"""FastAPI dependency provider resolving the active FeatureFlagPort instance.
|
|
113
|
+
|
|
114
|
+
Notes/Architectural Intent:
|
|
115
|
+
Retrieves the registered FeatureFlagPort from the application container or
|
|
116
|
+
falls back to ConfigFeatureFlagAdapter.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
request: Incoming FastAPI Request object.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The FeatureFlagPort instance.
|
|
123
|
+
"""
|
|
124
|
+
container = getattr(request.app.state, "container", None)
|
|
125
|
+
if isinstance(container, Container) and FeatureFlagPort in container:
|
|
126
|
+
return container.resolve(FeatureFlagPort)
|
|
127
|
+
return ConfigFeatureFlagAdapter()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_pipeline(request: Request) -> ExecutionPipeline:
|
|
131
|
+
"""FastAPI dependency provider resolving the active ExecutionPipeline instance.
|
|
132
|
+
|
|
133
|
+
Notes/Architectural Intent:
|
|
134
|
+
Enables route handlers to execute CQRS commands and queries through the unified pipeline.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
request: Incoming FastAPI Request object.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The ExecutionPipeline instance attached to application state or resolved from DI.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
DependencyResolutionError: If ExecutionPipeline cannot be resolved from application state or container.
|
|
144
|
+
"""
|
|
145
|
+
pipeline = getattr(request.app.state, "pipeline", None)
|
|
146
|
+
if isinstance(pipeline, ExecutionPipeline):
|
|
147
|
+
return pipeline
|
|
148
|
+
|
|
149
|
+
container = getattr(request.app.state, "container", None)
|
|
150
|
+
if isinstance(container, Container) and ExecutionPipeline in container:
|
|
151
|
+
return container.resolve(ExecutionPipeline)
|
|
152
|
+
|
|
153
|
+
raise DependencyResolutionError(
|
|
154
|
+
"ExecutionPipeline is not available on request.app.state.pipeline or container."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_feature(
|
|
159
|
+
flag_key: str,
|
|
160
|
+
*,
|
|
161
|
+
default: bool = False,
|
|
162
|
+
status_code: int = status.HTTP_404_NOT_FOUND,
|
|
163
|
+
detail: str | None = None,
|
|
164
|
+
) -> Any:
|
|
165
|
+
"""Create a FastAPI dependency that enforces a feature flag is enabled.
|
|
166
|
+
|
|
167
|
+
Notes/Architectural Intent:
|
|
168
|
+
Evaluates the feature flag dynamically against ambient UserContext / request state.
|
|
169
|
+
If disabled, raises an HTTPException (default 404 Not Found) to cleanly hide or gate routes.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
flag_key: Unique identifier of the feature flag to check.
|
|
173
|
+
default: Fallback boolean value if flag is not explicitly configured.
|
|
174
|
+
status_code: HTTP status code to return when disabled (defaults to 404).
|
|
175
|
+
detail: Optional error detail message.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
A FastAPI dependency callable.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
async def _dependency(request: Request) -> None:
|
|
182
|
+
flags = get_feature_flags(request)
|
|
183
|
+
eval_ctx = EvaluationContext.from_current_context()
|
|
184
|
+
if not flags.is_enabled(flag_key, default=default, context=eval_ctx):
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=status_code,
|
|
187
|
+
detail=detail or f"Feature '{flag_key}' is disabled.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return _dependency
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Zensical and static documentation mounting adapter for FastAPI.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides a declarative helper to serve pre-built Zensical or static HTML documentation
|
|
5
|
+
directly alongside FastAPI API routes:
|
|
6
|
+
1. Supports configurable mount paths (e.g. `/guide`, `/docs-site`, `/handbook`) to avoid
|
|
7
|
+
conflicts with FastAPI's native OpenAPI Swagger UI (`/docs`) and ReDoc (`/redoc`).
|
|
8
|
+
2. Allows custom fallback and index.html serving with static directory validation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
|
|
19
|
+
from hexastack_core.domain.exceptions import HexastackError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DocumentationNotFoundError(HexastackError):
|
|
23
|
+
"""Raised when configured documentation directory does not exist."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mount_zensical_docs(
|
|
27
|
+
app: FastAPI,
|
|
28
|
+
path: str = "/guide",
|
|
29
|
+
site_dir: str | Path = "site",
|
|
30
|
+
name: str = "zensical-docs",
|
|
31
|
+
html: bool = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Mount a pre-built Zensical static documentation site onto a FastAPI application.
|
|
34
|
+
|
|
35
|
+
Notes/Architectural Intent:
|
|
36
|
+
FastAPI defaults OpenAPI Swagger UI to `/docs` and ReDoc to `/redoc`.
|
|
37
|
+
Mounting Zensical under a configurable path (defaulting to `/guide`) enables
|
|
38
|
+
teams to serve complete developer guides, architecture diagrams, and tutorials
|
|
39
|
+
from the exact same microservice process without routing collisions.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
app: Target FastAPI application.
|
|
43
|
+
path: URL prefix where documentation will be served (e.g. "/guide" or "/manual").
|
|
44
|
+
site_dir: Path to directory containing built HTML files (typically "site" from `zensical build`).
|
|
45
|
+
name: Internal ASGI mount name.
|
|
46
|
+
html: Enable HTML mode (auto-resolving directory paths to index.html).
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
DocumentationNotFoundError: If site_dir does not exist and is required.
|
|
50
|
+
"""
|
|
51
|
+
resolved_dir = Path(site_dir).resolve()
|
|
52
|
+
|
|
53
|
+
if not resolved_dir.exists():
|
|
54
|
+
if os.environ.get("ENVIRONMENT", "").lower() in ("prod", "production"):
|
|
55
|
+
raise DocumentationNotFoundError(
|
|
56
|
+
f"Zensical documentation directory '{resolved_dir}' not found. "
|
|
57
|
+
f"Run 'zensical build' before launching the production server."
|
|
58
|
+
)
|
|
59
|
+
# In development/test mode, create the directory if missing to allow graceful startup
|
|
60
|
+
resolved_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
index_file = resolved_dir / "index.html"
|
|
62
|
+
if not index_file.exists():
|
|
63
|
+
index_file.write_text(
|
|
64
|
+
"<!DOCTYPE html><html><body><h1>Documentation building...</h1>"
|
|
65
|
+
"<p>Run <code>zensical build</code> to compile docs.</p></body></html>",
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
normalized_path = "/" + path.strip("/")
|
|
70
|
+
app.mount(
|
|
71
|
+
normalized_path,
|
|
72
|
+
StaticFiles(directory=str(resolved_dir), html=html),
|
|
73
|
+
name=name,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"DocumentationNotFoundError",
|
|
79
|
+
"mount_zensical_docs",
|
|
80
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from rodi import Container
|
|
7
|
+
|
|
8
|
+
from hexastack_core.utils.context import get_correlation_id
|
|
9
|
+
from hexastack_fastapi.infra.config import HealthConfig
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"create_health_router",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_health_router(
|
|
17
|
+
container: Container | None = None,
|
|
18
|
+
config: HealthConfig | None = None,
|
|
19
|
+
) -> APIRouter:
|
|
20
|
+
"""Create APIRouter with standard liveness and readiness probe endpoints.
|
|
21
|
+
|
|
22
|
+
Notes/Architectural Intent:
|
|
23
|
+
Exposes lightweight /health (liveness) and /ready (readiness) endpoints for orchestration
|
|
24
|
+
platforms like Kubernetes and container engines.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
container: Optional rodi Container instance to check dependency health.
|
|
28
|
+
config: Optional HealthConfig specifying custom URL paths.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
APIRouter mounted with health endpoints.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
None.
|
|
35
|
+
"""
|
|
36
|
+
cfg = config or HealthConfig()
|
|
37
|
+
router = APIRouter(tags=["Health"])
|
|
38
|
+
|
|
39
|
+
@router.get(cfg.health_path, summary="Liveness Probe")
|
|
40
|
+
async def liveness() -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"status": "ok",
|
|
43
|
+
"timestamp": datetime.now(tz=UTC).isoformat(),
|
|
44
|
+
"correlation_id": get_correlation_id(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@router.get(cfg.ready_path, summary="Readiness Probe")
|
|
48
|
+
async def readiness() -> JSONResponse:
|
|
49
|
+
checks: dict[str, str] = {
|
|
50
|
+
"container": "ok" if container is not None else "unconfigured",
|
|
51
|
+
}
|
|
52
|
+
status_ok = all(v == "ok" for v in checks.values())
|
|
53
|
+
payload: dict[str, Any] = {
|
|
54
|
+
"status": "ready" if status_ok else "unhealthy",
|
|
55
|
+
"timestamp": datetime.now(tz=UTC).isoformat(),
|
|
56
|
+
"correlation_id": get_correlation_id(),
|
|
57
|
+
"checks": checks,
|
|
58
|
+
}
|
|
59
|
+
status_code = 200 if status_ok else 503
|
|
60
|
+
return JSONResponse(status_code=status_code, content=payload)
|
|
61
|
+
|
|
62
|
+
return router
|