hexastack-fastapi 0.1.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.1.0 → hexastack_fastapi-0.2.0}/PKG-INFO +8 -3
  2. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/README.md +4 -2
  3. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/pyproject.toml +26 -1
  4. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/pyproject.toml.orig +31 -1
  5. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/__init__.py +14 -1
  6. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/app.py +26 -0
  7. {hexastack_fastapi-0.1.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.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/routing.py +12 -0
  10. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/autodiscovery.py +2 -0
  11. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/config.py +22 -0
  12. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/decorators.py +14 -4
  13. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/__init__.py +0 -0
  14. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
  15. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
  16. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/health.py +0 -0
  17. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/ui.py +0 -0
  18. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/__init__.py +0 -0
  19. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/bootstrap.py +0 -0
  20. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/exception_handlers.py +0 -0
  21. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/__init__.py +0 -0
  22. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/correlation.py +0 -0
  23. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
  24. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/py.typed +0 -0
  25. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
  26. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
  27. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/recorder.py +0 -0
  28. {hexastack_fastapi-0.1.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexastack-fastapi
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: FastAPI presentation adapter for Hexastack
5
5
  Author: Richard West
6
6
  Author-email: Richard West <dopplereffect.us@gmail.com>
@@ -10,12 +10,15 @@ Requires-Dist: hexastack-core
10
10
  Requires-Dist: hexastack-cqrs
11
11
  Requires-Dist: hexastack-auth ; extra == 'auth'
12
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'
13
15
  Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
14
16
  Requires-Dist: schemathesis>=3.39.0 ; extra == 'testing'
15
17
  Requires-Dist: nicegui>=2.0.0 ; extra == 'ui'
16
18
  Requires-Python: >=3.13
17
19
  Provides-Extra: auth
18
20
  Provides-Extra: docs
21
+ Provides-Extra: ratelimit
19
22
  Provides-Extra: testing
20
23
  Provides-Extra: ui
21
24
  Description-Content-Type: text/markdown
@@ -59,15 +62,17 @@ hexastack_fastapi/
59
62
  | Category | Exports |
60
63
  |---|---|
61
64
  | **Application Factory** | `create_app`, `FastApiBootstrapper` (order=30) |
62
- | **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `RouteMetadata` |
63
- | **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` |
64
67
  | **Documentation Mounting** | `mount_zensical_docs`, `DocumentationNotFoundError`, `ZensicalDocsConfig` |
65
68
  | **Exception Handlers** | `register_exception_handlers`, `domain_exception_handler` |
66
69
  | **Middlewares** | `DbSessionMiddleware`, `AsyncDbSessionMiddleware`, `add_db_session_middleware`, `CorrelationMiddleware`, `HttpLoggingMiddleware` |
70
+ | **Rate Limiting** | `SlowapiRateLimiterAdapter`, `get_remote_address`, `get_user_or_ip_key`, `RateLimitConfig` |
67
71
  | **Reactive UI (NiceGUI)** | `dispatch_command`, `dispatch_query`, `mount_devtools_dashboard`, `mount_ui_app`, `ui_page` |
68
72
  | **Routing** | `CqrsRouter`, `autodiscover_routes` |
69
73
  | **Testing, E2E & Demo Narration** | `create_test_client`, `check_openapi_conformance`, `EphemeralServer`, `ephemeral_server`, `find_free_port`, `smart_click`, `DemoNarrator`, `VIRTUAL_CURSOR_SCRIPT` |
70
74
 
75
+
71
76
  ---
72
77
 
73
78
  ## 3. Monorepo & Sibling Relationships
@@ -37,15 +37,17 @@ hexastack_fastapi/
37
37
  | Category | Exports |
38
38
  |---|---|
39
39
  | **Application Factory** | `create_app`, `FastApiBootstrapper` (order=30) |
40
- | **Decorators** | `@api_command`, `@api_query`, `@feature_flag_route`, `RouteMetadata` |
41
- | **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` |
42
42
  | **Documentation Mounting** | `mount_zensical_docs`, `DocumentationNotFoundError`, `ZensicalDocsConfig` |
43
43
  | **Exception Handlers** | `register_exception_handlers`, `domain_exception_handler` |
44
44
  | **Middlewares** | `DbSessionMiddleware`, `AsyncDbSessionMiddleware`, `add_db_session_middleware`, `CorrelationMiddleware`, `HttpLoggingMiddleware` |
45
+ | **Rate Limiting** | `SlowapiRateLimiterAdapter`, `get_remote_address`, `get_user_or_ip_key`, `RateLimitConfig` |
45
46
  | **Reactive UI (NiceGUI)** | `dispatch_command`, `dispatch_query`, `mount_devtools_dashboard`, `mount_ui_app`, `ui_page` |
46
47
  | **Routing** | `CqrsRouter`, `autodiscover_routes` |
47
48
  | **Testing, E2E & Demo Narration** | `create_test_client`, `check_openapi_conformance`, `EphemeralServer`, `ephemeral_server`, `find_free_port`, `smart_click`, `DemoNarrator`, `VIRTUAL_CURSOR_SCRIPT` |
48
49
 
50
+
49
51
  ---
50
52
 
51
53
  ## 3. Monorepo & Sibling Relationships
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexastack-fastapi"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "FastAPI presentation adapter for Hexastack"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -18,6 +18,10 @@ email = "dopplereffect.us@gmail.com"
18
18
  [project.optional-dependencies]
19
19
  auth = ["hexastack-auth"]
20
20
  docs = ["zensical>=0.0.56"]
21
+ ratelimit = [
22
+ "limits>=3.14.0",
23
+ "slowapi>=0.1.9",
24
+ ]
21
25
  testing = [
22
26
  "playwright>=1.49.0",
23
27
  "schemathesis>=3.39.0",
@@ -42,3 +46,24 @@ workspace = true
42
46
 
43
47
  [tool.importlinter]
44
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexastack-fastapi"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "FastAPI presentation adapter for Hexastack"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -21,6 +21,10 @@ auth = [
21
21
  docs = [
22
22
  "zensical>=0.0.56",
23
23
  ]
24
+ ratelimit = [
25
+ "limits>=3.14.0",
26
+ "slowapi>=0.1.9",
27
+ ]
24
28
  testing = [
25
29
  "playwright>=1.49.0",
26
30
  "schemathesis>=3.39.0",
@@ -29,6 +33,7 @@ ui = [
29
33
  "nicegui>=2.0.0",
30
34
  ]
31
35
 
36
+
32
37
  [project.entry-points."hexastack.bootstrappers"]
33
38
  fastapi = "hexastack_fastapi.infra.bootstrap:FastApiBootstrapper"
34
39
 
@@ -43,3 +48,28 @@ hexastack-cqrs = { workspace = true }
43
48
 
44
49
  [tool.importlinter]
45
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,
@@ -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