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.
- hexastack_graphql/__init__.py +7 -0
- hexastack_graphql/adapters/__init__.py +0 -0
- hexastack_graphql/adapters/fastapi.py +122 -0
- hexastack_graphql/domain/__init__.py +0 -0
- hexastack_graphql/domain/context.py +27 -0
- hexastack_graphql/domain/exceptions.py +25 -0
- hexastack_graphql/infra/__init__.py +0 -0
- hexastack_graphql/infra/autodiscovery.py +106 -0
- hexastack_graphql/infra/bootstrap.py +113 -0
- hexastack_graphql/infra/config.py +60 -0
- hexastack_graphql/infra/decorators.py +212 -0
- hexastack_graphql/infra/extensions.py +26 -0
- hexastack_graphql/infra/registries/__init__.py +0 -0
- hexastack_graphql/infra/registries/schema.py +151 -0
- hexastack_graphql/infra/resolvers.py +69 -0
- hexastack_graphql-0.0.0.dist-info/METADATA +190 -0
- hexastack_graphql-0.0.0.dist-info/RECORD +19 -0
- hexastack_graphql-0.0.0.dist-info/WHEEL +4 -0
- hexastack_graphql-0.0.0.dist-info/entry_points.txt +3 -0
|
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
|
+
[](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,,
|