hexastack-fastapi 0.0.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. hexastack_fastapi-0.0.0/README.md → hexastack_fastapi-0.2.0/PKG-INFO +36 -3
  2. hexastack_fastapi-0.0.0/PKG-INFO → hexastack_fastapi-0.2.0/README.md +10 -23
  3. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/pyproject.toml +27 -1
  4. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/pyproject.toml.orig +32 -1
  5. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/__init__.py +14 -1
  6. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/app.py +26 -0
  7. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/dependencies.py +63 -0
  8. hexastack_fastapi-0.2.0/src/hexastack_fastapi/adapters/ratelimit.py +208 -0
  9. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/routing.py +12 -0
  10. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/ui.py +26 -15
  11. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/autodiscovery.py +2 -0
  12. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/config.py +22 -0
  13. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/decorators.py +14 -4
  14. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/exception_handlers.py +11 -1
  15. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/__init__.py +0 -0
  16. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
  17. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
  18. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/health.py +0 -0
  19. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/__init__.py +0 -0
  20. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/bootstrap.py +0 -0
  21. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/__init__.py +0 -0
  22. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/correlation.py +0 -0
  23. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
  24. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/py.typed +0 -0
  25. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
  26. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
  27. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/recorder.py +0 -0
  28. {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/server.py +0 -0
@@ -1,9 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexastack-fastapi
3
+ Version: 0.2.0
4
+ Summary: FastAPI presentation adapter for Hexastack
5
+ Author: Richard West
6
+ Author-email: Richard West <dopplereffect.us@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Dist: fastapi>=0.141.1
9
+ Requires-Dist: hexastack-core
10
+ Requires-Dist: hexastack-cqrs
11
+ Requires-Dist: hexastack-auth ; extra == 'auth'
12
+ Requires-Dist: zensical>=0.0.56 ; extra == 'docs'
13
+ Requires-Dist: limits>=3.14.0 ; extra == 'ratelimit'
14
+ Requires-Dist: slowapi>=0.1.9 ; extra == 'ratelimit'
15
+ Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
16
+ Requires-Dist: schemathesis>=3.39.0 ; extra == 'testing'
17
+ Requires-Dist: nicegui>=2.0.0 ; extra == 'ui'
18
+ Requires-Python: >=3.13
19
+ Provides-Extra: auth
20
+ Provides-Extra: docs
21
+ Provides-Extra: ratelimit
22
+ Provides-Extra: testing
23
+ Provides-Extra: ui
24
+ Description-Content-Type: text/markdown
25
+
26
+ ![hexastack-fastapi](../../docs/assets/static/logos/packages/hexastack_fastapi.png)
27
+
1
28
  # hexastack-fastapi
2
29
 
3
30
  > FastAPI presentation adapter for Hexastack: automatic CQRS routing, database session middleware, exception mapping, and health check endpoints.
4
31
 
32
+ [![PyPI: hexastack-fastapi](https://img.shields.io/pypi/v/hexastack-fastapi.svg)](https://pypi.org/project/hexastack-fastapi/)
5
33
  [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
6
-
34
+ [![Coverage](https://codecov.io/github/TheTrueSCU/hexastack/graph/badge.svg?component=hexastack_fastapi)](https://codecov.io/github/TheTrueSCU/hexastack)
35
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
36
+ [![Accessibility: WCAG 2.1 AA](https://img.shields.io/badge/accessibility-WCAG%202.1%20AA-brightgreen.svg)](https://www.w3.org/WAI/WCAG21/quickref/?levels=aa)
37
+ [![Tested with: axe--core](https://img.shields.io/badge/tested%20with-axe--core-4353ff.svg?logo=deque)](https://github.com/dequelabs/axe-core)
7
38
  ---
8
39
 
9
40
  ## 1. Overview & Capabilities
@@ -31,15 +62,17 @@ hexastack_fastapi/
31
62
  | Category | Exports |
32
63
  |---|---|
33
64
  | **Application Factory** | `create_app`, `FastApiBootstrapper` (order=30) |
34
- | **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `RouteMetadata` |
35
- | **Dependencies** | `get_container`, `get_pipeline`, `get_feature_flags`, `require_feature` |
65
+ | **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `@rate_limit`, `RouteMetadata` |
66
+ | **Dependencies** | `get_container`, `get_pipeline`, `get_feature_flags`, `require_feature`, `get_rate_limiter`, `require_rate_limit` |
36
67
  | **Documentation Mounting** | `mount_zensical_docs`, `DocumentationNotFoundError`, `ZensicalDocsConfig` |
37
68
  | **Exception Handlers** | `register_exception_handlers`, `domain_exception_handler` |
38
69
  | **Middlewares** | `DbSessionMiddleware`, `AsyncDbSessionMiddleware`, `add_db_session_middleware`, `CorrelationMiddleware`, `HttpLoggingMiddleware` |
70
+ | **Rate Limiting** | `SlowapiRateLimiterAdapter`, `get_remote_address`, `get_user_or_ip_key`, `RateLimitConfig` |
39
71
  | **Reactive UI (NiceGUI)** | `dispatch_command`, `dispatch_query`, `mount_devtools_dashboard`, `mount_ui_app`, `ui_page` |
40
72
  | **Routing** | `CqrsRouter`, `autodiscover_routes` |
41
73
  | **Testing, E2E & Demo Narration** | `create_test_client`, `check_openapi_conformance`, `EphemeralServer`, `ephemeral_server`, `find_free_port`, `smart_click`, `DemoNarrator`, `VIRTUAL_CURSOR_SCRIPT` |
42
74
 
75
+
43
76
  ---
44
77
 
45
78
  ## 3. Monorepo & Sibling Relationships
@@ -1,30 +1,15 @@
1
- Metadata-Version: 2.3
2
- Name: hexastack-fastapi
3
- Version: 0.0.0
4
- Summary: FastAPI presentation adapter for Hexastack
5
- Author: Richard West
6
- Author-email: Richard West <dopplereffect.us@gmail.com>
7
- Requires-Dist: fastapi>=0.141.1
8
- Requires-Dist: hexastack-core
9
- Requires-Dist: hexastack-cqrs
10
- Requires-Dist: hexastack-auth ; extra == 'auth'
11
- Requires-Dist: zensical>=0.0.56 ; extra == 'docs'
12
- Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
13
- Requires-Dist: schemathesis>=3.39.0 ; extra == 'testing'
14
- Requires-Dist: nicegui>=2.0.0 ; extra == 'ui'
15
- Requires-Python: >=3.13
16
- Provides-Extra: auth
17
- Provides-Extra: docs
18
- Provides-Extra: testing
19
- Provides-Extra: ui
20
- Description-Content-Type: text/markdown
1
+ ![hexastack-fastapi](../../docs/assets/static/logos/packages/hexastack_fastapi.png)
21
2
 
22
3
  # hexastack-fastapi
23
4
 
24
5
  > FastAPI presentation adapter for Hexastack: automatic CQRS routing, database session middleware, exception mapping, and health check endpoints.
25
6
 
7
+ [![PyPI: hexastack-fastapi](https://img.shields.io/pypi/v/hexastack-fastapi.svg)](https://pypi.org/project/hexastack-fastapi/)
26
8
  [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
27
-
9
+ [![Coverage](https://codecov.io/github/TheTrueSCU/hexastack/graph/badge.svg?component=hexastack_fastapi)](https://codecov.io/github/TheTrueSCU/hexastack)
10
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
11
+ [![Accessibility: WCAG 2.1 AA](https://img.shields.io/badge/accessibility-WCAG%202.1%20AA-brightgreen.svg)](https://www.w3.org/WAI/WCAG21/quickref/?levels=aa)
12
+ [![Tested with: axe--core](https://img.shields.io/badge/tested%20with-axe--core-4353ff.svg?logo=deque)](https://github.com/dequelabs/axe-core)
28
13
  ---
29
14
 
30
15
  ## 1. Overview & Capabilities
@@ -52,15 +37,17 @@ hexastack_fastapi/
52
37
  | Category | Exports |
53
38
  |---|---|
54
39
  | **Application Factory** | `create_app`, `FastApiBootstrapper` (order=30) |
55
- | **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `RouteMetadata` |
56
- | **Dependencies** | `get_container`, `get_pipeline`, `get_feature_flags`, `require_feature` |
40
+ | **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `@rate_limit`, `RouteMetadata` |
41
+ | **Dependencies** | `get_container`, `get_pipeline`, `get_feature_flags`, `require_feature`, `get_rate_limiter`, `require_rate_limit` |
57
42
  | **Documentation Mounting** | `mount_zensical_docs`, `DocumentationNotFoundError`, `ZensicalDocsConfig` |
58
43
  | **Exception Handlers** | `register_exception_handlers`, `domain_exception_handler` |
59
44
  | **Middlewares** | `DbSessionMiddleware`, `AsyncDbSessionMiddleware`, `add_db_session_middleware`, `CorrelationMiddleware`, `HttpLoggingMiddleware` |
45
+ | **Rate Limiting** | `SlowapiRateLimiterAdapter`, `get_remote_address`, `get_user_or_ip_key`, `RateLimitConfig` |
60
46
  | **Reactive UI (NiceGUI)** | `dispatch_command`, `dispatch_query`, `mount_devtools_dashboard`, `mount_ui_app`, `ui_page` |
61
47
  | **Routing** | `CqrsRouter`, `autodiscover_routes` |
62
48
  | **Testing, E2E & Demo Narration** | `create_test_client`, `check_openapi_conformance`, `EphemeralServer`, `ephemeral_server`, `find_free_port`, `smart_click`, `DemoNarrator`, `VIRTUAL_CURSOR_SCRIPT` |
63
49
 
50
+
64
51
  ---
65
52
 
66
53
  ## 3. Monorepo & Sibling Relationships
@@ -1,8 +1,9 @@
1
1
  [project]
2
2
  name = "hexastack-fastapi"
3
- version = "0.0.0"
3
+ version = "0.2.0"
4
4
  description = "FastAPI presentation adapter for Hexastack"
5
5
  readme = "README.md"
6
+ license = "Apache-2.0"
6
7
  requires-python = ">=3.13"
7
8
  dependencies = [
8
9
  "fastapi>=0.141.1",
@@ -17,6 +18,10 @@ email = "dopplereffect.us@gmail.com"
17
18
  [project.optional-dependencies]
18
19
  auth = ["hexastack-auth"]
19
20
  docs = ["zensical>=0.0.56"]
21
+ ratelimit = [
22
+ "limits>=3.14.0",
23
+ "slowapi>=0.1.9",
24
+ ]
20
25
  testing = [
21
26
  "playwright>=1.49.0",
22
27
  "schemathesis>=3.39.0",
@@ -41,3 +46,24 @@ workspace = true
41
46
 
42
47
  [tool.importlinter]
43
48
  root_packages = ["hexastack_fastapi"]
49
+
50
+ [[tool.importlinter.contracts]]
51
+ name = "Hexagonal architecture layer hierarchy"
52
+ type = "layers"
53
+ containers = ["hexastack_fastapi"]
54
+ layers = [
55
+ "adapters",
56
+ "infra",
57
+ ]
58
+
59
+ [[tool.importlinter.contracts]]
60
+ name = "Forbidden imports for adapters"
61
+ type = "forbidden"
62
+ source_modules = ["hexastack_fastapi.adapters"]
63
+ forbidden_modules = ["hexastack_fastapi.testing"]
64
+
65
+ [[tool.importlinter.contracts]]
66
+ name = "Forbidden imports for infra"
67
+ type = "forbidden"
68
+ source_modules = ["hexastack_fastapi.infra"]
69
+ forbidden_modules = ["hexastack_fastapi.testing"]
@@ -1,8 +1,9 @@
1
1
  [project]
2
2
  name = "hexastack-fastapi"
3
- version = "0.0.0"
3
+ version = "0.2.0"
4
4
  description = "FastAPI presentation adapter for Hexastack"
5
5
  readme = "README.md"
6
+ license = "Apache-2.0"
6
7
  authors = [
7
8
  { name = "Richard West", email = "dopplereffect.us@gmail.com" }
8
9
  ]
@@ -20,6 +21,10 @@ auth = [
20
21
  docs = [
21
22
  "zensical>=0.0.56",
22
23
  ]
24
+ ratelimit = [
25
+ "limits>=3.14.0",
26
+ "slowapi>=0.1.9",
27
+ ]
23
28
  testing = [
24
29
  "playwright>=1.49.0",
25
30
  "schemathesis>=3.39.0",
@@ -28,6 +33,7 @@ ui = [
28
33
  "nicegui>=2.0.0",
29
34
  ]
30
35
 
36
+
31
37
  [project.entry-points."hexastack.bootstrappers"]
32
38
  fastapi = "hexastack_fastapi.infra.bootstrap:FastApiBootstrapper"
33
39
 
@@ -42,3 +48,28 @@ hexastack-cqrs = { workspace = true }
42
48
 
43
49
  [tool.importlinter]
44
50
  root_packages = ["hexastack_fastapi"]
51
+
52
+ [[tool.importlinter.contracts]]
53
+ name = "Hexagonal architecture layer hierarchy"
54
+ type = "layers"
55
+ containers = ["hexastack_fastapi"]
56
+ layers = [
57
+ "adapters",
58
+ "infra",
59
+ ]
60
+
61
+ [[tool.importlinter.contracts]]
62
+ name = "Forbidden imports for adapters"
63
+ type = "forbidden"
64
+ source_modules = ["hexastack_fastapi.adapters"]
65
+ forbidden_modules = [
66
+ "hexastack_fastapi.testing",
67
+ ]
68
+
69
+ [[tool.importlinter.contracts]]
70
+ name = "Forbidden imports for infra"
71
+ type = "forbidden"
72
+ source_modules = ["hexastack_fastapi.infra"]
73
+ forbidden_modules = [
74
+ "hexastack_fastapi.testing",
75
+ ]
@@ -1,17 +1,24 @@
1
- from hexastack_fastapi.adapters.app import create_fastapi_app
2
1
  from hexastack_fastapi.adapters.dependencies import (
3
2
  check_openapi_conformance,
4
3
  create_test_client,
5
4
  get_container,
6
5
  get_feature_flags,
7
6
  get_pipeline,
7
+ get_rate_limiter,
8
8
  require_feature,
9
+ require_rate_limit,
9
10
  )
10
11
  from hexastack_fastapi.adapters.docs import (
11
12
  DocumentationNotFoundError,
12
13
  mount_zensical_docs,
13
14
  )
14
15
  from hexastack_fastapi.adapters.health import create_health_router
16
+ from hexastack_fastapi.adapters.ratelimit import (
17
+ SlowapiRateLimiterAdapter,
18
+ get_remote_address,
19
+ get_user_or_ip_key,
20
+ rate_limit,
21
+ )
15
22
  from hexastack_fastapi.adapters.routing import CqrsRouter
16
23
  from hexastack_fastapi.adapters.ui import (
17
24
  dispatch_command,
@@ -33,9 +40,15 @@ __all__ = [
33
40
  "get_container",
34
41
  "get_feature_flags",
35
42
  "get_pipeline",
43
+ "get_rate_limiter",
44
+ "get_remote_address",
45
+ "get_user_or_ip_key",
36
46
  "mount_devtools_dashboard",
37
47
  "mount_ui_app",
38
48
  "mount_zensical_docs",
49
+ "rate_limit",
39
50
  "require_feature",
51
+ "require_rate_limit",
52
+ "SlowapiRateLimiterAdapter",
40
53
  "ui_page",
41
54
  ]
@@ -78,6 +78,32 @@ def create_fastapi_app(
78
78
  if pipeline is not None:
79
79
  app.state.pipeline = pipeline
80
80
 
81
+ # Attach rate limiter if enabled
82
+ if cfg.ratelimit.enable:
83
+ try:
84
+ from hexastack_fastapi.adapters.ratelimit import SlowapiRateLimiterAdapter
85
+
86
+ rate_limiter = SlowapiRateLimiterAdapter(
87
+ default_limits=cfg.ratelimit.default_limits,
88
+ storage_uri=cfg.ratelimit.storage_uri,
89
+ )
90
+ app.state.rate_limiter = rate_limiter
91
+ if container is not None:
92
+ from hexastack_core.ports.ratelimit import RateLimiterPort
93
+
94
+ container.add_instance(rate_limiter, declared_class=RateLimiterPort)
95
+ except Exception:
96
+ # Fall back to InMemoryRateLimiter if slowapi is not installed
97
+ from hexastack_core.adapters.ratelimit import InMemoryRateLimiter
98
+ from hexastack_core.ports.ratelimit import RateLimiterPort
99
+
100
+ in_memory_limiter = InMemoryRateLimiter()
101
+ app.state.rate_limiter = in_memory_limiter
102
+ if container is not None:
103
+ container.add_instance(
104
+ in_memory_limiter, declared_class=RateLimiterPort
105
+ )
106
+
81
107
  # Attach CORS middleware if enabled
82
108
  if cfg.cors.enable:
83
109
  app.add_middleware(
@@ -19,7 +19,9 @@ __all__ = [
19
19
  "get_container",
20
20
  "get_feature_flags",
21
21
  "get_pipeline",
22
+ "get_rate_limiter",
22
23
  "require_feature",
24
+ "require_rate_limit",
23
25
  ]
24
26
 
25
27
 
@@ -188,3 +190,64 @@ def require_feature(
188
190
  )
189
191
 
190
192
  return _dependency
193
+
194
+
195
+ def get_rate_limiter(request: Request) -> Any | None:
196
+ """FastAPI dependency resolving the registered RateLimiterPort instance if available.
197
+
198
+ Args:
199
+ request: Incoming FastAPI Request object.
200
+
201
+ Returns:
202
+ RateLimiterPort instance from app.state or DI container, or None.
203
+ """
204
+ limiter = getattr(request.app.state, "rate_limiter", None)
205
+ if limiter is not None:
206
+ return limiter
207
+
208
+ container = getattr(request.app.state, "container", None)
209
+ if isinstance(container, Container):
210
+ from hexastack_core.ports.ratelimit import RateLimiterPort
211
+
212
+ if RateLimiterPort in container:
213
+ return container.resolve(RateLimiterPort)
214
+ return None
215
+
216
+
217
+ def require_rate_limit(
218
+ limit: str,
219
+ *,
220
+ key_func: Any | None = None,
221
+ detail: str | None = None,
222
+ ) -> Any:
223
+ """Create a FastAPI dependency that enforces rate limiting on an endpoint.
224
+
225
+ Notes/Architectural Intent:
226
+ Checks rate limit against registered RateLimiterPort. If quota is exceeded,
227
+ raises an HTTPException with status 429 and Retry-After header.
228
+
229
+ Args:
230
+ limit: Rate limit specification string (e.g. '10/minute').
231
+ key_func: Optional callable to extract bucket key from request.
232
+ detail: Optional error message string.
233
+
234
+ Returns:
235
+ A FastAPI dependency callable.
236
+ """
237
+ from hexastack_fastapi.adapters.ratelimit import get_user_or_ip_key
238
+
239
+ key_extractor = key_func or get_user_or_ip_key
240
+
241
+ async def _dependency(request: Request) -> None:
242
+ limiter = get_rate_limiter(request)
243
+ if limiter is not None:
244
+ key = key_extractor(request)
245
+ if not limiter.hit(key, limit):
246
+ reset_sec = limiter.get_reset_window(key, limit)
247
+ raise HTTPException(
248
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
249
+ detail=detail or f"Rate limit exceeded: {limit}.",
250
+ headers={"Retry-After": str(reset_sec)},
251
+ )
252
+
253
+ return _dependency
@@ -0,0 +1,208 @@
1
+ import inspect
2
+ from collections.abc import Callable
3
+ from functools import wraps
4
+ from typing import Any
5
+
6
+ from fastapi import HTTPException, Request, status
7
+
8
+ from hexastack_core.domain.exceptions import MissingDependencyError
9
+ from hexastack_core.ports.ratelimit import RateLimiterPort
10
+ from hexastack_core.utils.context import get_user_context
11
+
12
+
13
+ def get_remote_address(request: Request) -> str:
14
+ """Extract client IP address from FastAPI request headers or client socket.
15
+
16
+ Args:
17
+ request: Incoming FastAPI Request object.
18
+
19
+ Returns:
20
+ Client IP string.
21
+ """
22
+ forwarded = request.headers.get("X-Forwarded-For")
23
+ if forwarded:
24
+ return forwarded.split(",")[0].strip()
25
+ if request.client and request.client.host:
26
+ return request.client.host
27
+ return "127.0.0.1"
28
+
29
+
30
+ def get_user_or_ip_key(request: Request) -> str:
31
+ """Extract rate limit bucket key from ambient UserContext or fallback to client IP.
32
+
33
+ Args:
34
+ request: Incoming FastAPI Request object.
35
+
36
+ Returns:
37
+ Rate limit identifier string (e.g. 'user:usr_123', 'tenant:t_456', or 'ip:1.2.3.4').
38
+ """
39
+ user_ctx = get_user_context()
40
+ if user_ctx and user_ctx.user_id:
41
+ return f"user:{user_ctx.user_id}"
42
+ if user_ctx and user_ctx.tenant_id:
43
+ return f"tenant:{user_ctx.tenant_id}"
44
+ return f"ip:{get_remote_address(request)}"
45
+
46
+
47
+ class SlowapiRateLimiterAdapter(RateLimiterPort):
48
+ """RateLimiterPort implementation backed by slowapi and limits libraries.
49
+
50
+ Notes/Architectural Intent:
51
+ Wraps slowapi Limiter and limits storage backends (memory, redis),
52
+ enforcing uniform rate limit verification across HTTP endpoints.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ key_func: Callable[[Request], str] | None = None,
58
+ default_limits: list[str] | None = None,
59
+ storage_uri: str = "memory://",
60
+ ) -> None:
61
+ """Initialize SlowapiRateLimiterAdapter.
62
+
63
+ Args:
64
+ key_func: Callable to extract bucket key from Request.
65
+ default_limits: List of default rate limit strings (e.g. ['100/minute']).
66
+ storage_uri: Storage URI ('memory://' or 'redis://localhost:6379').
67
+
68
+ Raises:
69
+ MissingDependencyError: If slowapi or limits is not installed.
70
+ """
71
+ try:
72
+ from limits.storage import storage_from_string
73
+ from slowapi import Limiter
74
+ except ImportError as e:
75
+ raise MissingDependencyError(
76
+ "slowapi and limits are required for SlowapiRateLimiterAdapter. "
77
+ "Install with 'pip install hexastack-fastapi[ratelimit]'."
78
+ ) from e
79
+
80
+ self._key_func = key_func or get_user_or_ip_key
81
+ self._default_limits: list[Any] = list(default_limits or ["100/minute"])
82
+ self._storage: Any = storage_from_string(storage_uri)
83
+ self._limiter = Limiter(
84
+ key_func=self._key_func,
85
+ default_limits=self._default_limits,
86
+ storage_uri=storage_uri,
87
+ )
88
+
89
+ @property
90
+ def limiter(self) -> Any:
91
+ """Underlying slowapi Limiter instance."""
92
+ return self._limiter
93
+
94
+ def hit(self, key: str, limit: str) -> bool:
95
+ """Record a hit against the rate limit window using limits storage."""
96
+ from limits import parse
97
+
98
+ rate_item = parse(limit)
99
+ storage: Any = self._storage
100
+ count = int(storage.incr(rate_item.key_for(key), rate_item.get_expiry()))
101
+ return count <= rate_item.amount
102
+
103
+ def get_reset_window(self, key: str, limit: str) -> int:
104
+ """Get remaining seconds until rate limit window resets."""
105
+ from limits import parse
106
+
107
+ rate_item = parse(limit)
108
+ storage: Any = self._storage
109
+ expiry = float(storage.get_expiry(rate_item.key_for(key)))
110
+ return max(1, int(expiry)) if expiry > 0 else 0
111
+
112
+ def clear(self, key: str | None = None) -> None:
113
+ """Clear rate limit storage."""
114
+ storage: Any = self._storage
115
+ if hasattr(storage, "reset"):
116
+ storage.reset()
117
+
118
+
119
+ def _extract_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request | None:
120
+ """Extract FastAPI Request instance from positional or keyword arguments."""
121
+ for v in kwargs.values():
122
+ if isinstance(v, Request):
123
+ return v
124
+ for arg in args:
125
+ if isinstance(arg, Request):
126
+ return arg
127
+ return None
128
+
129
+
130
+ def _enforce_rate_limit(
131
+ request: Request | None,
132
+ fn_name: str,
133
+ limit: str,
134
+ key_extractor: Callable[[Request], str],
135
+ detail: str | None,
136
+ ) -> None:
137
+ """Check rate limit quota against application rate limiter and raise HTTPException if exceeded."""
138
+ if request is None:
139
+ return
140
+ limiter_port: RateLimiterPort | None = getattr(
141
+ request.app.state, "rate_limiter", None
142
+ )
143
+ if limiter_port is None:
144
+ return
145
+
146
+ base_key = key_extractor(request)
147
+ key = f"{base_key}:{fn_name}"
148
+ if not limiter_port.hit(key, limit):
149
+ reset_sec = limiter_port.get_reset_window(key, limit)
150
+ raise HTTPException(
151
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
152
+ detail=detail or f"Rate limit exceeded: {limit}.",
153
+ headers={"Retry-After": str(reset_sec)},
154
+ )
155
+
156
+
157
+ def rate_limit(
158
+ limit: str,
159
+ key_func: Callable[[Request], str] | None = None,
160
+ detail: str | None = None,
161
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
162
+ """Decorator guarding a FastAPI route endpoint with rate limiting.
163
+
164
+ Notes/Architectural Intent:
165
+ Enforces rate limits on endpoint execution. If quota is exceeded,
166
+ raises an HTTPException with status 429 Too Many Requests and
167
+ includes a 'Retry-After' header.
168
+
169
+ Args:
170
+ limit: Rate limit specification string (e.g. '5/minute', '10/second').
171
+ key_func: Optional custom callable to extract bucket key from request.
172
+ detail: Optional error detail message.
173
+
174
+ Returns:
175
+ Decorator wrapping the endpoint function.
176
+ """
177
+ key_extractor = key_func or get_user_or_ip_key
178
+
179
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
180
+ fn_name = getattr(fn, "__name__", "endpoint")
181
+
182
+ if inspect.iscoroutinefunction(fn):
183
+
184
+ @wraps(fn)
185
+ async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
186
+ req = _extract_request(args, kwargs)
187
+ _enforce_rate_limit(req, fn_name, limit, key_extractor, detail)
188
+ return await fn(*args, **kwargs)
189
+
190
+ return async_wrapped
191
+
192
+ @wraps(fn)
193
+ def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
194
+ req = _extract_request(args, kwargs)
195
+ _enforce_rate_limit(req, fn_name, limit, key_extractor, detail)
196
+ return fn(*args, **kwargs)
197
+
198
+ return sync_wrapped
199
+
200
+ return decorator
201
+
202
+
203
+ __all__ = [
204
+ "get_remote_address",
205
+ "get_user_or_ip_key",
206
+ "rate_limit",
207
+ "SlowapiRateLimiterAdapter",
208
+ ]
@@ -29,6 +29,7 @@ class CqrsRouter(APIRouter):
29
29
  summary: str | None = None,
30
30
  tags: list[str | Enum] | None = None,
31
31
  feature_flag: str | None = None,
32
+ rate_limit: str | None = None,
32
33
  ) -> None:
33
34
  """Register an HTTP endpoint for executing a domain Command.
34
35
 
@@ -41,6 +42,7 @@ class CqrsRouter(APIRouter):
41
42
  summary: Optional OpenAPI summary string.
42
43
  tags: Optional OpenAPI tags list.
43
44
  feature_flag: Optional feature flag key required for endpoint execution.
45
+ rate_limit: Optional rate limit string (e.g. '10/minute').
44
46
 
45
47
  Returns:
46
48
  None.
@@ -54,6 +56,10 @@ class CqrsRouter(APIRouter):
54
56
  from hexastack_fastapi.adapters.dependencies import require_feature
55
57
 
56
58
  dependencies.append(Depends(require_feature(feature_flag)))
59
+ if rate_limit:
60
+ from hexastack_fastapi.adapters.dependencies import require_rate_limit
61
+
62
+ dependencies.append(Depends(require_rate_limit(rate_limit)))
57
63
 
58
64
  self.add_api_route(
59
65
  path=path,
@@ -77,6 +83,7 @@ class CqrsRouter(APIRouter):
77
83
  summary: str | None = None,
78
84
  tags: list[str | Enum] | None = None,
79
85
  feature_flag: str | None = None,
86
+ rate_limit: str | None = None,
80
87
  ) -> None:
81
88
  """Register an HTTP endpoint for executing a domain Query.
82
89
 
@@ -89,6 +96,7 @@ class CqrsRouter(APIRouter):
89
96
  summary: Optional OpenAPI summary string.
90
97
  tags: Optional OpenAPI tags list.
91
98
  feature_flag: Optional feature flag key required for endpoint execution.
99
+ rate_limit: Optional rate limit string (e.g. '10/minute').
92
100
 
93
101
  Returns:
94
102
  None.
@@ -102,6 +110,10 @@ class CqrsRouter(APIRouter):
102
110
  from hexastack_fastapi.adapters.dependencies import require_feature
103
111
 
104
112
  dependencies.append(Depends(require_feature(feature_flag)))
113
+ if rate_limit:
114
+ from hexastack_fastapi.adapters.dependencies import require_rate_limit
115
+
116
+ dependencies.append(Depends(require_rate_limit(rate_limit)))
105
117
 
106
118
  self.add_api_route(
107
119
  path=path,
@@ -229,15 +229,22 @@ def _render_middleware_chain(middlewares: list[Any]) -> None:
229
229
  ui.chip(
230
230
  f"{idx}. {mw_name}",
231
231
  icon="security" if "Auth" in mw_name else "filter_alt",
232
- ).classes(
233
- "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
234
- )
232
+ color="blue-9",
233
+ text_color="white",
234
+ ).classes("font-medium")
235
235
  if idx < len(middlewares):
236
- ui.icon("arrow_forward", size="sm").classes("text-slate-400")
237
- ui.icon("arrow_forward", size="sm").classes("text-slate-400")
238
- ui.chip("Handler Execution", icon="play_circle").classes(
239
- "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200 font-bold"
236
+ ui.icon("arrow_forward", size="sm").classes(
237
+ "text-slate-600 dark:text-slate-300"
238
+ )
239
+ ui.icon("arrow_forward", size="sm").classes(
240
+ "text-slate-600 dark:text-slate-300"
240
241
  )
242
+ ui.chip(
243
+ "Handler Execution",
244
+ icon="play_circle",
245
+ color="green-9",
246
+ text_color="white",
247
+ ).classes("font-bold")
241
248
  else:
242
249
  ui.label("No active middlewares attached to CommandBus.").classes(
243
250
  "text-slate-500 italic mb-6"
@@ -315,9 +322,9 @@ def _render_live_runner(
315
322
  except Exception as exc:
316
323
  log_output.push(f"❌ [ERROR] Execution failed: {exc}")
317
324
 
318
- ui.button("Dispatch Ping Command", on_click=_run_ping, icon="send").classes(
319
- "bg-blue-600 text-white"
320
- )
325
+ ui.button(
326
+ "Dispatch Ping Command", on_click=_run_ping, icon="send", color="blue-9"
327
+ ).classes("text-white font-medium shadow-sm")
321
328
 
322
329
 
323
330
  def _render_cqrs_tab(
@@ -366,15 +373,16 @@ def _render_flags_tab(container: Container) -> None:
366
373
  ],
367
374
  rows=flag_rows,
368
375
  row_key="flag",
376
+ pagination={"rowsPerPage": 10},
369
377
  ).classes("w-full")
370
378
  else:
371
379
  ui.label("No feature flags currently configured in provider.").classes(
372
- "text-slate-500 italic"
380
+ "text-slate-600 dark:text-slate-400 italic"
373
381
  )
374
382
  else:
375
383
  ui.label(
376
384
  "Feature flag provider not available or does not support listing."
377
- ).classes("text-slate-500 italic")
385
+ ).classes("text-slate-600 dark:text-slate-400 italic")
378
386
 
379
387
 
380
388
  def _render_container_tab(container: Container) -> None:
@@ -427,10 +435,11 @@ def _render_container_tab(container: Container) -> None:
427
435
  ],
428
436
  rows=services,
429
437
  row_key="service",
438
+ pagination={"rowsPerPage": 10},
430
439
  ).classes("w-full")
431
440
  else:
432
441
  ui.label("No direct services found in container introspection.").classes(
433
- "text-slate-500 italic"
442
+ "text-slate-600 dark:text-slate-400 italic"
434
443
  )
435
444
 
436
445
 
@@ -465,7 +474,7 @@ def mount_devtools_dashboard(
465
474
 
466
475
  @ui.page(path, title=title)
467
476
  def devtools_page():
468
- ui.colors(primary="#3B82F6", secondary="#10B981", accent="#8B5CF6")
477
+ ui.colors(primary="#1E40AF", secondary="#065F46", accent="#5B21B6")
469
478
 
470
479
  with ui.header().classes(
471
480
  "items-center justify-between bg-slate-900 text-white px-6 py-3"
@@ -473,7 +482,9 @@ def mount_devtools_dashboard(
473
482
  with ui.row().classes("items-center gap-3"):
474
483
  ui.icon("layers", size="md").classes("text-blue-400")
475
484
  ui.label(title).classes("text-xl font-bold tracking-tight")
476
- ui.badge("v0.1.0", color="blue").classes("text-xs")
485
+ ui.badge("v0.1.0", color="blue-10").classes(
486
+ "text-xs font-semibold text-white"
487
+ )
477
488
 
478
489
  with ui.tabs().classes("w-full bg-slate-100 dark:bg-slate-800") as tabs:
479
490
  tab_cqrs = ui.tab("CQRS Registry", icon="bolt")
@@ -87,6 +87,7 @@ def create_route_visitor(router: CqrsRouter) -> DiscoveryVisitor:
87
87
  summary=meta.summary,
88
88
  tags=tags,
89
89
  feature_flag=meta.feature_flag,
90
+ rate_limit=meta.rate_limit,
90
91
  )
91
92
  elif meta.kind == "query" and isinstance(obj, type) and issubclass(obj, Query):
92
93
  router.add_query(
@@ -98,6 +99,7 @@ def create_route_visitor(router: CqrsRouter) -> DiscoveryVisitor:
98
99
  summary=meta.summary,
99
100
  tags=tags,
100
101
  feature_flag=meta.feature_flag,
102
+ rate_limit=meta.rate_limit,
101
103
  )
102
104
 
103
105
  return visitor
@@ -49,6 +49,26 @@ class RequestLoggingConfig(BaseModel):
49
49
  )
50
50
 
51
51
 
52
+ class RateLimitConfig(BaseModel):
53
+ """Configuration schema for rate limiting and request quota enforcement.
54
+
55
+ Notes/Architectural Intent:
56
+ Controls global and endpoint-level rate limits, key extraction strategy
57
+ (IP, user_id, tenant_id), and storage backend connection.
58
+ """
59
+
60
+ enable: bool = Field(default=False)
61
+ default_limits: list[str] = Field(default_factory=lambda: ["100/minute"])
62
+ key_func: str = Field(
63
+ default="ip",
64
+ description="Key extraction strategy: 'ip', 'user', 'tenant', or 'combined'.",
65
+ )
66
+ storage_uri: str = Field(
67
+ default="memory://",
68
+ description="Backend storage URI: 'memory://' or 'redis://localhost:6379'.",
69
+ )
70
+
71
+
52
72
  class ZensicalDocsConfig(BaseModel):
53
73
  """Configuration schema for mounting pre-built Zensical documentation onto FastAPI.
54
74
 
@@ -83,6 +103,7 @@ class HexastackFastApiConfig(BaseModel):
83
103
  cors: CorsConfig = Field(default_factory=CorsConfig)
84
104
  health: HealthConfig = Field(default_factory=HealthConfig)
85
105
  logging: RequestLoggingConfig = Field(default_factory=RequestLoggingConfig)
106
+ ratelimit: RateLimitConfig = Field(default_factory=RateLimitConfig)
86
107
  zensical: ZensicalDocsConfig = Field(default_factory=ZensicalDocsConfig)
87
108
  auto_register_routes: bool = Field(default=True)
88
109
  packages_to_scan: list[str] = Field(default_factory=list)
@@ -92,6 +113,7 @@ __all__ = [
92
113
  "CorsConfig",
93
114
  "HealthConfig",
94
115
  "HexastackFastApiConfig",
116
+ "RateLimitConfig",
95
117
  "register_fastapi_config",
96
118
  "RequestLoggingConfig",
97
119
  "ZensicalDocsConfig",
@@ -1,10 +1,13 @@
1
1
  from collections.abc import Callable
2
2
  from dataclasses import dataclass
3
3
  from enum import Enum
4
- from typing import Any, Literal
4
+ from typing import Any, Literal, TypeVar
5
5
 
6
6
  from hexastack_core.domain import Command, Query
7
7
 
8
+ TCommand = TypeVar("TCommand", bound=Command)
9
+ TQuery = TypeVar("TQuery", bound=Query)
10
+
8
11
  _ROUTE_METADATA_ATTR = "__hexastack_route__"
9
12
 
10
13
 
@@ -14,7 +17,7 @@ class RouteMetadata:
14
17
 
15
18
  Notes/Architectural Intent:
16
19
  Carries routing parameters attached by decorators for automated endpoint registration,
17
- including optional feature flag gating.
20
+ including optional feature flag gating and rate limiting.
18
21
  """
19
22
 
20
23
  path: str
@@ -25,6 +28,7 @@ class RouteMetadata:
25
28
  summary: str | None = None
26
29
  tags: tuple[str | Enum, ...] | None = None
27
30
  feature_flag: str | None = None
31
+ rate_limit: str | None = None
28
32
 
29
33
 
30
34
  __all__ = [
@@ -35,7 +39,7 @@ __all__ = [
35
39
  ]
36
40
 
37
41
 
38
- def api_command[TCommand: Command](
42
+ def api_command(
39
43
  path: str,
40
44
  *,
41
45
  method: str = "POST",
@@ -44,6 +48,7 @@ def api_command[TCommand: Command](
44
48
  summary: str | None = None,
45
49
  tags: list[str | Enum] | None = None,
46
50
  feature_flag: str | None = None,
51
+ rate_limit: str | None = None,
47
52
  ) -> Callable[[type[TCommand]], type[TCommand]]:
48
53
  """Decorator marking a Command class for automatic HTTP endpoint exposure.
49
54
 
@@ -55,6 +60,7 @@ def api_command[TCommand: Command](
55
60
  summary: Optional OpenAPI summary.
56
61
  tags: Optional OpenAPI tags list.
57
62
  feature_flag: Optional feature flag key required for route access.
63
+ rate_limit: Optional rate limit string (e.g. '10/minute').
58
64
 
59
65
  Returns:
60
66
  Decorated Command class with attached routing metadata.
@@ -73,6 +79,7 @@ def api_command[TCommand: Command](
73
79
  summary=summary,
74
80
  tags=tuple(tags) if tags else None,
75
81
  feature_flag=feature_flag,
82
+ rate_limit=rate_limit,
76
83
  )
77
84
  setattr(cls, _ROUTE_METADATA_ATTR, meta)
78
85
  return cls
@@ -80,7 +87,7 @@ def api_command[TCommand: Command](
80
87
  return decorator
81
88
 
82
89
 
83
- def api_query[TQuery: Query](
90
+ def api_query(
84
91
  path: str,
85
92
  *,
86
93
  method: str = "GET",
@@ -89,6 +96,7 @@ def api_query[TQuery: Query](
89
96
  summary: str | None = None,
90
97
  tags: list[str | Enum] | None = None,
91
98
  feature_flag: str | None = None,
99
+ rate_limit: str | None = None,
92
100
  ) -> Callable[[type[TQuery]], type[TQuery]]:
93
101
  """Decorator marking a Query class for automatic HTTP endpoint exposure.
94
102
 
@@ -100,6 +108,7 @@ def api_query[TQuery: Query](
100
108
  summary: Optional OpenAPI summary.
101
109
  tags: Optional OpenAPI tags list.
102
110
  feature_flag: Optional feature flag key required for route access.
111
+ rate_limit: Optional rate limit string (e.g. '10/minute').
103
112
 
104
113
  Returns:
105
114
  Decorated Query class with attached routing metadata.
@@ -118,6 +127,7 @@ def api_query[TQuery: Query](
118
127
  summary=summary,
119
128
  tags=tuple(tags) if tags else None,
120
129
  feature_flag=feature_flag,
130
+ rate_limit=rate_limit,
121
131
  )
122
132
  setattr(cls, _ROUTE_METADATA_ATTR, meta)
123
133
  return cls
@@ -47,7 +47,17 @@ def register_exception_handlers(
47
47
  status_code = 404
48
48
  elif "conflict" in exc_name or "duplicate" in exc_name:
49
49
  status_code = 409
50
- elif "unauthorized" in exc_name or "forbidden" in exc_name:
50
+ elif (
51
+ "permission" in exc_name
52
+ or "forbidden" in exc_name
53
+ or "accessdenied" in exc_name
54
+ ):
55
+ status_code = 403
56
+ elif (
57
+ "unauthorized" in exc_name
58
+ or "unauthenticated" in exc_name
59
+ or "invalidcredential" in exc_name
60
+ ):
51
61
  status_code = 401
52
62
  elif "validation" in exc_name:
53
63
  status_code = 422