hexastack-fastapi 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hexastack-fastapi might be problematic. Click here for more details.
- hexastack_fastapi/__init__.py +6 -0
- hexastack_fastapi/adapters/__init__.py +41 -0
- hexastack_fastapi/adapters/app.py +118 -0
- hexastack_fastapi/adapters/db_session.py +160 -0
- hexastack_fastapi/adapters/dependencies.py +190 -0
- hexastack_fastapi/adapters/docs.py +80 -0
- hexastack_fastapi/adapters/health.py +62 -0
- hexastack_fastapi/adapters/routing.py +203 -0
- hexastack_fastapi/adapters/ui.py +491 -0
- hexastack_fastapi/infra/__init__.py +41 -0
- hexastack_fastapi/infra/autodiscovery.py +103 -0
- hexastack_fastapi/infra/bootstrap.py +85 -0
- hexastack_fastapi/infra/config.py +113 -0
- hexastack_fastapi/infra/decorators.py +199 -0
- hexastack_fastapi/infra/exception_handlers.py +60 -0
- hexastack_fastapi/infra/middleware/__init__.py +11 -0
- hexastack_fastapi/infra/middleware/correlation.py +100 -0
- hexastack_fastapi/infra/middleware/logging.py +95 -0
- hexastack_fastapi/py.typed +0 -0
- hexastack_fastapi/testing/__init__.py +18 -0
- hexastack_fastapi/testing/cursor.py +115 -0
- hexastack_fastapi/testing/recorder.py +238 -0
- hexastack_fastapi/testing/server.py +120 -0
- hexastack_fastapi-0.0.0.dist-info/METADATA +169 -0
- hexastack_fastapi-0.0.0.dist-info/RECORD +27 -0
- hexastack_fastapi-0.0.0.dist-info/WHEEL +4 -0
- hexastack_fastapi-0.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Annotated, Any, cast
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
|
|
8
|
+
from hexastack_core.domain import Command, Query
|
|
9
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
10
|
+
from hexastack_fastapi.adapters.dependencies import get_pipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CqrsRouter(APIRouter):
|
|
14
|
+
"""APIRouter providing direct endpoint registration for CQRS Commands and Queries.
|
|
15
|
+
|
|
16
|
+
Notes/Architectural Intent:
|
|
17
|
+
Eliminates boilerplate HTTP controller code by binding domain Command and Query
|
|
18
|
+
schemas directly to FastAPI endpoint routes dispatched via ExecutionPipeline.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def add_command[TCommand: Command](
|
|
22
|
+
self,
|
|
23
|
+
path: str,
|
|
24
|
+
command_cls: type[TCommand],
|
|
25
|
+
*,
|
|
26
|
+
method: str = "POST",
|
|
27
|
+
status_code: int = 200,
|
|
28
|
+
output_format: str | None = None,
|
|
29
|
+
summary: str | None = None,
|
|
30
|
+
tags: list[str | Enum] | None = None,
|
|
31
|
+
feature_flag: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Register an HTTP endpoint for executing a domain Command.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
path: URL path for the route.
|
|
37
|
+
command_cls: Pydantic Command model class.
|
|
38
|
+
method: HTTP method (e.g., 'POST', 'PUT', 'DELETE').
|
|
39
|
+
status_code: Success HTTP status code.
|
|
40
|
+
output_format: Optional presenter format (e.g., 'json').
|
|
41
|
+
summary: Optional OpenAPI summary string.
|
|
42
|
+
tags: Optional OpenAPI tags list.
|
|
43
|
+
feature_flag: Optional feature flag key required for endpoint execution.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
None.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
None.
|
|
50
|
+
"""
|
|
51
|
+
endpoint = _create_command_endpoint(command_cls, output_format)
|
|
52
|
+
dependencies = []
|
|
53
|
+
if feature_flag:
|
|
54
|
+
from hexastack_fastapi.adapters.dependencies import require_feature
|
|
55
|
+
|
|
56
|
+
dependencies.append(Depends(require_feature(feature_flag)))
|
|
57
|
+
|
|
58
|
+
self.add_api_route(
|
|
59
|
+
path=path,
|
|
60
|
+
endpoint=endpoint,
|
|
61
|
+
methods=[method.upper()],
|
|
62
|
+
status_code=status_code,
|
|
63
|
+
summary=summary or f"Execute {command_cls.__name__}",
|
|
64
|
+
tags=tags,
|
|
65
|
+
dependencies=dependencies if dependencies else None,
|
|
66
|
+
response_model=None,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def add_query[TQuery: Query](
|
|
70
|
+
self,
|
|
71
|
+
path: str,
|
|
72
|
+
query_cls: type[TQuery],
|
|
73
|
+
*,
|
|
74
|
+
method: str = "GET",
|
|
75
|
+
status_code: int = 200,
|
|
76
|
+
output_format: str | None = None,
|
|
77
|
+
summary: str | None = None,
|
|
78
|
+
tags: list[str | Enum] | None = None,
|
|
79
|
+
feature_flag: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Register an HTTP endpoint for executing a domain Query.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
path: URL path for the route.
|
|
85
|
+
query_cls: Pydantic Query model class.
|
|
86
|
+
method: HTTP method ('GET' or 'POST').
|
|
87
|
+
status_code: Success HTTP status code.
|
|
88
|
+
output_format: Optional presenter format (e.g., 'json').
|
|
89
|
+
summary: Optional OpenAPI summary string.
|
|
90
|
+
tags: Optional OpenAPI tags list.
|
|
91
|
+
feature_flag: Optional feature flag key required for endpoint execution.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
None.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
None.
|
|
98
|
+
"""
|
|
99
|
+
endpoint = _create_query_endpoint(query_cls, method, output_format)
|
|
100
|
+
dependencies = []
|
|
101
|
+
if feature_flag:
|
|
102
|
+
from hexastack_fastapi.adapters.dependencies import require_feature
|
|
103
|
+
|
|
104
|
+
dependencies.append(Depends(require_feature(feature_flag)))
|
|
105
|
+
|
|
106
|
+
self.add_api_route(
|
|
107
|
+
path=path,
|
|
108
|
+
endpoint=endpoint,
|
|
109
|
+
methods=[method.upper()],
|
|
110
|
+
status_code=status_code,
|
|
111
|
+
summary=summary or f"Execute {query_cls.__name__}",
|
|
112
|
+
tags=tags,
|
|
113
|
+
dependencies=dependencies if dependencies else None,
|
|
114
|
+
response_model=None,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"CqrsRouter",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _create_command_endpoint(
|
|
124
|
+
command_cls: type[Command], output_format: str | None
|
|
125
|
+
) -> Callable[..., Any]:
|
|
126
|
+
"""Dynamically construct a typed FastAPI endpoint for a Command model."""
|
|
127
|
+
|
|
128
|
+
async def endpoint(
|
|
129
|
+
cmd: Any,
|
|
130
|
+
pipeline: Annotated[ExecutionPipeline, Depends(get_pipeline)],
|
|
131
|
+
) -> Any:
|
|
132
|
+
return pipeline.execute(cmd, output_format=output_format)
|
|
133
|
+
|
|
134
|
+
sig = inspect.signature(endpoint)
|
|
135
|
+
params = [
|
|
136
|
+
inspect.Parameter(
|
|
137
|
+
"cmd",
|
|
138
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
139
|
+
annotation=command_cls,
|
|
140
|
+
),
|
|
141
|
+
inspect.Parameter(
|
|
142
|
+
"pipeline",
|
|
143
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
144
|
+
annotation=ExecutionPipeline,
|
|
145
|
+
default=Depends(get_pipeline),
|
|
146
|
+
),
|
|
147
|
+
]
|
|
148
|
+
cast("Any", endpoint).__signature__ = sig.replace(parameters=params)
|
|
149
|
+
return endpoint
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _create_query_endpoint(
|
|
153
|
+
query_cls: type[Query], method: str, output_format: str | None
|
|
154
|
+
) -> Callable[..., Any]:
|
|
155
|
+
"""Dynamically construct a typed FastAPI endpoint for a Query model."""
|
|
156
|
+
if method.upper() == "GET":
|
|
157
|
+
|
|
158
|
+
async def get_endpoint(
|
|
159
|
+
qry: Any,
|
|
160
|
+
pipeline: Annotated[ExecutionPipeline, Depends(get_pipeline)],
|
|
161
|
+
) -> Any:
|
|
162
|
+
return pipeline.execute(qry, output_format=output_format)
|
|
163
|
+
|
|
164
|
+
sig = inspect.signature(get_endpoint)
|
|
165
|
+
params = [
|
|
166
|
+
inspect.Parameter(
|
|
167
|
+
"qry",
|
|
168
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
169
|
+
annotation=query_cls,
|
|
170
|
+
default=Depends(query_cls),
|
|
171
|
+
),
|
|
172
|
+
inspect.Parameter(
|
|
173
|
+
"pipeline",
|
|
174
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
175
|
+
annotation=ExecutionPipeline,
|
|
176
|
+
default=Depends(get_pipeline),
|
|
177
|
+
),
|
|
178
|
+
]
|
|
179
|
+
cast("Any", get_endpoint).__signature__ = sig.replace(parameters=params)
|
|
180
|
+
return get_endpoint
|
|
181
|
+
|
|
182
|
+
async def post_endpoint(
|
|
183
|
+
qry: Any,
|
|
184
|
+
pipeline: Annotated[ExecutionPipeline, Depends(get_pipeline)],
|
|
185
|
+
) -> Any:
|
|
186
|
+
return pipeline.execute(qry, output_format=output_format)
|
|
187
|
+
|
|
188
|
+
sig = inspect.signature(post_endpoint)
|
|
189
|
+
params = [
|
|
190
|
+
inspect.Parameter(
|
|
191
|
+
"qry",
|
|
192
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
193
|
+
annotation=query_cls,
|
|
194
|
+
),
|
|
195
|
+
inspect.Parameter(
|
|
196
|
+
"pipeline",
|
|
197
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
198
|
+
annotation=ExecutionPipeline,
|
|
199
|
+
default=Depends(get_pipeline),
|
|
200
|
+
),
|
|
201
|
+
]
|
|
202
|
+
cast("Any", post_endpoint).__signature__ = sig.replace(parameters=params)
|
|
203
|
+
return post_endpoint
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""NiceGUI UI presentation adapter and CQRS reactive bindings for Hexastack.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides reactive UI integration built on NiceGUI and FastAPI:
|
|
5
|
+
1. Primitives: `ui_page`, `dispatch_command`, `dispatch_query`, and reactive hooks
|
|
6
|
+
enabling developers to build custom reactive web applications directly on CQRS.
|
|
7
|
+
2. Default DevTools Dashboard: `mount_devtools_dashboard` providing an out-of-the-box
|
|
8
|
+
interactive inspector for CQRS buses, feature flags, DI container, and metadata.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from rodi import Container
|
|
19
|
+
|
|
20
|
+
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
21
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from hexastack_core.domain import Command, Query
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"dispatch_command",
|
|
28
|
+
"dispatch_query",
|
|
29
|
+
"mount_devtools_dashboard",
|
|
30
|
+
"mount_ui_app",
|
|
31
|
+
"ui_page",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_nicegui_installed() -> None:
|
|
36
|
+
"""Verify NiceGUI is available in runtime environment."""
|
|
37
|
+
try:
|
|
38
|
+
import nicegui # noqa: F401
|
|
39
|
+
except ImportError as e:
|
|
40
|
+
raise MissingDependencyError(
|
|
41
|
+
"NiceGUI is required for hexastack-fastapi UI support. "
|
|
42
|
+
"Install with 'pip install hexastack-fastapi[ui]' or 'pip install nicegui'."
|
|
43
|
+
) from e
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ui_page(
|
|
47
|
+
path: str,
|
|
48
|
+
*,
|
|
49
|
+
title: str | None = None,
|
|
50
|
+
viewport: str | None = None,
|
|
51
|
+
favicon: str | None = None,
|
|
52
|
+
dark: bool | None = None,
|
|
53
|
+
response_timeout: float = 3.0,
|
|
54
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
55
|
+
"""Declarative decorator registering a NiceGUI reactive UI page.
|
|
56
|
+
|
|
57
|
+
Notes/Architectural Intent:
|
|
58
|
+
Wraps `nicegui.ui.page` while cleanly decoupling application modules from
|
|
59
|
+
direct NiceGUI hard dependency at module import time.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
path: URL path for the page (e.g. '/dashboard', '/users').
|
|
63
|
+
title: Optional browser tab title.
|
|
64
|
+
viewport: Optional viewport meta tag string.
|
|
65
|
+
favicon: Optional favicon path or URL.
|
|
66
|
+
dark: Optional dark mode setting (True/False/None).
|
|
67
|
+
response_timeout: Maximum seconds to wait for client connection response.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Decorated page function.
|
|
71
|
+
"""
|
|
72
|
+
_check_nicegui_installed()
|
|
73
|
+
from nicegui import ui
|
|
74
|
+
|
|
75
|
+
return ui.page(
|
|
76
|
+
path,
|
|
77
|
+
title=title,
|
|
78
|
+
viewport=viewport,
|
|
79
|
+
favicon=favicon,
|
|
80
|
+
dark=dark,
|
|
81
|
+
response_timeout=response_timeout,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def dispatch_command(pipeline: ExecutionPipeline, command: Command) -> Any:
|
|
86
|
+
"""Dispatch a CQRS command from a NiceGUI interactive event handler.
|
|
87
|
+
|
|
88
|
+
Notes/Architectural Intent:
|
|
89
|
+
Executes a command through the standard ExecutionPipeline,
|
|
90
|
+
supporting synchronous or asynchronous bus handlers.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
pipeline: ExecutionPipeline instance.
|
|
94
|
+
command: Command instance to execute.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Result returned by command execution.
|
|
98
|
+
"""
|
|
99
|
+
result = pipeline.execute(command)
|
|
100
|
+
if inspect.isawaitable(result):
|
|
101
|
+
return await result
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def dispatch_query(pipeline: ExecutionPipeline, query: Query) -> Any:
|
|
106
|
+
"""Dispatch a CQRS query from a NiceGUI interactive event handler or data loader.
|
|
107
|
+
|
|
108
|
+
Notes/Architectural Intent:
|
|
109
|
+
Executes a query through the standard ExecutionPipeline,
|
|
110
|
+
supporting synchronous or asynchronous bus handlers.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
pipeline: ExecutionPipeline instance.
|
|
114
|
+
query: Query instance to execute.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Result returned by query execution.
|
|
118
|
+
"""
|
|
119
|
+
result = pipeline.execute(query)
|
|
120
|
+
if inspect.isawaitable(result):
|
|
121
|
+
return await result
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def mount_ui_app(
|
|
126
|
+
app: FastAPI,
|
|
127
|
+
*,
|
|
128
|
+
title: str = "Hexastack UI",
|
|
129
|
+
viewport: str = "width=device-width, initial-scale=1",
|
|
130
|
+
favicon: str | None = None,
|
|
131
|
+
dark: bool | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Mount NiceGUI reactive engine onto an existing FastAPI application instance.
|
|
134
|
+
|
|
135
|
+
Notes/Architectural Intent:
|
|
136
|
+
Integrates NiceGUI's Socket.IO and static assets into the FastAPI lifecycle.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
app: Target FastAPI application.
|
|
140
|
+
title: Application default page title.
|
|
141
|
+
viewport: Viewport meta tag.
|
|
142
|
+
favicon: Optional favicon.
|
|
143
|
+
dark: Dark mode preference.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
None.
|
|
147
|
+
"""
|
|
148
|
+
_check_nicegui_installed()
|
|
149
|
+
from nicegui import ui
|
|
150
|
+
|
|
151
|
+
ui.run_with(
|
|
152
|
+
app,
|
|
153
|
+
title=title,
|
|
154
|
+
viewport=viewport,
|
|
155
|
+
favicon=favicon,
|
|
156
|
+
dark=dark,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _render_cqrs_messages(container: Container) -> None:
|
|
161
|
+
"""Render table of registered CQRS message contracts."""
|
|
162
|
+
from nicegui import ui
|
|
163
|
+
|
|
164
|
+
from hexastack_cqrs.infra.registries.command import CommandRegistry
|
|
165
|
+
from hexastack_cqrs.infra.registries.query import QueryRegistry
|
|
166
|
+
|
|
167
|
+
ui.label("Registered CQRS Commands & Queries").classes("text-lg font-semibold mb-4")
|
|
168
|
+
|
|
169
|
+
cmd_reg = (
|
|
170
|
+
container.resolve(CommandRegistry) if CommandRegistry in container else None
|
|
171
|
+
)
|
|
172
|
+
qry_reg = container.resolve(QueryRegistry) if QueryRegistry in container else None
|
|
173
|
+
|
|
174
|
+
commands_list = (
|
|
175
|
+
[
|
|
176
|
+
{"type": "Command", "name": getattr(k, "__name__", str(k))}
|
|
177
|
+
for k in cmd_reg.all.values()
|
|
178
|
+
]
|
|
179
|
+
if cmd_reg
|
|
180
|
+
else []
|
|
181
|
+
)
|
|
182
|
+
queries_list = (
|
|
183
|
+
[
|
|
184
|
+
{"type": "Query", "name": getattr(k, "__name__", str(k))}
|
|
185
|
+
for k in qry_reg.all.values()
|
|
186
|
+
]
|
|
187
|
+
if qry_reg
|
|
188
|
+
else []
|
|
189
|
+
)
|
|
190
|
+
cqrs_rows = commands_list + queries_list
|
|
191
|
+
|
|
192
|
+
if cqrs_rows:
|
|
193
|
+
ui.table(
|
|
194
|
+
columns=[
|
|
195
|
+
{
|
|
196
|
+
"name": "type",
|
|
197
|
+
"label": "Message Type",
|
|
198
|
+
"field": "type",
|
|
199
|
+
"sortable": True,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"name": "name",
|
|
203
|
+
"label": "Class Name",
|
|
204
|
+
"field": "name",
|
|
205
|
+
"sortable": True,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
rows=cqrs_rows,
|
|
209
|
+
row_key="name",
|
|
210
|
+
).classes("w-full mb-6")
|
|
211
|
+
else:
|
|
212
|
+
ui.label("No CQRS messages registered in container.").classes(
|
|
213
|
+
"text-slate-500 italic mb-6"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _render_middleware_chain(middlewares: list[Any]) -> None:
|
|
218
|
+
"""Render visual chain of active command bus middlewares."""
|
|
219
|
+
from nicegui import ui
|
|
220
|
+
|
|
221
|
+
ui.label("Command Bus Middleware Pipeline").classes("text-lg font-semibold mb-2")
|
|
222
|
+
if middlewares:
|
|
223
|
+
with (
|
|
224
|
+
ui.card().classes("w-full bg-slate-50 dark:bg-slate-900 border p-4 mb-6"),
|
|
225
|
+
ui.row().classes("items-center flex-wrap gap-2"),
|
|
226
|
+
):
|
|
227
|
+
for idx, mw in enumerate(middlewares, 1):
|
|
228
|
+
mw_name = type(mw).__name__
|
|
229
|
+
ui.chip(
|
|
230
|
+
f"{idx}. {mw_name}",
|
|
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
|
+
)
|
|
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"
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
ui.label("No active middlewares attached to CommandBus.").classes(
|
|
243
|
+
"text-slate-500 italic mb-6"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _render_live_runner(
|
|
248
|
+
container: Container,
|
|
249
|
+
pipeline: ExecutionPipeline | None,
|
|
250
|
+
middlewares: list[Any],
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Render interactive test runner card for ping command dispatch."""
|
|
253
|
+
from nicegui import ui
|
|
254
|
+
|
|
255
|
+
ui.label("Interactive Pipeline Execution").classes("text-lg font-semibold mb-2")
|
|
256
|
+
with ui.card().classes("w-full bg-slate-50 dark:bg-slate-900 border p-4"):
|
|
257
|
+
ui.label("Dispatch PingDemoCommand across the middleware pipeline:").classes(
|
|
258
|
+
"text-sm text-slate-600 dark:text-slate-400 mb-3"
|
|
259
|
+
)
|
|
260
|
+
with ui.row().classes("items-center gap-3 w-full"):
|
|
261
|
+
ping_input = ui.input(
|
|
262
|
+
label="Message Payload", value="Hello from Hexastack DevTools!"
|
|
263
|
+
).classes("flex-grow")
|
|
264
|
+
log_output = ui.log(max_lines=10).classes(
|
|
265
|
+
"w-full h-32 bg-slate-950 text-emerald-400 p-2 font-mono text-xs rounded border"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def _run_ping() -> None:
|
|
269
|
+
log_output.push(
|
|
270
|
+
f"➡️ [DISPATCH] PingDemoCommand(message='{ping_input.value}')"
|
|
271
|
+
)
|
|
272
|
+
for mw in middlewares:
|
|
273
|
+
log_output.push(
|
|
274
|
+
f" ↳ [MIDDLEWARE] Intercepting through {type(mw).__name__}..."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
active_pipeline = pipeline
|
|
279
|
+
if active_pipeline is None:
|
|
280
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
281
|
+
|
|
282
|
+
if ExecutionPipeline in container:
|
|
283
|
+
active_pipeline = container.resolve(ExecutionPipeline)
|
|
284
|
+
|
|
285
|
+
if active_pipeline is not None:
|
|
286
|
+
# Dynamically find registered command by name matching 'ping'
|
|
287
|
+
cmd_cls = None
|
|
288
|
+
from hexastack_cqrs.infra.registries.command import (
|
|
289
|
+
CommandRegistry,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if CommandRegistry in container:
|
|
293
|
+
creg = container.resolve(CommandRegistry)
|
|
294
|
+
for name, cls in creg.all.items():
|
|
295
|
+
if "ping" in name.lower():
|
|
296
|
+
cmd_cls = cls
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
if cmd_cls is not None:
|
|
300
|
+
cmd_instance = cmd_cls.model_validate(
|
|
301
|
+
{"message": ping_input.value}
|
|
302
|
+
)
|
|
303
|
+
res = active_pipeline.execute(cmd_instance)
|
|
304
|
+
log_output.push(f"✅ [SUCCESS] Result: {res}")
|
|
305
|
+
else:
|
|
306
|
+
# Fallback to execute_by_name directly
|
|
307
|
+
res = active_pipeline.execute_by_name(
|
|
308
|
+
"PingDemoCommand", {"message": ping_input.value}
|
|
309
|
+
)
|
|
310
|
+
log_output.push(f"✅ [SUCCESS] Result: {res}")
|
|
311
|
+
else:
|
|
312
|
+
log_output.push(
|
|
313
|
+
"⚠️ [WARNING] ExecutionPipeline instance not directly bound."
|
|
314
|
+
)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
log_output.push(f"❌ [ERROR] Execution failed: {exc}")
|
|
317
|
+
|
|
318
|
+
ui.button("Dispatch Ping Command", on_click=_run_ping, icon="send").classes(
|
|
319
|
+
"bg-blue-600 text-white"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _render_cqrs_tab(
|
|
324
|
+
container: Container, pipeline: ExecutionPipeline | None = None
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Render the CQRS tab panel showing registered messages, middleware pipeline, and live runner."""
|
|
327
|
+
from hexastack_cqrs.ports.buses import CommandBusPort
|
|
328
|
+
|
|
329
|
+
_render_cqrs_messages(container)
|
|
330
|
+
cmd_bus = container.resolve(CommandBusPort) if CommandBusPort in container else None
|
|
331
|
+
middlewares = getattr(cmd_bus, "_middleware", []) if cmd_bus else []
|
|
332
|
+
_render_middleware_chain(middlewares)
|
|
333
|
+
_render_live_runner(container, pipeline, middlewares)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _render_flags_tab(container: Container) -> None:
|
|
337
|
+
"""Render the feature flags tab panel showing active flags."""
|
|
338
|
+
from nicegui import ui
|
|
339
|
+
|
|
340
|
+
from hexastack_core.ports.feature_flags import FeatureFlagPort
|
|
341
|
+
|
|
342
|
+
ui.label("Active Feature Flags").classes("text-lg font-semibold mb-4")
|
|
343
|
+
flags_adapter = (
|
|
344
|
+
container.resolve(FeatureFlagPort) if FeatureFlagPort in container else None
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
get_all_fn = getattr(flags_adapter, "get_all_flags", None)
|
|
348
|
+
if callable(get_all_fn):
|
|
349
|
+
flags_data: dict[str, Any] = get_all_fn()
|
|
350
|
+
if flags_data:
|
|
351
|
+
flag_rows = [{"flag": k, "enabled": str(v)} for k, v in flags_data.items()]
|
|
352
|
+
ui.table(
|
|
353
|
+
columns=[
|
|
354
|
+
{
|
|
355
|
+
"name": "flag",
|
|
356
|
+
"label": "Flag Key",
|
|
357
|
+
"field": "flag",
|
|
358
|
+
"sortable": True,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"name": "enabled",
|
|
362
|
+
"label": "Status",
|
|
363
|
+
"field": "enabled",
|
|
364
|
+
"sortable": True,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
rows=flag_rows,
|
|
368
|
+
row_key="flag",
|
|
369
|
+
).classes("w-full")
|
|
370
|
+
else:
|
|
371
|
+
ui.label("No feature flags currently configured in provider.").classes(
|
|
372
|
+
"text-slate-500 italic"
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
ui.label(
|
|
376
|
+
"Feature flag provider not available or does not support listing."
|
|
377
|
+
).classes("text-slate-500 italic")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _render_container_tab(container: Container) -> None:
|
|
381
|
+
"""Render the DI container tab panel showing registered services."""
|
|
382
|
+
from nicegui import ui
|
|
383
|
+
|
|
384
|
+
ui.label("Dependency Injection Services").classes("text-lg font-semibold mb-4")
|
|
385
|
+
|
|
386
|
+
# rodi.Container stores service registrations in `_map`
|
|
387
|
+
service_map = getattr(container, "_map", {})
|
|
388
|
+
services: list[dict[str, str]] = []
|
|
389
|
+
|
|
390
|
+
for cls, resolver in service_map.items():
|
|
391
|
+
service_name = getattr(cls, "__qualname__", getattr(cls, "__name__", str(cls)))
|
|
392
|
+
module_name = getattr(cls, "__module__", "")
|
|
393
|
+
resolver_desc = str(resolver).strip("<>")
|
|
394
|
+
|
|
395
|
+
services.append(
|
|
396
|
+
{
|
|
397
|
+
"service": service_name,
|
|
398
|
+
"module": module_name,
|
|
399
|
+
"resolver": resolver_desc,
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Sort services alphabetically by service name
|
|
404
|
+
services.sort(key=lambda s: s["service"])
|
|
405
|
+
|
|
406
|
+
if services:
|
|
407
|
+
ui.table(
|
|
408
|
+
columns=[
|
|
409
|
+
{
|
|
410
|
+
"name": "service",
|
|
411
|
+
"label": "Registered Port / Service",
|
|
412
|
+
"field": "service",
|
|
413
|
+
"sortable": True,
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
"name": "module",
|
|
417
|
+
"label": "Module",
|
|
418
|
+
"field": "module",
|
|
419
|
+
"sortable": True,
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"name": "resolver",
|
|
423
|
+
"label": "Binding / Lifetime",
|
|
424
|
+
"field": "resolver",
|
|
425
|
+
"sortable": True,
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
rows=services,
|
|
429
|
+
row_key="service",
|
|
430
|
+
).classes("w-full")
|
|
431
|
+
else:
|
|
432
|
+
ui.label("No direct services found in container introspection.").classes(
|
|
433
|
+
"text-slate-500 italic"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def mount_devtools_dashboard(
|
|
438
|
+
app: FastAPI,
|
|
439
|
+
container: Container,
|
|
440
|
+
pipeline: ExecutionPipeline | None = None,
|
|
441
|
+
*,
|
|
442
|
+
path: str = "/_devtools",
|
|
443
|
+
title: str = "Hexastack DevTools",
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Mount the default Hexastack interactive DevTools dashboard on FastAPI.
|
|
446
|
+
|
|
447
|
+
Notes/Architectural Intent:
|
|
448
|
+
Renders a rich developer console inspecting CQRS buses, feature flags,
|
|
449
|
+
and DI container services built entirely using the NiceGUI primitives.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
app: Target FastAPI application.
|
|
453
|
+
container: Application rodi.Container instance.
|
|
454
|
+
pipeline: Optional ExecutionPipeline instance.
|
|
455
|
+
path: URL path where the dashboard is mounted (default '/_devtools').
|
|
456
|
+
title: Dashboard page title.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
None.
|
|
460
|
+
"""
|
|
461
|
+
_check_nicegui_installed()
|
|
462
|
+
from nicegui import ui
|
|
463
|
+
|
|
464
|
+
mount_ui_app(app, title=title)
|
|
465
|
+
|
|
466
|
+
@ui.page(path, title=title)
|
|
467
|
+
def devtools_page():
|
|
468
|
+
ui.colors(primary="#3B82F6", secondary="#10B981", accent="#8B5CF6")
|
|
469
|
+
|
|
470
|
+
with ui.header().classes(
|
|
471
|
+
"items-center justify-between bg-slate-900 text-white px-6 py-3"
|
|
472
|
+
):
|
|
473
|
+
with ui.row().classes("items-center gap-3"):
|
|
474
|
+
ui.icon("layers", size="md").classes("text-blue-400")
|
|
475
|
+
ui.label(title).classes("text-xl font-bold tracking-tight")
|
|
476
|
+
ui.badge("v0.1.0", color="blue").classes("text-xs")
|
|
477
|
+
|
|
478
|
+
with ui.tabs().classes("w-full bg-slate-100 dark:bg-slate-800") as tabs:
|
|
479
|
+
tab_cqrs = ui.tab("CQRS Registry", icon="bolt")
|
|
480
|
+
tab_flags = ui.tab("Feature Flags", icon="toggle_on")
|
|
481
|
+
tab_container = ui.tab("DI Container", icon="hub")
|
|
482
|
+
|
|
483
|
+
with ui.tab_panels(tabs, value=tab_cqrs).classes("w-full p-6"):
|
|
484
|
+
with ui.tab_panel(tab_cqrs):
|
|
485
|
+
_render_cqrs_tab(container, pipeline=pipeline)
|
|
486
|
+
|
|
487
|
+
with ui.tab_panel(tab_flags):
|
|
488
|
+
_render_flags_tab(container)
|
|
489
|
+
|
|
490
|
+
with ui.tab_panel(tab_container):
|
|
491
|
+
_render_container_tab(container)
|