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.

@@ -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
+ ]