hexastack-grpc 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-grpc might be problematic. Click here for more details.
- hexastack_grpc/__init__.py +7 -0
- hexastack_grpc/adapters/__init__.py +0 -0
- hexastack_grpc/adapters/server.py +46 -0
- hexastack_grpc/domain/__init__.py +0 -0
- hexastack_grpc/domain/exceptions.py +24 -0
- hexastack_grpc/infra/__init__.py +0 -0
- hexastack_grpc/infra/autodiscovery.py +64 -0
- hexastack_grpc/infra/bootstrap.py +113 -0
- hexastack_grpc/infra/config.py +49 -0
- hexastack_grpc/infra/decorators.py +62 -0
- hexastack_grpc/infra/dispatch.py +101 -0
- hexastack_grpc/infra/interceptors/__init__.py +0 -0
- hexastack_grpc/infra/interceptors/correlation.py +67 -0
- hexastack_grpc/infra/interceptors/exception.py +78 -0
- hexastack_grpc/infra/interceptors/generic.py +180 -0
- hexastack_grpc/infra/interceptors/logging.py +63 -0
- hexastack_grpc/infra/registries/__init__.py +0 -0
- hexastack_grpc/infra/registries/service.py +133 -0
- hexastack_grpc-0.0.0.dist-info/METADATA +142 -0
- hexastack_grpc-0.0.0.dist-info/RECORD +22 -0
- hexastack_grpc-0.0.0.dist-info/WHEEL +4 -0
- hexastack_grpc-0.0.0.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
import grpc
|
|
4
|
+
|
|
5
|
+
from hexastack_grpc.infra.config import HexastackGrpcConfig
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"create_async_grpc_server",
|
|
9
|
+
"run_grpc_server",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_async_grpc_server(
|
|
14
|
+
config: HexastackGrpcConfig,
|
|
15
|
+
interceptors: Sequence[grpc.aio.ServerInterceptor] | None = None,
|
|
16
|
+
) -> grpc.aio.Server:
|
|
17
|
+
"""Create an asynchronous grpc.aio.Server instance.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
config: HexastackGrpcConfig options.
|
|
21
|
+
interceptors: Optional async server interceptors.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Configured grpc.aio.Server instance.
|
|
25
|
+
"""
|
|
26
|
+
server = grpc.aio.server(interceptors=interceptors or ())
|
|
27
|
+
server.add_insecure_port(f"{config.host}:{config.port}")
|
|
28
|
+
return server
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_grpc_server(
|
|
32
|
+
server: grpc.Server,
|
|
33
|
+
block: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Start the gRPC server and optionally block the current thread.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
server: Configured grpc.Server instance.
|
|
39
|
+
block: If True, blocks thread with server.wait_for_termination().
|
|
40
|
+
"""
|
|
41
|
+
server.start()
|
|
42
|
+
if block:
|
|
43
|
+
try:
|
|
44
|
+
server.wait_for_termination()
|
|
45
|
+
except KeyboardInterrupt:
|
|
46
|
+
server.stop(grace=5.0)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from hexastack_core.domain.exceptions import HexastackError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GrpcError(HexastackError):
|
|
5
|
+
"""Base exception for all gRPC presentation layer errors.
|
|
6
|
+
|
|
7
|
+
Notes/Architectural Intent:
|
|
8
|
+
Inherits from HexastackError to maintain unified error hierarchy across the framework.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceRegistrationError(GrpcError):
|
|
13
|
+
"""Exception raised when a gRPC servicer fails registration or lacks a valid add_to_server callback."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RpcExecutionError(GrpcError):
|
|
17
|
+
"""Exception raised when an RPC handler execution fails during dispatch."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"GrpcError",
|
|
22
|
+
"RpcExecutionError",
|
|
23
|
+
"ServiceRegistrationError",
|
|
24
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from types import ModuleType
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from hexastack_core.infra.autodiscovery import (
|
|
6
|
+
DiscoveryVisitor,
|
|
7
|
+
scan_modules,
|
|
8
|
+
)
|
|
9
|
+
from hexastack_grpc.infra.decorators import _GRPC_SERVICE_ATTR
|
|
10
|
+
from hexastack_grpc.infra.registries.service import (
|
|
11
|
+
GrpcServiceRegistration,
|
|
12
|
+
GrpcServiceRegistry,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"autodiscover_grpc_services",
|
|
17
|
+
"create_grpc_visitor",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def autodiscover_grpc_services(
|
|
22
|
+
packages_to_scan: Sequence[str | ModuleType],
|
|
23
|
+
registry: GrpcServiceRegistry,
|
|
24
|
+
) -> GrpcServiceRegistry:
|
|
25
|
+
"""Discover decorated gRPC servicers from packages.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
packages_to_scan: Sequence of package names or module objects to inspect.
|
|
29
|
+
registry: Target GrpcServiceRegistry instance.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The populated GrpcServiceRegistry instance.
|
|
33
|
+
"""
|
|
34
|
+
visitor = create_grpc_visitor(registry)
|
|
35
|
+
scan_modules(packages_to_scan, [visitor])
|
|
36
|
+
return registry
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_grpc_visitor(
|
|
40
|
+
registry: GrpcServiceRegistry,
|
|
41
|
+
) -> DiscoveryVisitor:
|
|
42
|
+
"""Create a DiscoveryVisitor callback for single-pass gRPC service discovery.
|
|
43
|
+
|
|
44
|
+
Notes/Architectural Intent:
|
|
45
|
+
Inspects discovered classes and objects for @grpc_service metadata,
|
|
46
|
+
registering servicers into the supplied service registry during single-pass reflection.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
registry: Target GrpcServiceRegistry instance.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
DiscoveryVisitor callable accepting (member, module).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def visitor(obj: Any, module: ModuleType) -> None:
|
|
56
|
+
meta: GrpcServiceRegistration | None = getattr(obj, _GRPC_SERVICE_ATTR, None)
|
|
57
|
+
if meta is not None:
|
|
58
|
+
registry.register_service(
|
|
59
|
+
servicer=meta.servicer,
|
|
60
|
+
add_to_server_fn=meta.add_to_server_fn,
|
|
61
|
+
service_names=meta.service_names,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return visitor
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import grpc
|
|
4
|
+
|
|
5
|
+
from hexastack_core.infra.bootstrap import BootstrapContext
|
|
6
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
7
|
+
from hexastack_core.ports.bootstrap import BootstrapperPort
|
|
8
|
+
from hexastack_grpc.infra.autodiscovery import create_grpc_visitor
|
|
9
|
+
from hexastack_grpc.infra.config import (
|
|
10
|
+
HexastackGrpcConfig,
|
|
11
|
+
register_grpc_config,
|
|
12
|
+
)
|
|
13
|
+
from hexastack_grpc.infra.decorators import get_grpc_registry
|
|
14
|
+
from hexastack_grpc.infra.interceptors.correlation import (
|
|
15
|
+
CorrelationServerInterceptor,
|
|
16
|
+
)
|
|
17
|
+
from hexastack_grpc.infra.interceptors.exception import (
|
|
18
|
+
ExceptionServerInterceptor,
|
|
19
|
+
)
|
|
20
|
+
from hexastack_grpc.infra.interceptors.logging import (
|
|
21
|
+
LoggingServerInterceptor,
|
|
22
|
+
TimingServerInterceptor,
|
|
23
|
+
)
|
|
24
|
+
from hexastack_grpc.infra.registries.service import GrpcServiceRegistry
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class GrpcBootstrapResult:
|
|
29
|
+
"""Dataclass holding initialized gRPC server and configuration."""
|
|
30
|
+
|
|
31
|
+
config: HexastackGrpcConfig
|
|
32
|
+
server: grpc.Server
|
|
33
|
+
registry: GrpcServiceRegistry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GrpcBootstrapper(BootstrapperPort):
|
|
37
|
+
"""Bootstrap extension configuring and building the gRPC server.
|
|
38
|
+
|
|
39
|
+
Notes/Architectural Intent:
|
|
40
|
+
Implements BootstrapperPort with order=40 (executing after CQRS order=20),
|
|
41
|
+
registering the autodiscovery visitor, compiling the gRPC Server with
|
|
42
|
+
telemetry interceptors (correlation, exception mapping, logging, timing),
|
|
43
|
+
and binding it to the DI container.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name: str = "grpc"
|
|
47
|
+
order: int = 40
|
|
48
|
+
|
|
49
|
+
def configure(self, context: BootstrapContext) -> None:
|
|
50
|
+
"""Phase 2: Register visitor, assemble gRPC server with interceptors.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
context: BootstrapContext containing DI container, config, and properties.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
None.
|
|
57
|
+
"""
|
|
58
|
+
cfg = context.get_config("grpc", HexastackGrpcConfig)
|
|
59
|
+
|
|
60
|
+
registry = get_grpc_registry()
|
|
61
|
+
|
|
62
|
+
# Register visitor for single-pass reflective scanning (Phase 3)
|
|
63
|
+
visitor = create_grpc_visitor(registry)
|
|
64
|
+
context.register_visitor(visitor)
|
|
65
|
+
|
|
66
|
+
# 1. Assemble standard interceptors in order
|
|
67
|
+
interceptors = [
|
|
68
|
+
CorrelationServerInterceptor(),
|
|
69
|
+
ExceptionServerInterceptor(),
|
|
70
|
+
LoggingServerInterceptor(),
|
|
71
|
+
TimingServerInterceptor(),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# 2. Build grpc.Server instance
|
|
75
|
+
server = registry.build_server(
|
|
76
|
+
config=cfg,
|
|
77
|
+
interceptors=interceptors,
|
|
78
|
+
container=context.container,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# 3. Register Server and Registry into DI container
|
|
82
|
+
context.container.add_instance(server, declared_class=grpc.Server)
|
|
83
|
+
context.container.add_instance(registry)
|
|
84
|
+
|
|
85
|
+
# 4. Auto-start if configured
|
|
86
|
+
if cfg.auto_start:
|
|
87
|
+
server.start()
|
|
88
|
+
|
|
89
|
+
# 5. Store in context properties
|
|
90
|
+
result = GrpcBootstrapResult(
|
|
91
|
+
config=cfg,
|
|
92
|
+
server=server,
|
|
93
|
+
registry=registry,
|
|
94
|
+
)
|
|
95
|
+
context.properties["grpc_result"] = result
|
|
96
|
+
context.properties["grpc_server"] = server
|
|
97
|
+
|
|
98
|
+
def register_config(self, registry: ConfigRegistry) -> None:
|
|
99
|
+
"""Phase 1: Register gRPC configuration schema under 'grpc'.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
registry: Target ConfigRegistry instance.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
None.
|
|
106
|
+
"""
|
|
107
|
+
register_grpc_config(registry)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"GrpcBootstrapper",
|
|
112
|
+
"GrpcBootstrapResult",
|
|
113
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
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("grpc")
|
|
8
|
+
class HexastackGrpcConfig(BaseModel):
|
|
9
|
+
"""Configuration schema for Hexastack gRPC server adapter.
|
|
10
|
+
|
|
11
|
+
Notes/Architectural Intent:
|
|
12
|
+
Configures host binding, port, worker thread pools, and reflection support.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
host: str = Field(
|
|
16
|
+
default="0.0.0.0",
|
|
17
|
+
description="Host interface address to bind the gRPC server to.",
|
|
18
|
+
)
|
|
19
|
+
port: int = Field(
|
|
20
|
+
default=50051,
|
|
21
|
+
description="Port number to listen for incoming gRPC connections.",
|
|
22
|
+
)
|
|
23
|
+
max_workers: int = Field(
|
|
24
|
+
default=10,
|
|
25
|
+
description="Maximum worker threads allocated in the gRPC thread pool executor.",
|
|
26
|
+
)
|
|
27
|
+
enable_reflection: bool = Field(
|
|
28
|
+
default=True,
|
|
29
|
+
description="Enable gRPC Server Reflection protocol for grpcurl and postman discovery.",
|
|
30
|
+
)
|
|
31
|
+
auto_start: bool = Field(
|
|
32
|
+
default=False,
|
|
33
|
+
description="Automatically start the gRPC server in a background thread on bootstrap.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"HexastackGrpcConfig",
|
|
39
|
+
"register_grpc_config",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register_grpc_config(registry: ConfigRegistry) -> None:
|
|
44
|
+
"""Register gRPC configuration schema under 'grpc'.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
registry: Target ConfigRegistry instance.
|
|
48
|
+
"""
|
|
49
|
+
registry.register_config_section("grpc", HexastackGrpcConfig)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from hexastack_grpc.infra.registries.service import (
|
|
5
|
+
GrpcServiceRegistration,
|
|
6
|
+
GrpcServiceRegistry,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
_GRPC_SERVICE_ATTR = "__hexastack_grpc_service__"
|
|
10
|
+
|
|
11
|
+
_default_registry = GrpcServiceRegistry()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"get_grpc_registry",
|
|
16
|
+
"grpc_service",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_grpc_registry() -> GrpcServiceRegistry:
|
|
21
|
+
"""Return the global default GrpcServiceRegistry instance.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
GrpcServiceRegistry instance.
|
|
25
|
+
"""
|
|
26
|
+
return _default_registry
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def grpc_service(
|
|
30
|
+
add_to_server_fn: Callable[[Any, Any], None],
|
|
31
|
+
*,
|
|
32
|
+
service_names: Sequence[str] = (),
|
|
33
|
+
) -> Callable[[Any], Any]:
|
|
34
|
+
"""Decorator registering a gRPC servicer class or instance with its protobuf hook.
|
|
35
|
+
|
|
36
|
+
Notes/Architectural Intent:
|
|
37
|
+
Attaches metadata for single-pass module scanning and registers the servicer
|
|
38
|
+
in the default GrpcServiceRegistry.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
add_to_server_fn: Generated protobuf hook (e.g. add_UserServiceServicer_to_server).
|
|
42
|
+
service_names: Optional sequence of full service names for reflection.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Decorator function.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def decorator(target: Any) -> Any:
|
|
49
|
+
meta = GrpcServiceRegistration(
|
|
50
|
+
servicer=target,
|
|
51
|
+
add_to_server_fn=add_to_server_fn,
|
|
52
|
+
service_names=service_names,
|
|
53
|
+
)
|
|
54
|
+
setattr(target, _GRPC_SERVICE_ATTR, meta)
|
|
55
|
+
_default_registry.register_service(
|
|
56
|
+
servicer=target,
|
|
57
|
+
add_to_server_fn=add_to_server_fn,
|
|
58
|
+
service_names=service_names,
|
|
59
|
+
)
|
|
60
|
+
return target
|
|
61
|
+
|
|
62
|
+
return decorator
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from rodi import Container
|
|
5
|
+
|
|
6
|
+
from hexastack_core.domain.command import Command
|
|
7
|
+
from hexastack_core.domain.query import Query
|
|
8
|
+
from hexastack_core.utils.inspection import extract_dto_fields
|
|
9
|
+
from hexastack_cqrs.ports.buses import CommandBusPort, QueryBusPort
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"dispatch_rpc_command",
|
|
13
|
+
"dispatch_rpc_command_async",
|
|
14
|
+
"dispatch_rpc_query",
|
|
15
|
+
"dispatch_rpc_query_async",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dispatch_rpc_command[C: Command, R](
|
|
20
|
+
request: Any,
|
|
21
|
+
command_cls: type[C],
|
|
22
|
+
container: Container,
|
|
23
|
+
**extra_kwargs: Any,
|
|
24
|
+
) -> R:
|
|
25
|
+
"""Instantiate and dispatch a Command from a gRPC protobuf request.
|
|
26
|
+
|
|
27
|
+
Notes/Architectural Intent:
|
|
28
|
+
Bridges gRPC RPC handlers to CQRS CommandBusPort with automatic field mapping.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request: Incoming Protobuf request object.
|
|
32
|
+
command_cls: Target Command DTO class.
|
|
33
|
+
container: Active rodi DI Container.
|
|
34
|
+
**extra_kwargs: Additional parameters overriding request fields.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Command execution result.
|
|
38
|
+
"""
|
|
39
|
+
fields = extract_dto_fields(request, command_cls)
|
|
40
|
+
fields.update(extra_kwargs)
|
|
41
|
+
command = command_cls(**fields)
|
|
42
|
+
bus = container.resolve(CommandBusPort)
|
|
43
|
+
return bus.dispatch(command) # type: ignore[return-value]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def dispatch_rpc_command_async[C: Command, R](
|
|
47
|
+
request: Any,
|
|
48
|
+
command_cls: type[C],
|
|
49
|
+
container: Container,
|
|
50
|
+
**extra_kwargs: Any,
|
|
51
|
+
) -> R:
|
|
52
|
+
"""Asynchronously instantiate and dispatch a Command from a gRPC request."""
|
|
53
|
+
fields = extract_dto_fields(request, command_cls)
|
|
54
|
+
fields.update(extra_kwargs)
|
|
55
|
+
command = command_cls(**fields)
|
|
56
|
+
bus = container.resolve(CommandBusPort)
|
|
57
|
+
res = bus.dispatch(command)
|
|
58
|
+
if inspect.isawaitable(res):
|
|
59
|
+
res = await res
|
|
60
|
+
return res # type: ignore[return-value]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def dispatch_rpc_query[Q: Query, R](
|
|
64
|
+
request: Any,
|
|
65
|
+
query_cls: type[Q],
|
|
66
|
+
container: Container,
|
|
67
|
+
**extra_kwargs: Any,
|
|
68
|
+
) -> R:
|
|
69
|
+
"""Instantiate and dispatch a Query from a gRPC protobuf request.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
request: Incoming Protobuf request object.
|
|
73
|
+
query_cls: Target Query DTO class.
|
|
74
|
+
container: Active rodi DI Container.
|
|
75
|
+
**extra_kwargs: Additional parameters overriding request fields.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Query execution result.
|
|
79
|
+
"""
|
|
80
|
+
fields = extract_dto_fields(request, query_cls)
|
|
81
|
+
fields.update(extra_kwargs)
|
|
82
|
+
query = query_cls(**fields)
|
|
83
|
+
bus = container.resolve(QueryBusPort)
|
|
84
|
+
return bus.dispatch(query) # type: ignore[return-value]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def dispatch_rpc_query_async[Q: Query, R](
|
|
88
|
+
request: Any,
|
|
89
|
+
query_cls: type[Q],
|
|
90
|
+
container: Container,
|
|
91
|
+
**extra_kwargs: Any,
|
|
92
|
+
) -> R:
|
|
93
|
+
"""Asynchronously instantiate and dispatch a Query from a gRPC request."""
|
|
94
|
+
fields = extract_dto_fields(request, query_cls)
|
|
95
|
+
fields.update(extra_kwargs)
|
|
96
|
+
query = query_cls(**fields)
|
|
97
|
+
bus = container.resolve(QueryBusPort)
|
|
98
|
+
res = bus.dispatch(query)
|
|
99
|
+
if inspect.isawaitable(res):
|
|
100
|
+
res = await res
|
|
101
|
+
return res # type: ignore[return-value]
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import grpc
|
|
5
|
+
|
|
6
|
+
from hexastack_core.utils.context import (
|
|
7
|
+
correlation_scope,
|
|
8
|
+
new_correlation_id,
|
|
9
|
+
)
|
|
10
|
+
from hexastack_grpc.infra.interceptors.generic import (
|
|
11
|
+
AsyncGenericServerInterceptor,
|
|
12
|
+
GenericServerInterceptor,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_CORRELATION_METADATA_KEY = "x-correlation-id"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CorrelationServerInterceptor(GenericServerInterceptor):
|
|
19
|
+
"""Synchronous gRPC Server Interceptor for correlation ID propagation.
|
|
20
|
+
|
|
21
|
+
Notes/Architectural Intent:
|
|
22
|
+
Extracts 'x-correlation-id' from incoming gRPC invocation metadata,
|
|
23
|
+
or generates a fresh UUID4, setting it in ContextVar for the RPC duration.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def _handle_unary(
|
|
27
|
+
self,
|
|
28
|
+
request: Any,
|
|
29
|
+
context: grpc.ServicerContext,
|
|
30
|
+
unary_fn: Callable[[Any, grpc.ServicerContext], Any],
|
|
31
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Attach a correlation scope for the duration of the unary RPC call."""
|
|
34
|
+
cid = _extract_cid(handler_call_details.invocation_metadata)
|
|
35
|
+
with correlation_scope(cid):
|
|
36
|
+
return unary_fn(request, context)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncCorrelationServerInterceptor(AsyncGenericServerInterceptor):
|
|
40
|
+
"""Asynchronous gRPC Server Interceptor for correlation ID propagation."""
|
|
41
|
+
|
|
42
|
+
async def _handle_unary_async(
|
|
43
|
+
self,
|
|
44
|
+
request: Any,
|
|
45
|
+
context: grpc.aio.ServicerContext,
|
|
46
|
+
unary_fn: Callable[[Any, grpc.aio.ServicerContext], Any],
|
|
47
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
48
|
+
) -> Any:
|
|
49
|
+
"""Attach a correlation scope for the duration of the async unary RPC call."""
|
|
50
|
+
cid = _extract_cid(handler_call_details.invocation_metadata)
|
|
51
|
+
with correlation_scope(cid):
|
|
52
|
+
return await unary_fn(request, context)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AsyncCorrelationServerInterceptor",
|
|
57
|
+
"CorrelationServerInterceptor",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_cid(metadata: Any) -> str:
|
|
62
|
+
"""Extract correlation ID from gRPC invocation metadata or generate fresh UUID."""
|
|
63
|
+
if metadata:
|
|
64
|
+
for key, val in metadata:
|
|
65
|
+
if key.lower() == _CORRELATION_METADATA_KEY:
|
|
66
|
+
return val.decode("utf-8") if isinstance(val, bytes) else str(val)
|
|
67
|
+
return new_correlation_id()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import grpc
|
|
5
|
+
|
|
6
|
+
from hexastack_core.domain.exceptions import HexastackError
|
|
7
|
+
from hexastack_grpc.infra.interceptors.generic import (
|
|
8
|
+
AsyncGenericServerInterceptor,
|
|
9
|
+
GenericServerInterceptor,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExceptionServerInterceptor(GenericServerInterceptor):
|
|
14
|
+
"""Synchronous gRPC Server Interceptor mapping domain exceptions to gRPC status codes.
|
|
15
|
+
|
|
16
|
+
Notes/Architectural Intent:
|
|
17
|
+
Intercepts unhandled HexastackError domain exceptions and aborts the RPC
|
|
18
|
+
call with appropriate native gRPC StatusCodes (NOT_FOUND, INVALID_ARGUMENT, etc.).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def _handle_unary(
|
|
22
|
+
self,
|
|
23
|
+
request: Any,
|
|
24
|
+
context: grpc.ServicerContext,
|
|
25
|
+
unary_fn: Callable[[Any, grpc.ServicerContext], Any],
|
|
26
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
27
|
+
) -> Any:
|
|
28
|
+
"""Invoke the unary handler, aborting the RPC on any domain exception."""
|
|
29
|
+
try:
|
|
30
|
+
return unary_fn(request, context)
|
|
31
|
+
except Exception as exc: # noqa: BLE001
|
|
32
|
+
status_code, details = _map_exception_to_status_code(exc)
|
|
33
|
+
context.abort(status_code, details)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncExceptionServerInterceptor(AsyncGenericServerInterceptor):
|
|
37
|
+
"""Asynchronous gRPC Server Interceptor mapping domain exceptions to gRPC status codes."""
|
|
38
|
+
|
|
39
|
+
async def _handle_unary_async(
|
|
40
|
+
self,
|
|
41
|
+
request: Any,
|
|
42
|
+
context: grpc.aio.ServicerContext,
|
|
43
|
+
unary_fn: Callable[[Any, grpc.aio.ServicerContext], Any],
|
|
44
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""Invoke the async unary handler, aborting the RPC on any domain exception."""
|
|
47
|
+
try:
|
|
48
|
+
return await unary_fn(request, context)
|
|
49
|
+
except Exception as exc: # noqa: BLE001
|
|
50
|
+
status_code, details = _map_exception_to_status_code(exc)
|
|
51
|
+
await context.abort(status_code, details)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"AsyncExceptionServerInterceptor",
|
|
56
|
+
"ExceptionServerInterceptor",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _map_exception_to_status_code(exc: Exception) -> tuple[grpc.StatusCode, str]:
|
|
61
|
+
"""Map domain exceptions to gRPC status codes and detail messages."""
|
|
62
|
+
exc_name = type(exc).__name__.lower()
|
|
63
|
+
msg = str(exc)
|
|
64
|
+
|
|
65
|
+
if "notfound" in exc_name:
|
|
66
|
+
return grpc.StatusCode.NOT_FOUND, msg
|
|
67
|
+
if "validation" in exc_name or "invalid" in exc_name:
|
|
68
|
+
return grpc.StatusCode.INVALID_ARGUMENT, msg
|
|
69
|
+
if "unauthorized" in exc_name or "authentication" in exc_name:
|
|
70
|
+
return grpc.StatusCode.UNAUTHENTICATED, msg
|
|
71
|
+
if "forbidden" in exc_name or "permission" in exc_name:
|
|
72
|
+
return grpc.StatusCode.PERMISSION_DENIED, msg
|
|
73
|
+
if "conflict" in exc_name or "alreadyexists" in exc_name:
|
|
74
|
+
return grpc.StatusCode.ALREADY_EXISTS, msg
|
|
75
|
+
if isinstance(exc, HexastackError):
|
|
76
|
+
return grpc.StatusCode.INTERNAL, msg
|
|
77
|
+
|
|
78
|
+
return grpc.StatusCode.UNKNOWN, msg
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Generic base classes for synchronous and asynchronous gRPC server interceptors.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Eliminates the repeated handler-resolution, guard, unary extraction, and
|
|
5
|
+
method-handler rebuild boilerplate that is identical across every concrete
|
|
6
|
+
interceptor (correlation, exception, logging, timing, etc.).
|
|
7
|
+
|
|
8
|
+
Subclasses implement a single abstract method:
|
|
9
|
+
- ``_handle_unary`` (sync) or ``_handle_unary_async`` (async)
|
|
10
|
+
|
|
11
|
+
The base class owns the gRPC plumbing; the subclass owns only its
|
|
12
|
+
cross-cutting concern.
|
|
13
|
+
|
|
14
|
+
Supports ``grpc.ServerInterceptor`` (synchronous) and
|
|
15
|
+
``grpc.aio.ServerInterceptor`` (asynchronous) via two separate hierarchies
|
|
16
|
+
so that typing remains strict and no coroutine/non-coroutine mixing occurs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import grpc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GenericServerInterceptor(grpc.ServerInterceptor, ABC):
|
|
29
|
+
"""Abstract base for synchronous unary-unary gRPC server interceptors.
|
|
30
|
+
|
|
31
|
+
Notes/Architectural Intent:
|
|
32
|
+
Owns the repetitive handler-resolution and ``unary_unary`` extraction
|
|
33
|
+
pattern so concrete subclasses implement only their own cross-cutting
|
|
34
|
+
concern inside ``_handle_unary``.
|
|
35
|
+
|
|
36
|
+
The base transparently passes through handlers for non-unary RPC types
|
|
37
|
+
(client-streaming, server-streaming, bidi-streaming) so subclasses do
|
|
38
|
+
not need to handle those cases unless they wish to.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def _handle_unary(
|
|
43
|
+
self,
|
|
44
|
+
request: Any,
|
|
45
|
+
context: grpc.ServicerContext,
|
|
46
|
+
unary_fn: Callable[[Any, grpc.ServicerContext], Any],
|
|
47
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
48
|
+
) -> Any:
|
|
49
|
+
"""Execute the cross-cutting concern around the unary handler invocation.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
request: The deserialized gRPC request message.
|
|
53
|
+
context: The active ``grpc.ServicerContext`` for this RPC call.
|
|
54
|
+
unary_fn: The next-in-chain unary handler callable to invoke.
|
|
55
|
+
handler_call_details: Metadata about the incoming RPC call, including
|
|
56
|
+
method name and invocation metadata.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The response returned by ``unary_fn`` (or a substitute value).
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
Exception: Subclasses may raise, suppress, or re-raise as needed.
|
|
63
|
+
|
|
64
|
+
Notes/Architectural Intent:
|
|
65
|
+
Subclasses must call ``unary_fn(request, context)`` to propagate the
|
|
66
|
+
call downstream. They may wrap the call in try/except, prepend or
|
|
67
|
+
postpend logic, or modify context (e.g. abort) before/after.
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def intercept_service(
|
|
72
|
+
self,
|
|
73
|
+
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
|
|
74
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
75
|
+
) -> grpc.RpcMethodHandler:
|
|
76
|
+
"""Intercept handler resolution, wrapping unary-unary calls.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
continuation: Callable that resolves the next handler in the chain.
|
|
80
|
+
handler_call_details: Metadata about the incoming RPC call.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A (possibly wrapped) ``grpc.RpcMethodHandler``.
|
|
84
|
+
"""
|
|
85
|
+
handler: Any = continuation(handler_call_details)
|
|
86
|
+
if handler is None:
|
|
87
|
+
return handler
|
|
88
|
+
|
|
89
|
+
unary_fn = getattr(handler, "unary_unary", None)
|
|
90
|
+
if unary_fn is None:
|
|
91
|
+
return handler
|
|
92
|
+
|
|
93
|
+
interceptor = self
|
|
94
|
+
|
|
95
|
+
def _wrapped(request: Any, context: grpc.ServicerContext) -> Any:
|
|
96
|
+
return interceptor._handle_unary(
|
|
97
|
+
request, context, unary_fn, handler_call_details
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return grpc.unary_unary_rpc_method_handler(
|
|
101
|
+
_wrapped,
|
|
102
|
+
request_deserializer=getattr(handler, "request_deserializer", None),
|
|
103
|
+
response_serializer=getattr(handler, "response_serializer", None),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AsyncGenericServerInterceptor(grpc.aio.ServerInterceptor, ABC):
|
|
108
|
+
"""Abstract base for asynchronous unary-unary gRPC server interceptors.
|
|
109
|
+
|
|
110
|
+
Notes/Architectural Intent:
|
|
111
|
+
Async counterpart to ``GenericServerInterceptor``. Owns the
|
|
112
|
+
``await continuation(...)`` / ``await context.abort(...)`` plumbing so
|
|
113
|
+
concrete subclasses only implement ``_handle_unary_async``.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
async def _handle_unary_async(
|
|
118
|
+
self,
|
|
119
|
+
request: Any,
|
|
120
|
+
context: grpc.aio.ServicerContext,
|
|
121
|
+
unary_fn: Callable[[Any, grpc.aio.ServicerContext], Any],
|
|
122
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
123
|
+
) -> Any:
|
|
124
|
+
"""Execute the cross-cutting concern around the async unary handler invocation.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
request: The deserialized gRPC request message.
|
|
128
|
+
context: The active ``grpc.aio.ServicerContext`` for this RPC call.
|
|
129
|
+
unary_fn: The next-in-chain async unary handler coroutine to invoke.
|
|
130
|
+
handler_call_details: Metadata about the incoming RPC call, including
|
|
131
|
+
method name and invocation metadata.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The response returned by ``await unary_fn(request, context)``.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
Exception: Subclasses may raise, suppress, or re-raise as needed.
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
async def intercept_service(
|
|
142
|
+
self,
|
|
143
|
+
continuation: Callable[[grpc.HandlerCallDetails], Any],
|
|
144
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
145
|
+
) -> Any:
|
|
146
|
+
"""Intercept async handler resolution, wrapping unary-unary calls.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
continuation: Async callable that resolves the next handler.
|
|
150
|
+
handler_call_details: Metadata about the incoming RPC call.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
A (possibly wrapped) RPC method handler.
|
|
154
|
+
"""
|
|
155
|
+
handler: Any = await continuation(handler_call_details)
|
|
156
|
+
if handler is None:
|
|
157
|
+
return handler
|
|
158
|
+
|
|
159
|
+
unary_fn = getattr(handler, "unary_unary", None)
|
|
160
|
+
if unary_fn is None:
|
|
161
|
+
return handler
|
|
162
|
+
|
|
163
|
+
interceptor = self
|
|
164
|
+
|
|
165
|
+
async def _wrapped(request: Any, context: grpc.aio.ServicerContext) -> Any:
|
|
166
|
+
return await interceptor._handle_unary_async(
|
|
167
|
+
request, context, unary_fn, handler_call_details
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return grpc.unary_unary_rpc_method_handler(
|
|
171
|
+
_wrapped,
|
|
172
|
+
request_deserializer=getattr(handler, "request_deserializer", None),
|
|
173
|
+
response_serializer=getattr(handler, "response_serializer", None),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
"AsyncGenericServerInterceptor",
|
|
179
|
+
"GenericServerInterceptor",
|
|
180
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import grpc
|
|
7
|
+
|
|
8
|
+
from hexastack_grpc.infra.interceptors.generic import GenericServerInterceptor
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("hexastack.grpc")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoggingServerInterceptor(GenericServerInterceptor):
|
|
14
|
+
"""Synchronous gRPC Server Interceptor for structured RPC logging.
|
|
15
|
+
|
|
16
|
+
Notes/Architectural Intent:
|
|
17
|
+
Logs incoming RPC method calls and completion statuses, capturing exceptions
|
|
18
|
+
and logging structured error details.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def _handle_unary(
|
|
22
|
+
self,
|
|
23
|
+
request: Any,
|
|
24
|
+
context: grpc.ServicerContext,
|
|
25
|
+
unary_fn: Callable[[Any, grpc.ServicerContext], Any],
|
|
26
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
27
|
+
) -> Any:
|
|
28
|
+
"""Log the RPC call start, completion, and any errors."""
|
|
29
|
+
method = handler_call_details.method
|
|
30
|
+
logger.info("Handling gRPC call: %s", method)
|
|
31
|
+
try:
|
|
32
|
+
res = unary_fn(request, context)
|
|
33
|
+
logger.info("Completed gRPC call: %s", method)
|
|
34
|
+
return res
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error("Failed gRPC call %s: %s", method, e)
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TimingServerInterceptor(GenericServerInterceptor):
|
|
41
|
+
"""Synchronous gRPC Server Interceptor for measuring RPC execution latency."""
|
|
42
|
+
|
|
43
|
+
def _handle_unary(
|
|
44
|
+
self,
|
|
45
|
+
request: Any,
|
|
46
|
+
context: grpc.ServicerContext,
|
|
47
|
+
unary_fn: Callable[[Any, grpc.ServicerContext], Any],
|
|
48
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""Measure and log the elapsed time of the unary RPC call."""
|
|
51
|
+
method = handler_call_details.method
|
|
52
|
+
start = time.perf_counter()
|
|
53
|
+
try:
|
|
54
|
+
return unary_fn(request, context)
|
|
55
|
+
finally:
|
|
56
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
57
|
+
logger.debug("RPC %s executed in %.2fms", method, duration_ms)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"LoggingServerInterceptor",
|
|
62
|
+
"TimingServerInterceptor",
|
|
63
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from concurrent import futures
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import grpc
|
|
7
|
+
from rodi import Container
|
|
8
|
+
|
|
9
|
+
from hexastack_grpc.domain.exceptions import ServiceRegistrationError
|
|
10
|
+
from hexastack_grpc.infra.config import HexastackGrpcConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GrpcServiceRegistration:
|
|
15
|
+
"""Dataclass holding registered gRPC servicer and its protobuf attachment hook."""
|
|
16
|
+
|
|
17
|
+
servicer: Any
|
|
18
|
+
add_to_server_fn: Callable[[Any, Any], None]
|
|
19
|
+
service_names: Sequence[str] = ()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GrpcServiceRegistry:
|
|
23
|
+
"""Registry maintaining registered gRPC servicers and compiling the server.
|
|
24
|
+
|
|
25
|
+
Notes/Architectural Intent:
|
|
26
|
+
Compiles registered generated protobuf servicers into a unified grpc.Server,
|
|
27
|
+
attaching interceptor pipelines and optional Server Reflection.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
"""Initialize empty gRPC registry."""
|
|
32
|
+
self._services: list[GrpcServiceRegistration] = []
|
|
33
|
+
|
|
34
|
+
def _enable_reflection(
|
|
35
|
+
self,
|
|
36
|
+
server: grpc.Server,
|
|
37
|
+
all_service_names: Sequence[str],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Enable gRPC Server Reflection protocol if package is present."""
|
|
40
|
+
try:
|
|
41
|
+
from grpc_reflection.v1alpha import reflection
|
|
42
|
+
|
|
43
|
+
names = tuple(all_service_names) + (reflection.SERVICE_NAME,)
|
|
44
|
+
reflection.enable_server_reflection(names, server)
|
|
45
|
+
except ImportError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def _resolve_servicer_instance(
|
|
49
|
+
self,
|
|
50
|
+
servicer_val: Any,
|
|
51
|
+
container: Container | None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""Resolve or instantiate servicer instance."""
|
|
54
|
+
if not isinstance(servicer_val, type):
|
|
55
|
+
return servicer_val
|
|
56
|
+
if container is not None:
|
|
57
|
+
try:
|
|
58
|
+
return container.resolve(servicer_val)
|
|
59
|
+
except Exception: # noqa: BLE001
|
|
60
|
+
return servicer_val()
|
|
61
|
+
return servicer_val()
|
|
62
|
+
|
|
63
|
+
def build_server(
|
|
64
|
+
self,
|
|
65
|
+
config: HexastackGrpcConfig,
|
|
66
|
+
interceptors: Sequence[grpc.ServerInterceptor] | None = None,
|
|
67
|
+
container: Container | None = None,
|
|
68
|
+
) -> grpc.Server:
|
|
69
|
+
"""Construct and populate a grpc.Server instance with registered servicers.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
config: HexastackGrpcConfig options.
|
|
73
|
+
interceptors: Optional sequence of ServerInterceptor instances.
|
|
74
|
+
container: Optional rodi DI Container to resolve servicer class dependencies.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Configured grpc.Server instance.
|
|
78
|
+
"""
|
|
79
|
+
thread_pool = futures.ThreadPoolExecutor(max_workers=config.max_workers)
|
|
80
|
+
server = grpc.server(
|
|
81
|
+
thread_pool=thread_pool,
|
|
82
|
+
interceptors=interceptors or (),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
all_service_names: list[str] = []
|
|
86
|
+
|
|
87
|
+
for reg in self._services:
|
|
88
|
+
servicer_instance = self._resolve_servicer_instance(reg.servicer, container)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
reg.add_to_server_fn(servicer_instance, server)
|
|
92
|
+
all_service_names.extend(reg.service_names)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
raise ServiceRegistrationError(
|
|
95
|
+
f"Failed to attach {servicer_instance} to gRPC server: {e}"
|
|
96
|
+
) from e
|
|
97
|
+
|
|
98
|
+
if config.enable_reflection:
|
|
99
|
+
self._enable_reflection(server, all_service_names)
|
|
100
|
+
|
|
101
|
+
server.add_insecure_port(f"{config.host}:{config.port}")
|
|
102
|
+
return server
|
|
103
|
+
|
|
104
|
+
def clear(self) -> None:
|
|
105
|
+
"""Clear all registered services (for test isolation)."""
|
|
106
|
+
self._services.clear()
|
|
107
|
+
|
|
108
|
+
def register_service(
|
|
109
|
+
self,
|
|
110
|
+
servicer: Any,
|
|
111
|
+
add_to_server_fn: Callable[[Any, Any], None],
|
|
112
|
+
service_names: Sequence[str] = (),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Register a gRPC servicer and its generated add_to_server hook.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
servicer: Servicer instance or class.
|
|
118
|
+
add_to_server_fn: Generated protobuf hook (e.g. add_GreeterServicer_to_server).
|
|
119
|
+
service_names: Optional sequence of full service names for reflection.
|
|
120
|
+
"""
|
|
121
|
+
reg = GrpcServiceRegistration(
|
|
122
|
+
servicer=servicer,
|
|
123
|
+
add_to_server_fn=add_to_server_fn,
|
|
124
|
+
service_names=service_names,
|
|
125
|
+
)
|
|
126
|
+
if reg not in self._services:
|
|
127
|
+
self._services.append(reg)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = [
|
|
131
|
+
"GrpcServiceRegistration",
|
|
132
|
+
"GrpcServiceRegistry",
|
|
133
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: hexastack-grpc
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: High-performance gRPC presentation adapter and interceptors for Hexastack
|
|
5
|
+
Author: Richard West
|
|
6
|
+
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
7
|
+
Requires-Dist: grpcio>=1.68.0
|
|
8
|
+
Requires-Dist: hexastack-core
|
|
9
|
+
Requires-Dist: hexastack-cqrs
|
|
10
|
+
Requires-Dist: protobuf>=5.29.0
|
|
11
|
+
Requires-Dist: grpcio-reflection>=1.68.0 ; extra == 'reflection'
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Provides-Extra: reflection
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# hexastack-grpc
|
|
17
|
+
|
|
18
|
+
> High-performance gRPC presentation adapter, interceptors, and CQRS service integration for Hexastack.
|
|
19
|
+
|
|
20
|
+
[](https://www.python.org/downloads/)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. Overview & Capabilities
|
|
25
|
+
|
|
26
|
+
`hexastack-grpc` provides a binary RPC presentation adapter for Hexastack applications:
|
|
27
|
+
|
|
28
|
+
- **Declarative Service Registration (`@grpc_service`)**: Mounts generated protobuf RPC servicers onto the `grpc.Server` with automatic DI resolution.
|
|
29
|
+
- **Cross-Cutting Interceptor Pipeline**:
|
|
30
|
+
- `CorrelationServerInterceptor`: Propagates `x-correlation-id` from incoming metadata into `ContextVar`.
|
|
31
|
+
- `LoggingServerInterceptor`: Structured telemetry for RPC method invocations.
|
|
32
|
+
- `TimingServerInterceptor`: Measures RPC execution latency in milliseconds.
|
|
33
|
+
- **Single-Pass Reflection**: Automatically discovers and registers decorated servicers in Phase 3 module scanning via `create_grpc_visitor`.
|
|
34
|
+
- **gRPC Server Reflection**: Automatically enables gRPC Server Reflection Protocol when `grpcio-reflection` is installed.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 2. Package Anatomy & Key Components
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
hexastack_grpc/
|
|
42
|
+
├── domain/ # GrpcError, ServiceRegistrationError, RpcExecutionError
|
|
43
|
+
└── infra/
|
|
44
|
+
├── bootstrap.py # GrpcBootstrapper (order=40)
|
|
45
|
+
├── config.py # HexastackGrpcConfig
|
|
46
|
+
├── decorators.py# @grpc_service
|
|
47
|
+
├── autodiscovery.py # create_grpc_visitor, autodiscover_grpc_services
|
|
48
|
+
├── interceptors/# correlation, logging, timing interceptors
|
|
49
|
+
└── registries/ # service.py (GrpcServiceRegistry)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3. Monorepo & Sibling Relationships
|
|
55
|
+
|
|
56
|
+
```mermaid
|
|
57
|
+
graph TD
|
|
58
|
+
subgraph Clients ["gRPC Clients (Microservices, Mobile, Gateway)"]
|
|
59
|
+
CLIENT["gRPC Protobuf Client"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
subgraph GrpcAdapter ["hexastack-grpc"]
|
|
63
|
+
SERVER["grpc.Server"]
|
|
64
|
+
INTERCEPTORS["Interceptors (Correlation, Logging, Timing)"]
|
|
65
|
+
REG["GrpcServiceRegistry"]
|
|
66
|
+
SERVICER["CQRS Protobuf Servicer"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
subgraph CQRSExecution ["hexastack-cqrs"]
|
|
70
|
+
CBUS["CommandBusPort"]
|
|
71
|
+
QBUS["QueryBusPort"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
CLIENT --> SERVER
|
|
75
|
+
SERVER --> INTERCEPTORS
|
|
76
|
+
INTERCEPTORS --> SERVICER
|
|
77
|
+
SERVICER -->|dispatches commands| CBUS
|
|
78
|
+
SERVICER -->|dispatches queries| QBUS
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Explicit Dependencies (Direct)
|
|
82
|
+
- `hexastack-core`: DI container (`rodi`), configuration registry, context variables.
|
|
83
|
+
- `hexastack-cqrs`: `CommandBusPort` and `QueryBusPort`.
|
|
84
|
+
- `grpcio>=1.68.0`, `protobuf>=5.29.0`.
|
|
85
|
+
|
|
86
|
+
### Optional Integrations (Extras)
|
|
87
|
+
- `[reflection]`: Installs `grpcio-reflection>=1.68.0` for runtime service reflection tools (`grpcurl`, Postman).
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 4. Installation
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Standalone installation
|
|
95
|
+
pip install hexastack-grpc
|
|
96
|
+
|
|
97
|
+
# With Server Reflection support
|
|
98
|
+
pip install "hexastack-grpc[reflection]"
|
|
99
|
+
|
|
100
|
+
# Via umbrella package
|
|
101
|
+
pip install "hexastack[grpc]"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 5. Configuration Reference
|
|
107
|
+
|
|
108
|
+
```toml
|
|
109
|
+
[hexastack.grpc]
|
|
110
|
+
host = "0.0.0.0"
|
|
111
|
+
port = 50051
|
|
112
|
+
max_workers = 10
|
|
113
|
+
enable_reflection = true
|
|
114
|
+
auto_start = false
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 6. Quickstart Example
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from hexastack_core.infra.bootstrap import bootstrap
|
|
123
|
+
from hexastack_grpc.infra.decorators import grpc_service
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# 1. Define Servicer implementing generated protobuf class
|
|
127
|
+
class GreeterServicer:
|
|
128
|
+
def SayHello(self, request, context):
|
|
129
|
+
return HelloReply(message=f"Hello, {request.name}!")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# 2. Register Servicer with generated add hook
|
|
133
|
+
@grpc_service(add_GreeterServicer_to_server)
|
|
134
|
+
class GreeterService(GreeterServicer):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# 3. Bootstrap Runtime and Start Server
|
|
139
|
+
runtime = bootstrap(packages_to_scan=[__name__])
|
|
140
|
+
grpc_server = runtime.get("grpc_server")
|
|
141
|
+
grpc_server.start()
|
|
142
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
hexastack_grpc/__init__.py,sha256=xriYulIgr70neOvjzTKco0oP3gAiLEjjL-tv6OYAdnI,109
|
|
2
|
+
hexastack_grpc/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
hexastack_grpc/adapters/server.py,sha256=uRY_E4GEAbEgtPC5rLYTF1V58pqI5m2hK0XGND_aicE,1168
|
|
4
|
+
hexastack_grpc/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
hexastack_grpc/domain/exceptions.py,sha256=ZUJ-TRNFXb2B-z3D7DL0odocsJsP-FfPf9Ngw4ychIU,654
|
|
6
|
+
hexastack_grpc/infra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
hexastack_grpc/infra/autodiscovery.py,sha256=oxAMcPLvztdQzVt-r2GO6CEAR1dKG1A-bNCQXyZEKeo,1875
|
|
8
|
+
hexastack_grpc/infra/bootstrap.py,sha256=LpvodIFhVUFrwvaKN96kb-pdcokVpWeSGitzgKWonUM,3449
|
|
9
|
+
hexastack_grpc/infra/config.py,sha256=6Mv1LJ1Aq9i3mAuaRbIOCRiug4eIWk-DlNbePkSRQls,1462
|
|
10
|
+
hexastack_grpc/infra/decorators.py,sha256=l2TexzEmSercWrKjUNzhC65XDyxzWQkowBpwL6PPlQ4,1634
|
|
11
|
+
hexastack_grpc/infra/dispatch.py,sha256=lx_LIWXenMbsOHRXS-M2W4UobK0QD2Yt6W92cP31ASM,3045
|
|
12
|
+
hexastack_grpc/infra/interceptors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
hexastack_grpc/infra/interceptors/correlation.py,sha256=EHAW7xDa_D3rJZ9uNi2nS19rqAEG1o4rCiivO9u5DUA,2232
|
|
14
|
+
hexastack_grpc/infra/interceptors/exception.py,sha256=H8w-A72fnaxcqkPJgsxHaqBfVcO7jbqixMGd_ZgG34w,2873
|
|
15
|
+
hexastack_grpc/infra/interceptors/generic.py,sha256=E6J6n5w46mOticlqx_IJUWD4776dK0Ih6af0ko-4eHs,6580
|
|
16
|
+
hexastack_grpc/infra/interceptors/logging.py,sha256=S-prIyNSjC4-izjWh2mYk59yidD1eQG_XoSPWdfe6s8,1992
|
|
17
|
+
hexastack_grpc/infra/registries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
hexastack_grpc/infra/registries/service.py,sha256=IZRGnROI9Xfd0cTQWCgni6t0TFuwzH1b2De5Nfd-iho,4382
|
|
19
|
+
hexastack_grpc-0.0.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
|
|
20
|
+
hexastack_grpc-0.0.0.dist-info/entry_points.txt,sha256=q8_q1a0lSr9Ldxaq-Zg2z-N4TxFN5XXJI2Eao5OJBOQ,82
|
|
21
|
+
hexastack_grpc-0.0.0.dist-info/METADATA,sha256=oAOEoc0PvLF0W2dbs2TzwfGDDHIo71f2otHhOTWBeZQ,4192
|
|
22
|
+
hexastack_grpc-0.0.0.dist-info/RECORD,,
|