hexastack-graphql 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.
@@ -0,0 +1,7 @@
1
+ from hexastack_graphql import adapters, domain, infra
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "domain",
6
+ "infra",
7
+ ]
File without changes
@@ -0,0 +1,122 @@
1
+ import importlib.util
2
+ from typing import Any
3
+
4
+ import strawberry
5
+ from rodi import Container
6
+
7
+ from hexastack_core.domain.exceptions import MissingDependencyError
8
+ from hexastack_cqrs.ports.buses import CommandBusPort, QueryBusPort
9
+ from hexastack_graphql.domain.context import GraphQLContext
10
+
11
+ __all__ = [
12
+ "create_graphql_router",
13
+ "mount_graphql_router",
14
+ ]
15
+
16
+
17
+ def _require_fastapi() -> None:
18
+ """Guard against missing FastAPI installation.
19
+
20
+ Raises:
21
+ MissingDependencyError: If fastapi is not installed.
22
+ """
23
+ if importlib.util.find_spec("fastapi") is None:
24
+ raise MissingDependencyError(
25
+ "fastapi is required for FastAPI GraphQL integration. "
26
+ "Install via 'pip install hexastack-graphql[fastapi]'."
27
+ )
28
+
29
+
30
+ def create_graphql_router(
31
+ schema: strawberry.Schema,
32
+ container: Container,
33
+ *,
34
+ command_bus: CommandBusPort | None = None,
35
+ query_bus: QueryBusPort | None = None,
36
+ graphiql: bool = True,
37
+ ) -> Any:
38
+ """Construct a Strawberry GraphQLRouter for FastAPI.
39
+
40
+ Notes/Architectural Intent:
41
+ Creates a FastAPI APIRouter subclass (GraphQLRouter) that dynamically
42
+ injects the rodi Container and CQRS message buses into Info.context for
43
+ every HTTP execution.
44
+
45
+ Args:
46
+ schema: Compiled strawberry.Schema instance.
47
+ container: rodi DI Container.
48
+ command_bus: Optional CommandBusPort instance.
49
+ query_bus: Optional QueryBusPort instance.
50
+ graphiql: If True, enables the GraphiQL interactive playground.
51
+
52
+ Returns:
53
+ Configured strawberry.fastapi.GraphQLRouter instance.
54
+
55
+ Raises:
56
+ MissingDependencyError: If fastapi is not installed.
57
+ """
58
+ _require_fastapi()
59
+ from starlette.requests import Request
60
+ from strawberry.fastapi import GraphQLRouter
61
+
62
+ async def get_context(request: Request) -> GraphQLContext:
63
+ # Dynamically resolve buses from container if not passed explicitly
64
+ c_bus = command_bus
65
+ if c_bus is None and container is not None:
66
+ try:
67
+ c_bus = container.resolve(CommandBusPort)
68
+ except Exception: # noqa: BLE001
69
+ c_bus = None
70
+
71
+ q_bus = query_bus
72
+ if q_bus is None and container is not None:
73
+ try:
74
+ q_bus = container.resolve(QueryBusPort)
75
+ except Exception: # noqa: BLE001
76
+ q_bus = None
77
+
78
+ ctx = GraphQLContext(
79
+ container=container,
80
+ command_bus=c_bus,
81
+ query_bus=q_bus,
82
+ )
83
+ ctx.request = request
84
+ return ctx
85
+
86
+ return GraphQLRouter(
87
+ schema=schema,
88
+ context_getter=get_context,
89
+ graphql_ide="graphiql" if graphiql else None,
90
+ )
91
+
92
+
93
+ def mount_graphql_router(
94
+ app: Any,
95
+ schema: strawberry.Schema,
96
+ container: Container,
97
+ *,
98
+ path: str = "/graphql",
99
+ graphiql: bool = True,
100
+ ) -> None:
101
+ """Mount Strawberry GraphQL router directly onto a FastAPI application instance.
102
+
103
+ Args:
104
+ app: Target FastAPI application instance.
105
+ schema: Compiled strawberry.Schema.
106
+ container: rodi DI Container.
107
+ path: Route prefix for GraphQL endpoints. Defaults to "/graphql".
108
+ graphiql: If True, enables GraphiQL playground.
109
+
110
+ Returns:
111
+ None.
112
+
113
+ Raises:
114
+ MissingDependencyError: If fastapi is not installed.
115
+ """
116
+ _require_fastapi()
117
+ router = create_graphql_router(
118
+ schema=schema,
119
+ container=container,
120
+ graphiql=graphiql,
121
+ )
122
+ app.include_router(router, prefix=path)
File without changes
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+ from strawberry.fastapi.context import BaseContext
5
+
6
+
7
+ @dataclass
8
+ class GraphQLContext(BaseContext):
9
+ """Execution context injected into Strawberry GraphQL resolvers.
10
+
11
+ Notes/Architectural Intent:
12
+ Carries the rodi DI container and CQRS command/query buses into
13
+ field resolvers, enabling clean dispatching from GraphQL queries
14
+ and mutations. Inherits from Strawberry's BaseContext for full
15
+ FastAPI router integration.
16
+ """
17
+
18
+ container: Any | None = None
19
+ command_bus: Any | None = None
20
+ query_bus: Any | None = None
21
+ request: Any | None = None
22
+ properties: dict[str, Any] = field(default_factory=dict)
23
+
24
+
25
+ __all__ = [
26
+ "GraphQLContext",
27
+ ]
@@ -0,0 +1,25 @@
1
+ from hexastack_core.domain.exceptions import HexastackError
2
+
3
+
4
+ class GraphQLError(HexastackError):
5
+ """Base exception for all GraphQL adapter errors.
6
+
7
+ Notes/Architectural Intent:
8
+ Inherits from HexastackError to maintain unified exception hierarchy
9
+ across core and adapter layers.
10
+ """
11
+
12
+
13
+ class SchemaBuildingError(GraphQLError):
14
+ """Exception raised when GraphQL schema construction or validation fails.
15
+
16
+ Notes/Architectural Intent:
17
+ Raised when no queries or mutations are registered, or when invalid
18
+ field definitions conflict during schema compilation.
19
+ """
20
+
21
+
22
+ __all__ = [
23
+ "GraphQLError",
24
+ "SchemaBuildingError",
25
+ ]
File without changes
@@ -0,0 +1,106 @@
1
+ import inspect
2
+ from collections.abc import Sequence
3
+ from types import ModuleType
4
+ from typing import Any
5
+
6
+ from hexastack_core.infra.autodiscovery import (
7
+ DiscoveryVisitor,
8
+ scan_modules,
9
+ )
10
+ from hexastack_graphql.infra.registries.schema import GraphQLSchemaRegistry
11
+
12
+ _GRAPHQL_TYPE_ATTR = "__hexastack_graphql_type__"
13
+ _GRAPHQL_FIELD_ATTR = "__hexastack_graphql_field__"
14
+
15
+
16
+ class GraphQLTypeMetadata:
17
+ """Metadata container for decorated GraphQL root types."""
18
+
19
+ def __init__(self, kind: str) -> None:
20
+ self.kind = kind # "query" or "mutation"
21
+
22
+
23
+ class GraphQLFieldMetadata:
24
+ """Metadata container for decorated standalone GraphQL fields."""
25
+
26
+ def __init__(self, kind: str, name: str | None = None) -> None:
27
+ self.kind = kind # "query" or "mutation"
28
+ self.name = name
29
+
30
+
31
+ __all__ = [
32
+ "autodiscover_graphql_schema",
33
+ "create_graphql_visitor",
34
+ "GraphQLFieldMetadata",
35
+ "GraphQLTypeMetadata",
36
+ ]
37
+
38
+
39
+ def _register_graphql_field(
40
+ obj: Any,
41
+ registry: GraphQLSchemaRegistry,
42
+ ) -> None:
43
+ """Register decorated GraphQL query or mutation field."""
44
+ field_meta: GraphQLFieldMetadata | None = getattr(obj, _GRAPHQL_FIELD_ATTR, None)
45
+ if field_meta is not None:
46
+ field_name = field_meta.name or getattr(obj, "__name__", "field")
47
+ if field_meta.kind == "query":
48
+ registry.register_query_field(field_name, obj)
49
+ elif field_meta.kind == "mutation":
50
+ registry.register_mutation_field(field_name, obj)
51
+
52
+
53
+ def _register_graphql_type(
54
+ obj: type[Any],
55
+ registry: GraphQLSchemaRegistry,
56
+ ) -> None:
57
+ """Register decorated GraphQL query or mutation type."""
58
+ type_meta: GraphQLTypeMetadata | None = getattr(obj, _GRAPHQL_TYPE_ATTR, None)
59
+ if type_meta is not None:
60
+ if type_meta.kind == "query":
61
+ registry.register_query_type(obj)
62
+ elif type_meta.kind == "mutation":
63
+ registry.register_mutation_type(obj)
64
+
65
+
66
+ def autodiscover_graphql_schema(
67
+ packages_to_scan: Sequence[str | ModuleType],
68
+ registry: GraphQLSchemaRegistry,
69
+ ) -> GraphQLSchemaRegistry:
70
+ """Discover decorated GraphQL components and register them into the schema registry.
71
+
72
+ Args:
73
+ packages_to_scan: Sequence of package names or module objects to inspect.
74
+ registry: Target GraphQLSchemaRegistry instance.
75
+
76
+ Returns:
77
+ The populated GraphQLSchemaRegistry instance.
78
+ """
79
+ visitor = create_graphql_visitor(registry)
80
+ scan_modules(packages_to_scan, [visitor])
81
+ return registry
82
+
83
+
84
+ def create_graphql_visitor(
85
+ registry: GraphQLSchemaRegistry,
86
+ ) -> DiscoveryVisitor:
87
+ """Create a DiscoveryVisitor callback for single-pass GraphQL schema element discovery.
88
+
89
+ Notes/Architectural Intent:
90
+ Inspects discovered classes and functions for GraphQL decorator metadata,
91
+ registering query/mutation types and fields into the supplied schema registry
92
+ during single-pass reflection.
93
+
94
+ Args:
95
+ registry: Target GraphQLSchemaRegistry instance.
96
+
97
+ Returns:
98
+ DiscoveryVisitor callable accepting (member, module).
99
+ """
100
+
101
+ def visitor(obj: Any, module: ModuleType) -> None:
102
+ if inspect.isclass(obj):
103
+ _register_graphql_type(obj, registry)
104
+ _register_graphql_field(obj, registry)
105
+
106
+ return visitor
@@ -0,0 +1,113 @@
1
+ import importlib.util
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ import strawberry
6
+
7
+ from hexastack_core.infra.bootstrap import BootstrapContext
8
+ from hexastack_core.infra.registries.config import ConfigRegistry
9
+ from hexastack_core.ports.bootstrap import BootstrapperPort
10
+ from hexastack_graphql.infra.autodiscovery import create_graphql_visitor
11
+ from hexastack_graphql.infra.config import (
12
+ HexastackGraphQLConfig,
13
+ register_graphql_config,
14
+ )
15
+ from hexastack_graphql.infra.decorators import get_schema_registry
16
+ from hexastack_graphql.infra.extensions import CorrelationExtension
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class GraphQLBootstrapResult:
21
+ """Dataclass holding compiled GraphQL schema and configuration."""
22
+
23
+ config: HexastackGraphQLConfig
24
+ schema: strawberry.Schema
25
+ router: Any | None = None
26
+
27
+
28
+ class GraphQLBootstrapper(BootstrapperPort):
29
+ """Bootstrap extension compiling Strawberry schema and mounting FastAPI router.
30
+
31
+ Notes/Architectural Intent:
32
+ Implements BootstrapperPort with order=35 (executing after CQRS order=20
33
+ and FastAPI order=30), registering the autodiscovery visitor, assembling
34
+ the Strawberry Schema with telemetry extensions, and dynamically mounting
35
+ the GraphQL router into the FastAPI application if present.
36
+ """
37
+
38
+ name: str = "graphql"
39
+ order: int = 35
40
+
41
+ def configure(self, context: BootstrapContext) -> None:
42
+ """Phase 2: Register visitor, compile Strawberry schema, and mount into FastAPI.
43
+
44
+ Args:
45
+ context: BootstrapContext containing DI container, config, and properties.
46
+
47
+ Returns:
48
+ None.
49
+
50
+ Raises:
51
+ None.
52
+ """
53
+ cfg = context.get_config("graphql", HexastackGraphQLConfig)
54
+
55
+ registry = get_schema_registry()
56
+
57
+ # Register visitor for single-pass reflective scanning (Phase 3)
58
+ visitor = create_graphql_visitor(registry)
59
+ context.register_visitor(visitor)
60
+
61
+ # 1. Compile schema from registry with CorrelationExtension class
62
+ extensions = [CorrelationExtension]
63
+ schema = registry.build_schema(extensions=extensions)
64
+
65
+ # 2. Register Schema and Registry into DI container
66
+ context.container.add_instance(schema, declared_class=strawberry.Schema)
67
+ context.container.add_instance(registry)
68
+
69
+ # 3. Mount onto FastAPI app if available and configured
70
+ router = None
71
+ if cfg.auto_mount_fastapi and importlib.util.find_spec("fastapi") is not None:
72
+ fastapi_app = context.properties.get("app")
73
+ if fastapi_app is not None:
74
+ from hexastack_graphql.adapters.fastapi import (
75
+ create_graphql_router,
76
+ )
77
+
78
+ router = create_graphql_router(
79
+ schema=schema,
80
+ container=context.container,
81
+ graphiql=cfg.graphiql,
82
+ )
83
+ fastapi_app.include_router(router, prefix=cfg.path)
84
+
85
+ # 4. Store in context properties
86
+ result = GraphQLBootstrapResult(
87
+ config=cfg,
88
+ schema=schema,
89
+ router=router,
90
+ )
91
+ context.properties["graphql_result"] = result
92
+ context.properties["graphql_schema"] = schema
93
+ context.properties["graphql_router"] = router
94
+
95
+ def register_config(self, registry: ConfigRegistry) -> None:
96
+ """Phase 1: Register GraphQL configuration schema under 'graphql'.
97
+
98
+ Args:
99
+ registry: Target ConfigRegistry instance.
100
+
101
+ Returns:
102
+ None.
103
+
104
+ Raises:
105
+ None.
106
+ """
107
+ register_graphql_config(registry)
108
+
109
+
110
+ __all__ = [
111
+ "GraphQLBootstrapper",
112
+ "GraphQLBootstrapResult",
113
+ ]
@@ -0,0 +1,60 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from hexastack_core.infra.decorators import config_section
4
+ from hexastack_core.infra.registries.config import ConfigRegistry
5
+
6
+
7
+ @config_section("graphql")
8
+ class HexastackGraphQLConfig(BaseModel):
9
+ """Configuration schema for Hexastack Strawberry GraphQL adapter.
10
+
11
+ Notes/Architectural Intent:
12
+ Controls GraphQL routing, GraphiQL interactive playground, mutation enabling,
13
+ and automatic mounting into FastAPI applications.
14
+ """
15
+
16
+ path: str = Field(
17
+ default="/graphql",
18
+ description="HTTP path prefix for GraphQL queries and mutations.",
19
+ )
20
+ graphiql: bool = Field(
21
+ default=True,
22
+ description="Enable interactive GraphiQL web interface.",
23
+ )
24
+ allow_queries: bool = Field(
25
+ default=True,
26
+ description="Enable GraphQL query execution.",
27
+ )
28
+ allow_mutations: bool = Field(
29
+ default=True,
30
+ description="Enable GraphQL mutation execution.",
31
+ )
32
+ auto_mount_fastapi: bool = Field(
33
+ default=True,
34
+ description="Automatically mount GraphQLRouter into FastAPI application during bootstrap.",
35
+ )
36
+ title: str = Field(
37
+ default="Hexastack GraphQL API",
38
+ description="GraphQL schema / API title.",
39
+ )
40
+
41
+
42
+ __all__ = [
43
+ "HexastackGraphQLConfig",
44
+ "register_graphql_config",
45
+ ]
46
+
47
+
48
+ def register_graphql_config(registry: ConfigRegistry) -> None:
49
+ """Register GraphQL configuration schema under 'graphql'.
50
+
51
+ Args:
52
+ registry: Target ConfigRegistry instance.
53
+
54
+ Returns:
55
+ None.
56
+
57
+ Raises:
58
+ None.
59
+ """
60
+ registry.register_config_section("graphql", HexastackGraphQLConfig)
@@ -0,0 +1,212 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, cast
3
+
4
+ import strawberry
5
+
6
+ from hexastack_core.ports.feature_flags import FeatureFlagPort
7
+ from hexastack_graphql.infra.autodiscovery import (
8
+ _GRAPHQL_FIELD_ATTR,
9
+ _GRAPHQL_TYPE_ATTR,
10
+ GraphQLFieldMetadata,
11
+ GraphQLTypeMetadata,
12
+ )
13
+ from hexastack_graphql.infra.registries.schema import GraphQLSchemaRegistry
14
+
15
+ _default_registry = GraphQLSchemaRegistry()
16
+
17
+
18
+ __all__ = [
19
+ "feature_flag_field",
20
+ "get_schema_registry",
21
+ "graphql_mutation",
22
+ "graphql_mutation_type",
23
+ "graphql_query",
24
+ "graphql_query_type",
25
+ ]
26
+
27
+
28
+ def _resolve_flags(args: tuple[Any, ...], kwargs: dict[str, Any]) -> FeatureFlagPort:
29
+ """Helper to extract FeatureFlagPort from arguments context or fallback to ConfigFeatureFlagAdapter."""
30
+ from hexastack_core.adapters.feature_flags.config import ConfigFeatureFlagAdapter
31
+ from hexastack_core.ports.feature_flags import FeatureFlagPort
32
+
33
+ for item in (*args, *kwargs.values()):
34
+ if hasattr(item, "context") and getattr(item.context, "container", None):
35
+ container = item.context.container
36
+ if FeatureFlagPort in container:
37
+ return container.resolve(FeatureFlagPort)
38
+ break
39
+ return ConfigFeatureFlagAdapter()
40
+
41
+
42
+ def feature_flag_field(
43
+ flag_key: str,
44
+ *,
45
+ default: bool = False,
46
+ fallback: Any = None,
47
+ raise_error: bool = True,
48
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
49
+ """Wrap a Strawberry GraphQL field resolver with dynamic feature flag evaluation.
50
+
51
+ Notes/Architectural Intent:
52
+ Evaluates the specified feature flag against ambient UserContext / GraphQLContext.
53
+ If disabled:
54
+ - If raise_error is True, raises a GraphQLError with a descriptive message.
55
+ - If raise_error is False, returns fallback value (e.g. None).
56
+
57
+ Args:
58
+ flag_key: Unique identifier of the feature flag to check.
59
+ default: Fallback boolean value if flag is not explicitly configured.
60
+ fallback: Value to return if flag is disabled and raise_error is False (defaults to None).
61
+ raise_error: Whether to raise a GraphQLError when disabled (defaults to True).
62
+
63
+ Returns:
64
+ Decorator wrapping the GraphQL resolver function.
65
+ """
66
+ import inspect
67
+ from functools import wraps
68
+
69
+ from hexastack_core.domain.feature_flags import EvaluationContext
70
+ from hexastack_graphql.domain.exceptions import GraphQLError
71
+
72
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
73
+ if inspect.iscoroutinefunction(fn):
74
+
75
+ @wraps(fn)
76
+ async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
77
+ flags = _resolve_flags(args, kwargs)
78
+ eval_ctx = EvaluationContext.from_current_context()
79
+ if not flags.is_enabled(flag_key, default=default, context=eval_ctx):
80
+ if raise_error:
81
+ raise GraphQLError(
82
+ f"GraphQL field is disabled by feature flag '{flag_key}'."
83
+ )
84
+ return fallback
85
+ return await fn(*args, **kwargs)
86
+
87
+ return async_wrapped
88
+
89
+ @wraps(fn)
90
+ def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
91
+ flags = _resolve_flags(args, kwargs)
92
+ eval_ctx = EvaluationContext.from_current_context()
93
+ if not flags.is_enabled(flag_key, default=default, context=eval_ctx):
94
+ if raise_error:
95
+ raise GraphQLError(
96
+ f"GraphQL field is disabled by feature flag '{flag_key}'."
97
+ )
98
+ return fallback
99
+ return fn(*args, **kwargs)
100
+
101
+ return sync_wrapped
102
+
103
+ return decorator
104
+
105
+
106
+ def get_schema_registry() -> GraphQLSchemaRegistry:
107
+ """Return the global default GraphQLSchemaRegistry instance.
108
+
109
+ Returns:
110
+ GraphQLSchemaRegistry instance.
111
+ """
112
+ return _default_registry
113
+
114
+
115
+ def graphql_mutation(
116
+ name: str | None = None,
117
+ *,
118
+ description: str | None = None,
119
+ ) -> Callable[[Callable[..., Any]], Any]:
120
+ """Decorator registering a standalone function as a root GraphQL mutation field.
121
+
122
+ Args:
123
+ name: Optional custom field name.
124
+ description: Optional GraphQL documentation description.
125
+
126
+ Returns:
127
+ Decorator function.
128
+ """
129
+
130
+ def decorator(fn: Callable[..., Any]) -> Any:
131
+ field_name = name or getattr(fn, "__name__", "field")
132
+ s_field = strawberry.mutation(fn, name=field_name, description=description)
133
+ setattr(
134
+ s_field,
135
+ _GRAPHQL_FIELD_ATTR,
136
+ GraphQLFieldMetadata(kind="mutation", name=field_name),
137
+ )
138
+ _default_registry.register_mutation_field(field_name, s_field)
139
+ return s_field
140
+
141
+ return decorator
142
+
143
+
144
+ def graphql_mutation_type[T: type[Any]](cls: T) -> T:
145
+ """Decorator registering a class as a root Mutation type.
146
+
147
+ Notes/Architectural Intent:
148
+ Automatically applies @strawberry.type if not already applied,
149
+ attaches discovery metadata, and registers in the default schema registry.
150
+
151
+ Args:
152
+ cls: Target class containing mutation fields.
153
+
154
+ Returns:
155
+ Decorated Strawberry type class.
156
+ """
157
+ wrapped: Any = cls
158
+ if not hasattr(cls, "__strawberry_definition__"):
159
+ wrapped = strawberry.type(cls)
160
+ setattr(wrapped, _GRAPHQL_TYPE_ATTR, GraphQLTypeMetadata(kind="mutation"))
161
+ _default_registry.register_mutation_type(cast("type[Any]", wrapped))
162
+ return cast("T", wrapped)
163
+
164
+
165
+ def graphql_query(
166
+ name: str | None = None,
167
+ *,
168
+ description: str | None = None,
169
+ ) -> Callable[[Callable[..., Any]], Any]:
170
+ """Decorator registering a standalone function as a root GraphQL query field.
171
+
172
+ Args:
173
+ name: Optional custom field name.
174
+ description: Optional GraphQL documentation description.
175
+
176
+ Returns:
177
+ Decorator function.
178
+ """
179
+
180
+ def decorator(fn: Callable[..., Any]) -> Any:
181
+ field_name = name or getattr(fn, "__name__", "field")
182
+ s_field = strawberry.field(fn, name=field_name, description=description)
183
+ setattr(
184
+ s_field,
185
+ _GRAPHQL_FIELD_ATTR,
186
+ GraphQLFieldMetadata(kind="query", name=field_name),
187
+ )
188
+ _default_registry.register_query_field(field_name, s_field)
189
+ return s_field
190
+
191
+ return decorator
192
+
193
+
194
+ def graphql_query_type[T: type[Any]](cls: T) -> T:
195
+ """Decorator registering a class as a root Query type.
196
+
197
+ Notes/Architectural Intent:
198
+ Automatically applies @strawberry.type if not already applied,
199
+ attaches discovery metadata, and registers in the default schema registry.
200
+
201
+ Args:
202
+ cls: Target class containing query fields.
203
+
204
+ Returns:
205
+ Decorated Strawberry type class.
206
+ """
207
+ wrapped: Any = cls
208
+ if not hasattr(cls, "__strawberry_definition__"):
209
+ wrapped = strawberry.type(cls)
210
+ setattr(wrapped, _GRAPHQL_TYPE_ATTR, GraphQLTypeMetadata(kind="query"))
211
+ _default_registry.register_query_type(cast("type[Any]", wrapped))
212
+ return cast("T", wrapped)
@@ -0,0 +1,26 @@
1
+ from typing import Any
2
+
3
+ from strawberry.extensions import SchemaExtension
4
+
5
+ from hexastack_core.utils.context import get_correlation_id
6
+
7
+
8
+ class CorrelationExtension(SchemaExtension):
9
+ """Strawberry Schema Extension injecting correlation ID into GraphQL execution result.
10
+
11
+ Notes/Architectural Intent:
12
+ Aligns GraphQL execution with REST and CQRS telemetry by attaching
13
+ the active async context's correlation_id into the GraphQL 'extensions' payload.
14
+ """
15
+
16
+ def get_results(self) -> dict[str, Any]:
17
+ """Return extension dictionary to be merged into GraphQL ExecutionResult."""
18
+ cid = get_correlation_id()
19
+ if cid:
20
+ return {"correlation_id": cid}
21
+ return {}
22
+
23
+
24
+ __all__ = [
25
+ "CorrelationExtension",
26
+ ]
File without changes
@@ -0,0 +1,151 @@
1
+ from collections.abc import Callable, Sequence
2
+ from typing import Any
3
+
4
+ import strawberry
5
+ from strawberry.extensions import SchemaExtension
6
+ from strawberry.types import Info
7
+
8
+ from hexastack_graphql.domain.context import GraphQLContext
9
+ from hexastack_graphql.domain.exceptions import SchemaBuildingError
10
+
11
+
12
+ class GraphQLSchemaRegistry:
13
+ """Registry maintaining registered GraphQL query types, mutation types, and fields.
14
+
15
+ Notes/Architectural Intent:
16
+ Aggregates modular GraphQL query and mutation fields registered across
17
+ distributed application modules, compiling them into a unified strawberry.Schema.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ """Initialize empty schema registry."""
22
+ self._query_types: list[type[Any]] = []
23
+ self._mutation_types: list[type[Any]] = []
24
+ self._query_fields: dict[str, Any] = {}
25
+ self._mutation_fields: dict[str, Any] = {}
26
+ self._custom_schema: strawberry.Schema | None = None
27
+
28
+ def _build_mutation_root(self) -> type[Any] | None:
29
+ """Assemble composite GraphQL Mutation root type if any mutations registered."""
30
+ if self._mutation_types:
31
+ if len(self._mutation_types) == 1 and not self._mutation_fields:
32
+ return self._mutation_types[0]
33
+ bases = tuple(self._mutation_types)
34
+ MutationType = type("Mutation", bases, dict(self._mutation_fields))
35
+ return strawberry.type(MutationType)
36
+
37
+ if self._mutation_fields:
38
+ MutationType = type("Mutation", (), dict(self._mutation_fields))
39
+ return strawberry.type(MutationType)
40
+
41
+ return None
42
+
43
+ def _build_query_root(self) -> type[Any]:
44
+ """Assemble composite or fallback GraphQL Query root type."""
45
+ if self._query_types:
46
+ if len(self._query_types) == 1 and not self._query_fields:
47
+ return self._query_types[0]
48
+ bases = tuple(self._query_types)
49
+ fields = dict(self._query_fields)
50
+ QueryType = type("Query", bases, fields)
51
+ return strawberry.type(QueryType)
52
+
53
+ if self._query_fields:
54
+ QueryType = type("Query", (), dict(self._query_fields))
55
+ return strawberry.type(QueryType)
56
+
57
+ @strawberry.type
58
+ class DefaultQuery:
59
+ @strawberry.field
60
+ def ping(self, info: Info[GraphQLContext, Any]) -> str:
61
+ return "pong"
62
+
63
+ return DefaultQuery
64
+
65
+ def build_schema(
66
+ self,
67
+ extensions: Sequence[type[SchemaExtension] | Callable[[], SchemaExtension]]
68
+ | None = None,
69
+ ) -> strawberry.Schema:
70
+ """Assemble all registered types and fields into a strawberry.Schema.
71
+
72
+ Args:
73
+ extensions: Optional list of strawberry SchemaExtension classes or instances.
74
+
75
+ Returns:
76
+ The compiled strawberry.Schema instance.
77
+
78
+ Raises:
79
+ SchemaBuildingError: If schema assembly or validation fails.
80
+ """
81
+ if self._custom_schema is not None:
82
+ return self._custom_schema
83
+
84
+ query_cls = self._build_query_root()
85
+ mutation_cls = self._build_mutation_root()
86
+
87
+ try:
88
+ return strawberry.Schema(
89
+ query=query_cls,
90
+ mutation=mutation_cls,
91
+ extensions=extensions or (),
92
+ )
93
+ except Exception as e:
94
+ raise SchemaBuildingError(f"Failed to build GraphQL schema: {e}") from e
95
+
96
+ def clear(self) -> None:
97
+ """Clear all registered schema components (used for test isolation)."""
98
+ self._query_types.clear()
99
+ self._mutation_types.clear()
100
+ self._query_fields.clear()
101
+ self._mutation_fields.clear()
102
+ self._custom_schema = None
103
+
104
+ def register_mutation_field(self, name: str, field_def: Any) -> None:
105
+ """Register an individual mutation field or resolver function.
106
+
107
+ Args:
108
+ name: Field name.
109
+ field_def: Strawberry field or resolver function.
110
+ """
111
+ self._mutation_fields[name] = field_def
112
+
113
+ def register_mutation_type(self, cls: type[Any]) -> None:
114
+ """Register a Strawberry type class containing mutation fields.
115
+
116
+ Args:
117
+ cls: A class decorated with @strawberry.type.
118
+ """
119
+ if cls not in self._mutation_types:
120
+ self._mutation_types.append(cls)
121
+
122
+ def register_query_field(self, name: str, field_def: Any) -> None:
123
+ """Register an individual query field or resolver function.
124
+
125
+ Args:
126
+ name: Field name.
127
+ field_def: Strawberry field or resolver function.
128
+ """
129
+ self._query_fields[name] = field_def
130
+
131
+ def register_query_type(self, cls: type[Any]) -> None:
132
+ """Register a Strawberry type class containing query fields.
133
+
134
+ Args:
135
+ cls: A class decorated with @strawberry.type.
136
+ """
137
+ if cls not in self._query_types:
138
+ self._query_types.append(cls)
139
+
140
+ def set_custom_schema(self, schema: strawberry.Schema) -> None:
141
+ """Explicitly override with a pre-constructed Strawberry Schema.
142
+
143
+ Args:
144
+ schema: Pre-configured strawberry.Schema instance.
145
+ """
146
+ self._custom_schema = schema
147
+
148
+
149
+ __all__ = [
150
+ "GraphQLSchemaRegistry",
151
+ ]
@@ -0,0 +1,69 @@
1
+ from strawberry.types import Info
2
+
3
+ from hexastack_core.domain.command import Command
4
+ from hexastack_core.domain.query import Query
5
+ from hexastack_graphql.domain.context import GraphQLContext
6
+ from hexastack_graphql.domain.exceptions import GraphQLError
7
+
8
+ __all__ = [
9
+ "dispatch_command",
10
+ "dispatch_query",
11
+ ]
12
+
13
+
14
+ def dispatch_command[T](
15
+ info: Info[GraphQLContext, None],
16
+ command: Command,
17
+ ) -> T:
18
+ """Dispatch a CQRS command from inside a Strawberry field resolver.
19
+
20
+ Notes/Architectural Intent:
21
+ Resolves CommandBusPort from info.context and dispatches the command,
22
+ returning the handler's result with type inference.
23
+
24
+ Args:
25
+ info: Strawberry execution Info object containing GraphQLContext.
26
+ command: Concrete Command instance to dispatch.
27
+
28
+ Returns:
29
+ The command execution result.
30
+
31
+ Raises:
32
+ GraphQLError: If CommandBusPort is not configured in context.
33
+ """
34
+ bus = info.context.command_bus
35
+ if bus is None:
36
+ raise GraphQLError(
37
+ "CommandBusPort is not available in GraphQLContext. "
38
+ "Ensure hexastack-cqrs is configured."
39
+ )
40
+ return bus.dispatch(command) # type: ignore[no-any-return]
41
+
42
+
43
+ def dispatch_query[T](
44
+ info: Info[GraphQLContext, None],
45
+ query: Query,
46
+ ) -> T:
47
+ """Dispatch a CQRS query from inside a Strawberry field resolver.
48
+
49
+ Notes/Architectural Intent:
50
+ Resolves QueryBusPort from info.context and dispatches the query,
51
+ returning the handler's result with type inference.
52
+
53
+ Args:
54
+ info: Strawberry execution Info object containing GraphQLContext.
55
+ query: Concrete Query instance to dispatch.
56
+
57
+ Returns:
58
+ The query execution result.
59
+
60
+ Raises:
61
+ GraphQLError: If QueryBusPort is not configured in context.
62
+ """
63
+ bus = info.context.query_bus
64
+ if bus is None:
65
+ raise GraphQLError(
66
+ "QueryBusPort is not available in GraphQLContext. "
67
+ "Ensure hexastack-cqrs is configured."
68
+ )
69
+ return bus.dispatch(query) # type: ignore[no-any-return]
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.3
2
+ Name: hexastack-graphql
3
+ Version: 0.0.0
4
+ Summary: Strawberry GraphQL presentation adapter and CQRS integration for Hexastack
5
+ Author: Richard West
6
+ Author-email: Richard West <dopplereffect.us@gmail.com>
7
+ Requires-Dist: hexastack-core
8
+ Requires-Dist: hexastack-cqrs
9
+ Requires-Dist: strawberry-graphql>=0.260.0
10
+ Requires-Dist: fastapi>=0.141.1 ; extra == 'fastapi'
11
+ Requires-Dist: hexastack-fastapi ; extra == 'fastapi'
12
+ Requires-Python: >=3.13
13
+ Provides-Extra: fastapi
14
+ Description-Content-Type: text/markdown
15
+
16
+ # hexastack-graphql
17
+
18
+ > Strawberry GraphQL presentation adapter and CQRS integration for Hexastack.
19
+
20
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
21
+
22
+ ---
23
+
24
+ ## 1. Overview & Capabilities
25
+
26
+ `hexastack-graphql` brings the power and type-safety of [Strawberry GraphQL](https://strawberry.rocks/) into the Hexastack architecture:
27
+
28
+ - **Type-Safe GraphQL Schemas**: Native Python dataclass-based GraphQL schema definition via Strawberry.
29
+ - **CQRS Integration via Context**: Injects the `rodi.Container`, `CommandBusPort`, and `QueryBusPort` directly into Strawberry's `Info.context` (`GraphQLContext`).
30
+ - **Declarative Query & Mutation Registries**:
31
+ - `@graphql_query_type` and `@graphql_mutation_type`: Register whole type classes to be merged into root Query and Mutation types.
32
+ - `@graphql_query` and `@graphql_mutation`: Register standalone resolver functions as top-level fields.
33
+ - **Dynamic Field Resolver Feature Flagging**:
34
+ - `@feature_flag_field("flag_key", raise_error=True, fallback=...)`: Evaluates feature flags dynamically before executing field resolvers, raising a `GraphQLError` or returning a safe fallback value.
35
+ - **FastAPI Mount & GraphiQL Playground**: Seamless mounting as a `GraphQLRouter` into FastAPI applications with interactive GraphiQL playground enabled.
36
+
37
+ ---
38
+
39
+ ## 2. Package Anatomy & Key Components
40
+
41
+ ```
42
+ hexastack_graphql/
43
+ ├── domain/ # GraphQLContext, GraphQLError, SchemaBuildingError
44
+ ├── ports/ # GraphQLContextFactoryPort
45
+ ├── adapters/ # create_graphql_router, mount_graphql_router (FastAPI integration)
46
+ └── infra/
47
+ ├── bootstrap.py # GraphQLBootstrapper (order=35)
48
+ ├── config.py # HexastackGraphQLConfig
49
+ ├── decorators.py# @graphql_query, @graphql_mutation, @graphql_query_type, @graphql_mutation_type, @feature_flag_field
50
+ └── registries/ # schema.py (GraphQLSchemaRegistry)
51
+ ```
52
+
53
+ ### Key Exports
54
+
55
+ | Category | Exports |
56
+ |---|---|
57
+ | **Bootstrap** | `GraphQLBootstrapper` (order=35), `HexastackGraphQLConfig` |
58
+ | **Context & Domain** | `GraphQLContext`, `GraphQLError`, `SchemaBuildingError` |
59
+ | **Decorators** | `@graphql_query`, `@graphql_mutation`, `@graphql_query_type`, `@graphql_mutation_type`, `@feature_flag_field` |
60
+ | **FastAPI Adapters** | `create_graphql_router`, `mount_graphql_router` |
61
+ | **Registries** | `GraphQLSchemaRegistry`, `get_schema_registry` |
62
+
63
+ ---
64
+
65
+ ## 3. Monorepo & Sibling Relationships
66
+
67
+ ```mermaid
68
+ graph TD
69
+ subgraph ClientRequests ["GraphQL Client Requests"]
70
+ CLIENT["Web / Mobile GraphQL Clients"]
71
+ end
72
+
73
+ subgraph GraphQLAdapter ["hexastack-graphql"]
74
+ SCHEMA["strawberry.Schema"]
75
+ CTX["GraphQLContext (Container + Buses)"]
76
+ ROUTER["GraphQLRouter (FastAPI integration)"]
77
+ end
78
+
79
+ subgraph ApplicationLayer ["hexastack-cqrs"]
80
+ CBUS["CommandBusPort"]
81
+ QBUS["QueryBusPort"]
82
+ end
83
+
84
+ subgraph WebServer ["hexastack-fastapi"]
85
+ FASTAPI_APP["FastAPI Application"]
86
+ end
87
+
88
+ CLIENT --> ROUTER
89
+ FASTAPI_APP --> ROUTER
90
+ ROUTER --> SCHEMA
91
+ SCHEMA --> CTX
92
+ CTX -->|dispatches commands/queries to| CBUS
93
+ CTX -->|dispatches commands/queries to| QBUS
94
+ ```
95
+
96
+ ### Explicit Dependencies (Direct)
97
+ - `hexastack-core`: DI container, configuration registry, base exceptions.
98
+ - `hexastack-cqrs`: `CommandBusPort` and `QueryBusPort` for message dispatching.
99
+ - `strawberry-graphql>=0.260.0`: Core GraphQL engine and schema generator.
100
+
101
+ ### Implied / Behavioral Relationships (DI-Mediated)
102
+ - **FastAPI Auto-Mounting**: `GraphQLBootstrapper` (order=35) discovers the `FastAPI` instance created by `FastApiBootstrapper` (order=30) and attaches the `GraphQLRouter` automatically if `auto_mount_fastapi=true`.
103
+ - **CQRS Dispatching**: Field resolvers receive `info.context.query_bus` and `info.context.command_bus` to delegate execution into the CQRS pipeline.
104
+
105
+ ### Optional Integrations (Extras)
106
+ - `[fastapi]`: Installs `hexastack-fastapi` and `fastapi>=0.141.1` for HTTP routing and GraphiQL playground.
107
+
108
+ ---
109
+
110
+ ## 4. Installation
111
+
112
+ ```bash
113
+ # Standalone install
114
+ pip install hexastack-graphql
115
+
116
+ # With FastAPI integration
117
+ pip install "hexastack-graphql[fastapi]"
118
+
119
+ # Via umbrella package
120
+ pip install "hexastack[graphql]"
121
+ ```
122
+
123
+ ---
124
+
125
+ ## 5. Configuration Reference
126
+
127
+ ```toml
128
+ [hexastack.graphql]
129
+ path = "/graphql" # Route prefix for GraphQL endpoint
130
+ graphiql = true # Enable interactive GraphiQL web UI
131
+ allow_queries = true
132
+ allow_mutations = true
133
+ auto_mount_fastapi = true # Auto mount onto FastAPI application on bootstrap
134
+ title = "Hexastack GraphQL API"
135
+ ```
136
+
137
+ ---
138
+
139
+ ## 6. Quickstart Example
140
+
141
+ ```python
142
+ from dataclasses import dataclass
143
+ import strawberry
144
+ from strawberry.types import Info
145
+ from hexastack_core.infra.bootstrap import bootstrap
146
+ from hexastack_cqrs.domain.query import Query
147
+ from hexastack_cqrs.infra.decorators import query_handler
148
+ from hexastack_graphql.domain.context import GraphQLContext
149
+ from hexastack_graphql.infra.decorators import graphql_query_type
150
+
151
+
152
+ # 1. Define CQRS Query & Handler
153
+ @dataclass(frozen=True)
154
+ class GetItemQuery(Query):
155
+ item_id: str
156
+
157
+
158
+ @query_handler(GetItemQuery)
159
+ class GetItemHandler:
160
+ def __call__(self, qry: GetItemQuery) -> dict:
161
+ return {"id": qry.item_id, "name": f"Item {qry.item_id}"}
162
+
163
+
164
+ # 2. Define Strawberry GraphQL Type
165
+ @strawberry.type
166
+ class ItemType:
167
+ id: str
168
+ name: str
169
+
170
+
171
+ @graphql_query_type
172
+ class Query:
173
+ @strawberry.field
174
+ def item(self, info: Info[GraphQLContext, None], item_id: str) -> ItemType:
175
+ res = info.context.query_bus.dispatch(GetItemQuery(item_id=item_id))
176
+ return ItemType(id=res["id"], name=res["name"])
177
+
178
+
179
+ # 3. Bootstrap Runtime with GraphQL
180
+ runtime = bootstrap(packages_to_scan=[__name__])
181
+ schema = runtime.get("graphql_schema")
182
+
183
+ result = schema.execute_sync(
184
+ '{ item(itemId: "123") { id name } }',
185
+ context_value=GraphQLContext(
186
+ container=runtime.container, query_bus=runtime.get("query_bus")
187
+ ),
188
+ )
189
+ print(result.data) # {'item': {'id': '123', 'name': 'Item 123'}}
190
+ ```
@@ -0,0 +1,19 @@
1
+ hexastack_graphql/__init__.py,sha256=xCfNCPXPMLP3z745T4Efk3YIJrulBlDhIG0ylRrntMg,112
2
+ hexastack_graphql/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ hexastack_graphql/adapters/fastapi.py,sha256=QRzLTULYTvfn5UoTnkm-Nd5JlJewNYr164nc9Zg9BEg,3568
4
+ hexastack_graphql/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ hexastack_graphql/domain/context.py,sha256=0wWrvnVmWKF3KjJeWA64uhDvqCbNeEcd8CtU-YA2DCA,758
6
+ hexastack_graphql/domain/exceptions.py,sha256=Ub-znwr6qlQhKvzyM6sDU5ajsUHsEkFtv1NsAAhnOvk,673
7
+ hexastack_graphql/infra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ hexastack_graphql/infra/autodiscovery.py,sha256=xfyzgek_cr2WKJ6m1OM094_msklAieCMqw2x1T_RcG0,3297
9
+ hexastack_graphql/infra/bootstrap.py,sha256=XhiJNoLJJLi8qHR2Q8qgHHQHsud6ReFPeXHKmwsHbog,3734
10
+ hexastack_graphql/infra/config.py,sha256=a0xH-Bf0f7bsTaP65V2KVstbB3OYK1YjTdzZhXOEqX4,1669
11
+ hexastack_graphql/infra/decorators.py,sha256=JmCr8iyJtkFfNnRTun61W23VY6GMTEknmYyCXLnL0Nw,7106
12
+ hexastack_graphql/infra/extensions.py,sha256=H1T_rWaN7I-QbYotwHDrg-EJ1tivOu8EwAIVJugSH-k,765
13
+ hexastack_graphql/infra/registries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ hexastack_graphql/infra/registries/schema.py,sha256=MAtfbNsy4qNysQouiEi8G_hB88kDzzgXoP769U4rpEw,5275
15
+ hexastack_graphql/infra/resolvers.py,sha256=4tw7E1YnBNiiWgJ1ZBL5xD6Y0Jkjf_8YGbC_54Zh6a4,2079
16
+ hexastack_graphql-0.0.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
17
+ hexastack_graphql-0.0.0.dist-info/entry_points.txt,sha256=QIRZICASiqhHa1vQ2BZ8xPZgKu_n78U0HM-XpwZV2X4,91
18
+ hexastack_graphql-0.0.0.dist-info/METADATA,sha256=6m585j1WU5bWeOVCSWs88wwcxaEoF7NNbWQTGqyyY7k,6566
19
+ hexastack_graphql-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [hexastack.bootstrappers]
2
+ graphql = hexastack_graphql.infra.bootstrap:GraphQLBootstrapper
3
+