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.infra.autodiscovery import (
|
|
2
|
+
autodiscover_routes,
|
|
3
|
+
create_route_visitor,
|
|
4
|
+
)
|
|
5
|
+
from hexastack_fastapi.infra.bootstrap import FastApiBootstrapper
|
|
6
|
+
from hexastack_fastapi.infra.config import (
|
|
7
|
+
CorsConfig,
|
|
8
|
+
HealthConfig,
|
|
9
|
+
HexastackFastApiConfig,
|
|
10
|
+
RequestLoggingConfig,
|
|
11
|
+
register_fastapi_config,
|
|
12
|
+
)
|
|
13
|
+
from hexastack_fastapi.infra.decorators import (
|
|
14
|
+
RouteMetadata,
|
|
15
|
+
api_command,
|
|
16
|
+
api_query,
|
|
17
|
+
)
|
|
18
|
+
from hexastack_fastapi.infra.exception_handlers import (
|
|
19
|
+
register_exception_handlers,
|
|
20
|
+
)
|
|
21
|
+
from hexastack_fastapi.infra.middleware import (
|
|
22
|
+
CorrelationHttpMiddleware,
|
|
23
|
+
RequestLoggingHttpMiddleware,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"api_command",
|
|
28
|
+
"api_query",
|
|
29
|
+
"autodiscover_routes",
|
|
30
|
+
"CorrelationHttpMiddleware",
|
|
31
|
+
"CorsConfig",
|
|
32
|
+
"create_route_visitor",
|
|
33
|
+
"FastApiBootstrapper",
|
|
34
|
+
"HealthConfig",
|
|
35
|
+
"HexastackFastApiConfig",
|
|
36
|
+
"register_exception_handlers",
|
|
37
|
+
"register_fastapi_config",
|
|
38
|
+
"RequestLoggingConfig",
|
|
39
|
+
"RequestLoggingHttpMiddleware",
|
|
40
|
+
"RouteMetadata",
|
|
41
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from hexastack_core.domain import Command, Query
|
|
9
|
+
from hexastack_core.infra.autodiscovery import (
|
|
10
|
+
DiscoveryVisitor,
|
|
11
|
+
scan_modules,
|
|
12
|
+
)
|
|
13
|
+
from hexastack_fastapi.adapters.routing import CqrsRouter
|
|
14
|
+
from hexastack_fastapi.infra.decorators import (
|
|
15
|
+
_ROUTE_METADATA_ATTR,
|
|
16
|
+
RouteMetadata,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"autodiscover_routes",
|
|
21
|
+
"create_route_visitor",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def autodiscover_routes(
|
|
26
|
+
app: FastAPI,
|
|
27
|
+
packages_to_scan: Sequence[str | ModuleType],
|
|
28
|
+
router: CqrsRouter | None = None,
|
|
29
|
+
) -> CqrsRouter:
|
|
30
|
+
"""Automatically discover decorated routes from packages and attach them to a FastAPI application.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
app: Target FastAPI application instance.
|
|
34
|
+
packages_to_scan: Sequence of package names or module objects to inspect.
|
|
35
|
+
router: Optional CqrsRouter instance to populate and mount.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The mounted CqrsRouter instance.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
None.
|
|
42
|
+
"""
|
|
43
|
+
target_router = router or CqrsRouter()
|
|
44
|
+
visitor = create_route_visitor(target_router)
|
|
45
|
+
scan_modules(packages_to_scan, [visitor])
|
|
46
|
+
app.include_router(target_router)
|
|
47
|
+
return target_router
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_route_visitor(router: CqrsRouter) -> DiscoveryVisitor:
|
|
51
|
+
"""Create a DiscoveryVisitor callback for single-pass HTTP route registration.
|
|
52
|
+
|
|
53
|
+
Notes/Architectural Intent:
|
|
54
|
+
Inspects discovered classes for @api_command and @api_query metadata,
|
|
55
|
+
mounting them onto the supplied CqrsRouter without duplicate reflection loops.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
router: Target CqrsRouter instance.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
DiscoveryVisitor callable accepting (member, module).
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
None.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def visitor(obj: Any, module: ModuleType) -> None:
|
|
68
|
+
if not inspect.isclass(obj):
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
meta: RouteMetadata | None = getattr(obj, _ROUTE_METADATA_ATTR, None)
|
|
72
|
+
if meta is None:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
tags = list(meta.tags) if meta.tags else None
|
|
76
|
+
if (
|
|
77
|
+
meta.kind == "command"
|
|
78
|
+
and isinstance(obj, type)
|
|
79
|
+
and issubclass(obj, Command)
|
|
80
|
+
):
|
|
81
|
+
router.add_command(
|
|
82
|
+
path=meta.path,
|
|
83
|
+
command_cls=obj,
|
|
84
|
+
method=meta.method,
|
|
85
|
+
status_code=meta.status_code,
|
|
86
|
+
output_format=meta.output_format,
|
|
87
|
+
summary=meta.summary,
|
|
88
|
+
tags=tags,
|
|
89
|
+
feature_flag=meta.feature_flag,
|
|
90
|
+
)
|
|
91
|
+
elif meta.kind == "query" and isinstance(obj, type) and issubclass(obj, Query):
|
|
92
|
+
router.add_query(
|
|
93
|
+
path=meta.path,
|
|
94
|
+
query_cls=obj,
|
|
95
|
+
method=meta.method,
|
|
96
|
+
status_code=meta.status_code,
|
|
97
|
+
output_format=meta.output_format,
|
|
98
|
+
summary=meta.summary,
|
|
99
|
+
tags=tags,
|
|
100
|
+
feature_flag=meta.feature_flag,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return visitor
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
|
|
3
|
+
from hexastack_core.infra.bootstrap import BootstrapContext
|
|
4
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
5
|
+
from hexastack_core.infra.registries.exception import ExceptionRegistry
|
|
6
|
+
from hexastack_core.ports.bootstrap import BootstrapperPort
|
|
7
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
8
|
+
from hexastack_fastapi.infra.config import (
|
|
9
|
+
HexastackFastApiConfig,
|
|
10
|
+
register_fastapi_config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FastApiBootstrapper(BootstrapperPort):
|
|
15
|
+
"""Bootstrap extension configuring FastAPI web presentation layer.
|
|
16
|
+
|
|
17
|
+
Notes/Architectural Intent:
|
|
18
|
+
Implements BootstrapperPort for hexastack-fastapi, registering 'fastapi'
|
|
19
|
+
configuration in Phase 1 and creating/binding the FastAPI application instance
|
|
20
|
+
in Phase 2 after logging (order=10) and CQRS pipeline (order=20).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str = "fastapi"
|
|
24
|
+
order: int = 30
|
|
25
|
+
|
|
26
|
+
def configure(self, context: BootstrapContext) -> None:
|
|
27
|
+
"""Phase 2: Instantiate FastAPI app, configure middleware, and register in container.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
context: BootstrapContext containing DI container, configuration, and properties.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
None.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
None.
|
|
37
|
+
"""
|
|
38
|
+
from hexastack_fastapi.adapters.app import create_fastapi_app
|
|
39
|
+
from hexastack_fastapi.adapters.routing import CqrsRouter
|
|
40
|
+
from hexastack_fastapi.infra.autodiscovery import create_route_visitor
|
|
41
|
+
|
|
42
|
+
cfg = context.get_config("fastapi", HexastackFastApiConfig)
|
|
43
|
+
|
|
44
|
+
pipeline = context.properties.get("pipeline")
|
|
45
|
+
if pipeline is None and ExecutionPipeline in context.container:
|
|
46
|
+
pipeline = context.container.resolve(ExecutionPipeline)
|
|
47
|
+
|
|
48
|
+
exception_reg = None
|
|
49
|
+
if ExceptionRegistry in context.container:
|
|
50
|
+
exception_reg = context.container.resolve(ExceptionRegistry)
|
|
51
|
+
|
|
52
|
+
app = create_fastapi_app(
|
|
53
|
+
config=cfg,
|
|
54
|
+
container=context.container,
|
|
55
|
+
pipeline=pipeline,
|
|
56
|
+
exception_registry=exception_reg,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if cfg.auto_register_routes:
|
|
60
|
+
router = CqrsRouter()
|
|
61
|
+
visitor = create_route_visitor(router)
|
|
62
|
+
context.register_visitor(visitor)
|
|
63
|
+
app.include_router(router)
|
|
64
|
+
|
|
65
|
+
context.container.add_instance(app, declared_class=FastAPI)
|
|
66
|
+
context.properties["app"] = app
|
|
67
|
+
|
|
68
|
+
def register_config(self, registry: ConfigRegistry) -> None:
|
|
69
|
+
"""Phase 1: Register FastAPI configuration schema under 'fastapi'.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
registry: Target ConfigRegistry instance.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
None.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
None.
|
|
79
|
+
"""
|
|
80
|
+
register_fastapi_config(registry)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"FastApiBootstrapper",
|
|
85
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from hexastack_core.infra.decorators import config_section
|
|
4
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CorsConfig(BaseModel):
|
|
8
|
+
"""Configuration schema for Cross-Origin Resource Sharing (CORS).
|
|
9
|
+
|
|
10
|
+
Notes/Architectural Intent:
|
|
11
|
+
Controls CORS headers, origins, allowed methods, and credential passing for REST endpoints.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
enable: bool = Field(default=False)
|
|
15
|
+
allow_origins: list[str] = Field(default_factory=lambda: ["*"])
|
|
16
|
+
allow_methods: list[str] = Field(default_factory=lambda: ["*"])
|
|
17
|
+
allow_headers: list[str] = Field(default_factory=lambda: ["*"])
|
|
18
|
+
allow_credentials: bool = Field(default=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HealthConfig(BaseModel):
|
|
22
|
+
"""Configuration schema for health and readiness HTTP endpoints.
|
|
23
|
+
|
|
24
|
+
Notes/Architectural Intent:
|
|
25
|
+
Controls automatic registration of liveness and readiness probe routes for container orchestrators.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
enable: bool = Field(default=True)
|
|
29
|
+
health_path: str = Field(default="/health")
|
|
30
|
+
ready_path: str = Field(default="/ready")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RequestLoggingConfig(BaseModel):
|
|
34
|
+
"""Configuration schema for HTTP request access logging middleware.
|
|
35
|
+
|
|
36
|
+
Notes/Architectural Intent:
|
|
37
|
+
Controls emission of structured HTTP access log records with method, path, status, and duration.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
enable: bool = Field(default=True)
|
|
41
|
+
exclude_paths: list[str] = Field(
|
|
42
|
+
default_factory=lambda: [
|
|
43
|
+
"/health",
|
|
44
|
+
"/ready",
|
|
45
|
+
"/docs",
|
|
46
|
+
"/redoc",
|
|
47
|
+
"/openapi.json",
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ZensicalDocsConfig(BaseModel):
|
|
53
|
+
"""Configuration schema for mounting pre-built Zensical documentation onto FastAPI.
|
|
54
|
+
|
|
55
|
+
Notes/Architectural Intent:
|
|
56
|
+
Controls automatic mounting of Zensical static documentation site.
|
|
57
|
+
Default mount path is `/guide` to prevent collision with OpenAPI Swagger UI at `/docs`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
enable: bool = Field(default=False)
|
|
61
|
+
path: str = Field(default="/guide")
|
|
62
|
+
site_dir: str = Field(default="site")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@config_section("fastapi")
|
|
66
|
+
class HexastackFastApiConfig(BaseModel):
|
|
67
|
+
"""Configuration schema for FastAPI HTTP presentation adapter.
|
|
68
|
+
|
|
69
|
+
Notes/Architectural Intent:
|
|
70
|
+
Controls OpenAPI metadata, documentation routes, CORS policy, header identifiers,
|
|
71
|
+
access logging, health probes, Zensical docs mounting, and automatic route autodiscovery.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
title: str = Field(default="Hexastack API")
|
|
75
|
+
version: str = Field(default="0.1.0")
|
|
76
|
+
description: str = Field(default="")
|
|
77
|
+
docs_url: str = Field(default="/docs")
|
|
78
|
+
redoc_url: str = Field(default="/redoc")
|
|
79
|
+
openapi_url: str = Field(default="/openapi.json")
|
|
80
|
+
correlation_header: str = Field(default="X-Correlation-ID")
|
|
81
|
+
user_header: str = Field(default="X-User-ID")
|
|
82
|
+
tenant_header: str = Field(default="X-Tenant-ID")
|
|
83
|
+
cors: CorsConfig = Field(default_factory=CorsConfig)
|
|
84
|
+
health: HealthConfig = Field(default_factory=HealthConfig)
|
|
85
|
+
logging: RequestLoggingConfig = Field(default_factory=RequestLoggingConfig)
|
|
86
|
+
zensical: ZensicalDocsConfig = Field(default_factory=ZensicalDocsConfig)
|
|
87
|
+
auto_register_routes: bool = Field(default=True)
|
|
88
|
+
packages_to_scan: list[str] = Field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"CorsConfig",
|
|
93
|
+
"HealthConfig",
|
|
94
|
+
"HexastackFastApiConfig",
|
|
95
|
+
"register_fastapi_config",
|
|
96
|
+
"RequestLoggingConfig",
|
|
97
|
+
"ZensicalDocsConfig",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def register_fastapi_config(registry: ConfigRegistry) -> None:
|
|
102
|
+
"""Register FastAPI configuration schema with a ConfigRegistry under 'fastapi'.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
registry: Target ConfigRegistry instance.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
None.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
None.
|
|
112
|
+
"""
|
|
113
|
+
registry.register_config_section("fastapi", HexastackFastApiConfig)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from hexastack_core.domain import Command, Query
|
|
7
|
+
|
|
8
|
+
_ROUTE_METADATA_ATTR = "__hexastack_route__"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class RouteMetadata:
|
|
13
|
+
"""Metadata describing an HTTP endpoint binding for a Command or Query.
|
|
14
|
+
|
|
15
|
+
Notes/Architectural Intent:
|
|
16
|
+
Carries routing parameters attached by decorators for automated endpoint registration,
|
|
17
|
+
including optional feature flag gating.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
path: str
|
|
21
|
+
kind: Literal["command", "query"]
|
|
22
|
+
method: str = "POST"
|
|
23
|
+
status_code: int = 200
|
|
24
|
+
output_format: str | None = None
|
|
25
|
+
summary: str | None = None
|
|
26
|
+
tags: tuple[str | Enum, ...] | None = None
|
|
27
|
+
feature_flag: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"api_command",
|
|
32
|
+
"api_query",
|
|
33
|
+
"feature_flag_route",
|
|
34
|
+
"RouteMetadata",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def api_command[TCommand: Command](
|
|
39
|
+
path: str,
|
|
40
|
+
*,
|
|
41
|
+
method: str = "POST",
|
|
42
|
+
status_code: int = 200,
|
|
43
|
+
output_format: str | None = None,
|
|
44
|
+
summary: str | None = None,
|
|
45
|
+
tags: list[str | Enum] | None = None,
|
|
46
|
+
feature_flag: str | None = None,
|
|
47
|
+
) -> Callable[[type[TCommand]], type[TCommand]]:
|
|
48
|
+
"""Decorator marking a Command class for automatic HTTP endpoint exposure.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path: URL path for the route.
|
|
52
|
+
method: HTTP method (defaults to 'POST').
|
|
53
|
+
status_code: Success HTTP status code (defaults to 200).
|
|
54
|
+
output_format: Optional presenter output format.
|
|
55
|
+
summary: Optional OpenAPI summary.
|
|
56
|
+
tags: Optional OpenAPI tags list.
|
|
57
|
+
feature_flag: Optional feature flag key required for route access.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Decorated Command class with attached routing metadata.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
None.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def decorator(cls: type[TCommand]) -> type[TCommand]:
|
|
67
|
+
meta = RouteMetadata(
|
|
68
|
+
path=path,
|
|
69
|
+
kind="command",
|
|
70
|
+
method=method.upper(),
|
|
71
|
+
status_code=status_code,
|
|
72
|
+
output_format=output_format,
|
|
73
|
+
summary=summary,
|
|
74
|
+
tags=tuple(tags) if tags else None,
|
|
75
|
+
feature_flag=feature_flag,
|
|
76
|
+
)
|
|
77
|
+
setattr(cls, _ROUTE_METADATA_ATTR, meta)
|
|
78
|
+
return cls
|
|
79
|
+
|
|
80
|
+
return decorator
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def api_query[TQuery: Query](
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
method: str = "GET",
|
|
87
|
+
status_code: int = 200,
|
|
88
|
+
output_format: str | None = None,
|
|
89
|
+
summary: str | None = None,
|
|
90
|
+
tags: list[str | Enum] | None = None,
|
|
91
|
+
feature_flag: str | None = None,
|
|
92
|
+
) -> Callable[[type[TQuery]], type[TQuery]]:
|
|
93
|
+
"""Decorator marking a Query class for automatic HTTP endpoint exposure.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
path: URL path for the route.
|
|
97
|
+
method: HTTP method ('GET' or 'POST', defaults to 'GET').
|
|
98
|
+
status_code: Success HTTP status code (defaults to 200).
|
|
99
|
+
output_format: Optional presenter output format.
|
|
100
|
+
summary: Optional OpenAPI summary.
|
|
101
|
+
tags: Optional OpenAPI tags list.
|
|
102
|
+
feature_flag: Optional feature flag key required for route access.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Decorated Query class with attached routing metadata.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
None.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def decorator(cls: type[TQuery]) -> type[TQuery]:
|
|
112
|
+
meta = RouteMetadata(
|
|
113
|
+
path=path,
|
|
114
|
+
kind="query",
|
|
115
|
+
method=method.upper(),
|
|
116
|
+
status_code=status_code,
|
|
117
|
+
output_format=output_format,
|
|
118
|
+
summary=summary,
|
|
119
|
+
tags=tuple(tags) if tags else None,
|
|
120
|
+
feature_flag=feature_flag,
|
|
121
|
+
)
|
|
122
|
+
setattr(cls, _ROUTE_METADATA_ATTR, meta)
|
|
123
|
+
return cls
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def feature_flag_route(
|
|
129
|
+
flag_key: str,
|
|
130
|
+
*,
|
|
131
|
+
default: bool = False,
|
|
132
|
+
status_code: int = 404,
|
|
133
|
+
detail: str | None = None,
|
|
134
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
135
|
+
"""Decorator guarding a custom FastAPI route function with dynamic feature flag evaluation.
|
|
136
|
+
|
|
137
|
+
Notes/Architectural Intent:
|
|
138
|
+
Wraps standard FastAPI endpoint callables with dynamic feature flag evaluation,
|
|
139
|
+
returning an HTTP error (default 404) when the flag evaluates to False.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
flag_key: Unique identifier of the feature flag to check.
|
|
143
|
+
default: Fallback boolean value if flag is not explicitly configured.
|
|
144
|
+
status_code: HTTP status code returned when disabled.
|
|
145
|
+
detail: Optional error message string.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Decorator wrapping the endpoint callable.
|
|
149
|
+
"""
|
|
150
|
+
import inspect
|
|
151
|
+
from functools import wraps
|
|
152
|
+
|
|
153
|
+
from fastapi import HTTPException
|
|
154
|
+
|
|
155
|
+
from hexastack_core.adapters.feature_flags.config import ConfigFeatureFlagAdapter
|
|
156
|
+
from hexastack_core.domain.feature_flags import EvaluationContext
|
|
157
|
+
from hexastack_core.ports.feature_flags import FeatureFlagPort
|
|
158
|
+
from hexastack_fastapi.adapters.dependencies import get_feature_flags
|
|
159
|
+
|
|
160
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
161
|
+
if inspect.iscoroutinefunction(fn):
|
|
162
|
+
|
|
163
|
+
@wraps(fn)
|
|
164
|
+
async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
165
|
+
request = kwargs.get("request")
|
|
166
|
+
flags: FeatureFlagPort = (
|
|
167
|
+
get_feature_flags(request)
|
|
168
|
+
if request is not None
|
|
169
|
+
else ConfigFeatureFlagAdapter()
|
|
170
|
+
)
|
|
171
|
+
eval_ctx = EvaluationContext.from_current_context()
|
|
172
|
+
if not flags.is_enabled(flag_key, default=default, context=eval_ctx):
|
|
173
|
+
raise HTTPException(
|
|
174
|
+
status_code=status_code,
|
|
175
|
+
detail=detail or f"Feature '{flag_key}' is disabled.",
|
|
176
|
+
)
|
|
177
|
+
return await fn(*args, **kwargs)
|
|
178
|
+
|
|
179
|
+
return async_wrapped
|
|
180
|
+
|
|
181
|
+
@wraps(fn)
|
|
182
|
+
def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
183
|
+
request = kwargs.get("request")
|
|
184
|
+
flags: FeatureFlagPort = (
|
|
185
|
+
get_feature_flags(request)
|
|
186
|
+
if request is not None
|
|
187
|
+
else ConfigFeatureFlagAdapter()
|
|
188
|
+
)
|
|
189
|
+
eval_ctx = EvaluationContext.from_current_context()
|
|
190
|
+
if not flags.is_enabled(flag_key, default=default, context=eval_ctx):
|
|
191
|
+
raise HTTPException(
|
|
192
|
+
status_code=status_code,
|
|
193
|
+
detail=detail or f"Feature '{flag_key}' is disabled.",
|
|
194
|
+
)
|
|
195
|
+
return fn(*args, **kwargs)
|
|
196
|
+
|
|
197
|
+
return sync_wrapped
|
|
198
|
+
|
|
199
|
+
return decorator
|
|
@@ -0,0 +1,60 @@
|
|
|
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 "unauthorized" in exc_name or "forbidden" in exc_name:
|
|
51
|
+
status_code = 401
|
|
52
|
+
elif "validation" in exc_name:
|
|
53
|
+
status_code = 422
|
|
54
|
+
|
|
55
|
+
content: dict[str, Any] = {
|
|
56
|
+
"error": str(exc),
|
|
57
|
+
"error_type": exc.__class__.__name__,
|
|
58
|
+
"correlation_id": get_correlation_id(),
|
|
59
|
+
}
|
|
60
|
+
return JSONResponse(status_code=status_code, content=content)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from hexastack_fastapi.infra.middleware.correlation import (
|
|
2
|
+
CorrelationHttpMiddleware,
|
|
3
|
+
)
|
|
4
|
+
from hexastack_fastapi.infra.middleware.logging import (
|
|
5
|
+
RequestLoggingHttpMiddleware,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CorrelationHttpMiddleware",
|
|
10
|
+
"RequestLoggingHttpMiddleware",
|
|
11
|
+
]
|