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.
- hexastack_fastapi-0.0.0/README.md → hexastack_fastapi-0.2.0/PKG-INFO +36 -3
- hexastack_fastapi-0.0.0/PKG-INFO → hexastack_fastapi-0.2.0/README.md +10 -23
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/pyproject.toml +27 -1
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/pyproject.toml.orig +32 -1
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/__init__.py +14 -1
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/app.py +26 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/dependencies.py +63 -0
- hexastack_fastapi-0.2.0/src/hexastack_fastapi/adapters/ratelimit.py +208 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/routing.py +12 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/ui.py +26 -15
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/autodiscovery.py +2 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/config.py +22 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/decorators.py +14 -4
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/exception_handlers.py +11 -1
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/__init__.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/db_session.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/docs.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/health.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/__init__.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/bootstrap.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/__init__.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/correlation.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/middleware/logging.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/py.typed +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/__init__.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/cursor.py +0 -0
- {hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/recorder.py +0 -0
- {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
|
+

|
|
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
|
+
[](https://pypi.org/project/hexastack-fastapi/)
|
|
5
33
|
[](https://www.python.org/downloads/)
|
|
6
|
-
|
|
34
|
+
[](https://codecov.io/github/TheTrueSCU/hexastack)
|
|
35
|
+
[](../../LICENSE)
|
|
36
|
+
[](https://www.w3.org/WAI/WCAG21/quickref/?levels=aa)
|
|
37
|
+
[](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
|
-
|
|
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
|
+

|
|
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
|
+
[](https://pypi.org/project/hexastack-fastapi/)
|
|
26
8
|
[](https://www.python.org/downloads/)
|
|
27
|
-
|
|
9
|
+
[](https://codecov.io/github/TheTrueSCU/hexastack)
|
|
10
|
+
[](../../LICENSE)
|
|
11
|
+
[](https://www.w3.org/WAI/WCAG21/quickref/?levels=aa)
|
|
12
|
+
[](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.
|
|
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.
|
|
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
|
+
]
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/__init__.py
RENAMED
|
@@ -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(
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/dependencies.py
RENAMED
|
@@ -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
|
+
]
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/routing.py
RENAMED
|
@@ -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
|
-
|
|
233
|
-
"
|
|
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(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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(
|
|
319
|
-
"
|
|
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-
|
|
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-
|
|
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-
|
|
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="#
|
|
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(
|
|
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")
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/autodiscovery.py
RENAMED
|
@@ -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",
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/decorators.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/db_session.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/adapters/health.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/infra/bootstrap.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexastack_fastapi-0.0.0 → hexastack_fastapi-0.2.0}/src/hexastack_fastapi/testing/recorder.py
RENAMED
|
File without changes
|
|
File without changes
|