austial-microservices 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- austial_microservices-0.5.0/.gitignore +15 -0
- austial_microservices-0.5.0/PKG-INFO +22 -0
- austial_microservices-0.5.0/pyproject.toml +34 -0
- austial_microservices-0.5.0/src/austial/microservices/__init__.py +65 -0
- austial_microservices-0.5.0/src/austial/microservices/client.py +71 -0
- austial_microservices-0.5.0/src/austial/microservices/decorators.py +55 -0
- austial_microservices-0.5.0/src/austial/microservices/factory.py +109 -0
- austial_microservices-0.5.0/src/austial/microservices/microservice.py +52 -0
- austial_microservices-0.5.0/src/austial/microservices/server.py +172 -0
- austial_microservices-0.5.0/src/austial/microservices/transport.py +276 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/__init__.py +36 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/grpc.py +114 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/grpc_proto.py +360 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/kafka.py +77 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/mqtt.py +61 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/nats.py +48 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/pubsub.py +177 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/rabbitmq.py +61 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/redis.py +72 -0
- austial_microservices-0.5.0/src/austial/microservices/transports/redis_streams.py +94 -0
- austial_microservices-0.5.0/test/grpc_proto_spec.py +220 -0
- austial_microservices-0.5.0/test/microservices_spec.py +343 -0
- austial_microservices-0.5.0/test/protos/hero.proto +17 -0
- austial_microservices-0.5.0/test/transports_spec.py +599 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: austial-microservices
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Message-driven applications: @MessagePattern / @EventPattern handlers served over TCP, in-process, Redis (Pub/Sub and Streams), NATS, Kafka, MQTT, RabbitMQ and gRPC (JSON or protobuf with streaming), plus ClientProxy and hybrid apps -- mirrors @nestjs/microservices.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: austial-core
|
|
7
|
+
Provides-Extra: grpc
|
|
8
|
+
Requires-Dist: grpcio>=1.66; extra == 'grpc'
|
|
9
|
+
Provides-Extra: grpc-proto
|
|
10
|
+
Requires-Dist: grpcio-tools>=1.66; extra == 'grpc-proto'
|
|
11
|
+
Requires-Dist: grpcio>=1.66; extra == 'grpc-proto'
|
|
12
|
+
Requires-Dist: protobuf>=5; extra == 'grpc-proto'
|
|
13
|
+
Provides-Extra: kafka
|
|
14
|
+
Requires-Dist: aiokafka>=0.11; extra == 'kafka'
|
|
15
|
+
Provides-Extra: mqtt
|
|
16
|
+
Requires-Dist: aiomqtt>=2.3; extra == 'mqtt'
|
|
17
|
+
Provides-Extra: nats
|
|
18
|
+
Requires-Dist: nats-py>=2.9; extra == 'nats'
|
|
19
|
+
Provides-Extra: rabbitmq
|
|
20
|
+
Requires-Dist: aio-pika>=9.4; extra == 'rabbitmq'
|
|
21
|
+
Provides-Extra: redis
|
|
22
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "austial-microservices"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Message-driven applications: @MessagePattern / @EventPattern handlers served over TCP, in-process, Redis (Pub/Sub and Streams), NATS, Kafka, MQTT, RabbitMQ and gRPC (JSON or protobuf with streaming), plus ClientProxy and hybrid apps -- mirrors @nestjs/microservices."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"austial-core",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
# Each broker library is imported lazily and raises an install hint when missing.
|
|
12
|
+
redis = ["redis>=5.0"]
|
|
13
|
+
nats = ["nats-py>=2.9"]
|
|
14
|
+
kafka = ["aiokafka>=0.11"]
|
|
15
|
+
mqtt = ["aiomqtt>=2.3"]
|
|
16
|
+
rabbitmq = ["aio-pika>=9.4"]
|
|
17
|
+
grpc = ["grpcio>=1.66"]
|
|
18
|
+
grpc-proto = ["grpcio>=1.66", "grpcio-tools>=1.66", "protobuf>=5"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/austial"]
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
austial-core = { workspace = true }
|
|
29
|
+
|
|
30
|
+
[tool.hatch.version]
|
|
31
|
+
source = "vcs"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version.raw-options]
|
|
34
|
+
root = "../.."
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""``austial.microservices`` -- message-driven services, mirrors ``@nestjs/microservices``.
|
|
2
|
+
|
|
3
|
+
from austial.microservices import (
|
|
4
|
+
MessagePattern, EventPattern, Payload, Ctx, ClientsModule, ClientProxy, ClientOptions,
|
|
5
|
+
MicroserviceOptions, Transport, create_microservice,
|
|
6
|
+
)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from austial.microservices.client import ClientProxy, ClientsModule
|
|
10
|
+
from austial.microservices.decorators import Ctx, EventPattern, MessagePattern, Payload
|
|
11
|
+
from austial.microservices.factory import build_client_transport, build_server_transport
|
|
12
|
+
from austial.microservices.microservice import (
|
|
13
|
+
MicroserviceApplication,
|
|
14
|
+
attach_microservice,
|
|
15
|
+
create_microservice,
|
|
16
|
+
)
|
|
17
|
+
from austial.microservices.server import HandlerRegistry, MicroserviceServer, RpcContext, RpcExecutionContext
|
|
18
|
+
from austial.microservices.transport import (
|
|
19
|
+
ClientOptions,
|
|
20
|
+
ClientTransport,
|
|
21
|
+
LocalClient,
|
|
22
|
+
LocalServer,
|
|
23
|
+
MicroserviceOptions,
|
|
24
|
+
RpcError,
|
|
25
|
+
RpcException,
|
|
26
|
+
ServerTransport,
|
|
27
|
+
TcpClient,
|
|
28
|
+
TcpServer,
|
|
29
|
+
Transport,
|
|
30
|
+
pattern_key,
|
|
31
|
+
)
|
|
32
|
+
from austial.microservices.transports.grpc_proto import GrpcMethod
|
|
33
|
+
from austial.microservices.transports.pubsub import JsonCodec
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ClientOptions",
|
|
37
|
+
"ClientProxy",
|
|
38
|
+
"ClientTransport",
|
|
39
|
+
"ClientsModule",
|
|
40
|
+
"Ctx",
|
|
41
|
+
"EventPattern",
|
|
42
|
+
"GrpcMethod",
|
|
43
|
+
"HandlerRegistry",
|
|
44
|
+
"JsonCodec",
|
|
45
|
+
"LocalClient",
|
|
46
|
+
"LocalServer",
|
|
47
|
+
"MessagePattern",
|
|
48
|
+
"MicroserviceApplication",
|
|
49
|
+
"MicroserviceOptions",
|
|
50
|
+
"MicroserviceServer",
|
|
51
|
+
"Payload",
|
|
52
|
+
"RpcContext",
|
|
53
|
+
"RpcError",
|
|
54
|
+
"RpcException",
|
|
55
|
+
"RpcExecutionContext",
|
|
56
|
+
"ServerTransport",
|
|
57
|
+
"TcpClient",
|
|
58
|
+
"TcpServer",
|
|
59
|
+
"Transport",
|
|
60
|
+
"attach_microservice",
|
|
61
|
+
"build_client_transport",
|
|
62
|
+
"build_server_transport",
|
|
63
|
+
"create_microservice",
|
|
64
|
+
"pattern_key",
|
|
65
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``ClientProxy`` and ``ClientsModule`` -- mirror Nest's: talk to another microservice.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
@Module(imports=[ClientsModule.register([ClientOptions(name="MATH", port=3001)])])
|
|
6
|
+
class AppModule: ...
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
class Calculator:
|
|
10
|
+
def __init__(self, math: ClientProxy = Inject("MATH")):
|
|
11
|
+
self.math = math
|
|
12
|
+
|
|
13
|
+
async def total(self, numbers):
|
|
14
|
+
return await self.math.send({"cmd": "sum"}, numbers)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from austial.common.decorators.modules import Module
|
|
24
|
+
from austial.core.dynamic_module import DynamicModule
|
|
25
|
+
from austial.microservices.factory import build_client_transport
|
|
26
|
+
from austial.microservices.transport import ClientOptions, ClientTransport, pattern_key
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClientProxy:
|
|
30
|
+
def __init__(self, options: ClientOptions, transport: ClientTransport | None = None):
|
|
31
|
+
self.options = options
|
|
32
|
+
self.transport = transport or build_client_transport(options)
|
|
33
|
+
|
|
34
|
+
async def connect(self) -> None:
|
|
35
|
+
await self.transport.connect()
|
|
36
|
+
|
|
37
|
+
async def send(self, pattern: Any, data: Any = None, *, timeout: float | None = None) -> Any:
|
|
38
|
+
"""Request-response: returns the handler's result, raises ``RpcError`` if it failed or
|
|
39
|
+
``asyncio.TimeoutError`` if nothing answered in ``timeout`` seconds (default: the client's)."""
|
|
40
|
+
wait = timeout if timeout is not None else self.options.timeout
|
|
41
|
+
return await asyncio.wait_for(self.transport.request(pattern_key(pattern), data), wait)
|
|
42
|
+
|
|
43
|
+
async def emit(self, pattern: Any, data: Any = None) -> None:
|
|
44
|
+
"""Fire-and-forget event."""
|
|
45
|
+
await self.transport.emit(pattern_key(pattern), data)
|
|
46
|
+
|
|
47
|
+
def get_service(self, name: str) -> Any:
|
|
48
|
+
"""Protobuf gRPC only: a ``.proto`` service client, e.g. ``get_service("HeroService").find_one({})``."""
|
|
49
|
+
getter = getattr(self.transport, "get_service", None)
|
|
50
|
+
if getter is None:
|
|
51
|
+
raise TypeError("get_service() needs Transport.GRPC with extra={'proto_path': ...}")
|
|
52
|
+
return getter(name)
|
|
53
|
+
|
|
54
|
+
async def close(self) -> None:
|
|
55
|
+
await self.transport.close()
|
|
56
|
+
|
|
57
|
+
# lifecycle: connect when the app initialises, close when it shuts down
|
|
58
|
+
async def on_module_init(self) -> None:
|
|
59
|
+
await self.connect()
|
|
60
|
+
|
|
61
|
+
async def on_module_destroy(self) -> None:
|
|
62
|
+
await self.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@Module()
|
|
66
|
+
class ClientsModule:
|
|
67
|
+
@staticmethod
|
|
68
|
+
def register(clients: Sequence[ClientOptions]) -> DynamicModule:
|
|
69
|
+
"""Registers one :class:`ClientProxy` per entry, injectable as ``Inject(options.name)``."""
|
|
70
|
+
providers = [{"provide": c.name, "useValue": ClientProxy(c)} for c in clients]
|
|
71
|
+
return DynamicModule(module=ClientsModule, providers=providers, exports=[c.name for c in clients])
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``@MessagePattern``, ``@EventPattern``, ``@Payload`` and ``@Ctx`` -- mirror ``@nestjs/microservices``.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
@Controller()
|
|
6
|
+
class MathController:
|
|
7
|
+
@MessagePattern({"cmd": "sum"}) # request-response: the return value is the reply
|
|
8
|
+
def accumulate(self, numbers: list[int] = Payload()) -> int:
|
|
9
|
+
return sum(numbers)
|
|
10
|
+
|
|
11
|
+
@EventPattern("user.created") # fire-and-forget: nothing is returned to the sender
|
|
12
|
+
async def on_user_created(self, user: dict = Payload()): ...
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from austial.core.metadata import get_metadata, set_metadata
|
|
21
|
+
|
|
22
|
+
MESSAGE_PATTERN_METADATA = "austial:rpc:message_pattern"
|
|
23
|
+
EVENT_PATTERN_METADATA = "austial:rpc:event_pattern"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def MessagePattern(pattern: Any) -> Callable: # noqa: N802
|
|
27
|
+
return set_metadata(MESSAGE_PATTERN_METADATA, pattern)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def EventPattern(pattern: Any) -> Callable: # noqa: N802
|
|
31
|
+
def decorator(fn):
|
|
32
|
+
existing = list(get_metadata(EVENT_PATTERN_METADATA, fn, []))
|
|
33
|
+
set_metadata(EVENT_PATTERN_METADATA, [*existing, pattern])(fn)
|
|
34
|
+
return fn
|
|
35
|
+
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PayloadMarker:
|
|
40
|
+
def __init__(self, key: str | None):
|
|
41
|
+
self.key = key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CtxMarker:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def Payload(key: str | None = None) -> Any: # noqa: N802
|
|
49
|
+
"""The message data (or, with ``key``, one field of it)."""
|
|
50
|
+
return PayloadMarker(key)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def Ctx() -> Any: # noqa: N802
|
|
54
|
+
"""An :class:`RpcContext` with the pattern that matched."""
|
|
55
|
+
return CtxMarker()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Turns ``MicroserviceOptions`` / ``ClientOptions`` into the right transport object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from austial.microservices.transport import (
|
|
8
|
+
ClientOptions,
|
|
9
|
+
ClientTransport,
|
|
10
|
+
LocalClient,
|
|
11
|
+
LocalServer,
|
|
12
|
+
MicroserviceOptions,
|
|
13
|
+
ServerTransport,
|
|
14
|
+
TcpClient,
|
|
15
|
+
TcpServer,
|
|
16
|
+
Transport,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _split_host_port(url: str | None, default_host: str, default_port: int) -> tuple[str, int]:
|
|
21
|
+
if not url:
|
|
22
|
+
return default_host, default_port
|
|
23
|
+
host, _, port = url.removeprefix("mqtt://").rpartition(":")
|
|
24
|
+
return (host or url, int(port)) if port.isdigit() else (url, default_port)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _backend(transport: Transport, url: str | None, group: str, extra: dict[str, Any]) -> Any:
|
|
28
|
+
from austial.microservices.transports import (
|
|
29
|
+
KafkaBackend,
|
|
30
|
+
MqttBackend,
|
|
31
|
+
NatsBackend,
|
|
32
|
+
RabbitMqBackend,
|
|
33
|
+
RedisBackend,
|
|
34
|
+
RedisStreamsBackend,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if transport == Transport.REDIS:
|
|
38
|
+
return RedisBackend(url or "redis://localhost:6379", **extra)
|
|
39
|
+
if transport == Transport.REDIS_STREAMS:
|
|
40
|
+
return RedisStreamsBackend(url or "redis://localhost:6379", **extra)
|
|
41
|
+
if transport == Transport.NATS:
|
|
42
|
+
return NatsBackend(url or "nats://localhost:4222", **extra)
|
|
43
|
+
if transport == Transport.KAFKA:
|
|
44
|
+
return KafkaBackend(url or "localhost:9092", group=group, **extra)
|
|
45
|
+
if transport == Transport.MQTT:
|
|
46
|
+
host, port = _split_host_port(url, "localhost", 1883)
|
|
47
|
+
return MqttBackend(host, port, **extra)
|
|
48
|
+
if transport == Transport.RMQ:
|
|
49
|
+
return RabbitMqBackend(url or "amqp://guest:guest@localhost/", **extra)
|
|
50
|
+
raise ValueError(f"Not a broker transport: {transport}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_BROKERS = {Transport.REDIS, Transport.REDIS_STREAMS, Transport.NATS, Transport.KAFKA, Transport.MQTT, Transport.RMQ}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_server_transport(options: MicroserviceOptions) -> ServerTransport:
|
|
57
|
+
if options.transport == Transport.TCP:
|
|
58
|
+
return TcpServer(options.host, options.port)
|
|
59
|
+
if options.transport == Transport.LOCAL:
|
|
60
|
+
return LocalServer(options.name)
|
|
61
|
+
if options.transport in _BROKERS:
|
|
62
|
+
from austial.microservices.transports import PubSubServer
|
|
63
|
+
|
|
64
|
+
# NATS, MQTT, Kafka and Redis Streams share load through the group; RabbitMQ shares by queue
|
|
65
|
+
grouped = options.transport in (Transport.NATS, Transport.MQTT, Transport.REDIS_STREAMS, Transport.KAFKA)
|
|
66
|
+
return PubSubServer(
|
|
67
|
+
_backend(options.transport, options.url, options.group, options.extra),
|
|
68
|
+
prefix=options.prefix,
|
|
69
|
+
group=options.group if grouped else None,
|
|
70
|
+
codec=options.codec,
|
|
71
|
+
)
|
|
72
|
+
if options.transport == Transport.GRPC:
|
|
73
|
+
if "proto_path" in options.extra: # a .proto file: real protobuf services
|
|
74
|
+
from austial.microservices.transports.grpc_proto import GrpcProtoServer
|
|
75
|
+
|
|
76
|
+
return GrpcProtoServer(options.host, options.port, **options.extra)
|
|
77
|
+
from austial.microservices.transports import GrpcServer
|
|
78
|
+
|
|
79
|
+
return GrpcServer(options.host, options.port, **options.extra)
|
|
80
|
+
if options.custom_server is None:
|
|
81
|
+
raise ValueError("Transport.CUSTOM needs custom_server=")
|
|
82
|
+
return options.custom_server
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_client_transport(options: ClientOptions) -> ClientTransport:
|
|
86
|
+
if options.transport == Transport.TCP:
|
|
87
|
+
return TcpClient(options.host, options.port, options.timeout)
|
|
88
|
+
if options.transport == Transport.LOCAL:
|
|
89
|
+
return LocalClient(options.broker)
|
|
90
|
+
if options.transport in _BROKERS:
|
|
91
|
+
from austial.microservices.transports import PubSubClient
|
|
92
|
+
|
|
93
|
+
return PubSubClient(
|
|
94
|
+
_backend(options.transport, options.url, "austial", options.extra),
|
|
95
|
+
prefix=options.prefix,
|
|
96
|
+
timeout=options.timeout,
|
|
97
|
+
codec=options.codec,
|
|
98
|
+
)
|
|
99
|
+
if options.transport == Transport.GRPC:
|
|
100
|
+
if "proto_path" in options.extra:
|
|
101
|
+
from austial.microservices.transports.grpc_proto import GrpcProtoClient
|
|
102
|
+
|
|
103
|
+
return GrpcProtoClient(options.host, options.port, timeout=options.timeout, **options.extra)
|
|
104
|
+
from austial.microservices.transports import GrpcClient
|
|
105
|
+
|
|
106
|
+
return GrpcClient(options.host, options.port, timeout=options.timeout, **options.extra)
|
|
107
|
+
if options.custom_client is None:
|
|
108
|
+
raise ValueError(f"Client '{options.name}': Transport.CUSTOM needs custom_client=")
|
|
109
|
+
return options.custom_client
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Standalone and hybrid microservices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from austial.core.application_context import AustialApplicationContext
|
|
8
|
+
from austial.core.container import Container
|
|
9
|
+
from austial.core.discovery import DiscoveryService
|
|
10
|
+
from austial.core.module_ref import ModuleRef
|
|
11
|
+
from austial.core.module_scanner import scan_module
|
|
12
|
+
from austial.microservices.factory import build_server_transport
|
|
13
|
+
from austial.microservices.server import HandlerRegistry, MicroserviceServer
|
|
14
|
+
from austial.microservices.transport import MicroserviceOptions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_server(container: Container, options: MicroserviceOptions) -> MicroserviceServer:
|
|
18
|
+
registry = HandlerRegistry(container.resolve(DiscoveryService), container.resolve(ModuleRef))
|
|
19
|
+
return MicroserviceServer(build_server_transport(options), registry)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MicroserviceApplication(AustialApplicationContext):
|
|
23
|
+
"""What ``create_microservice`` returns: a DI context plus a transport server.
|
|
24
|
+
|
|
25
|
+
``await ms.listen()`` initialises the app (hooks, async providers) and starts the transport;
|
|
26
|
+
``await ms.close()`` stops it and runs the shutdown hooks.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, container: Container, options: MicroserviceOptions):
|
|
30
|
+
super().__init__(container)
|
|
31
|
+
self.options = options
|
|
32
|
+
self.server = build_server(container, options)
|
|
33
|
+
|
|
34
|
+
async def listen(self) -> None:
|
|
35
|
+
await self.init()
|
|
36
|
+
await self.server.start()
|
|
37
|
+
|
|
38
|
+
async def close(self, signal: str | None = None) -> None:
|
|
39
|
+
await self.server.stop()
|
|
40
|
+
await super().close(signal)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_microservice(root_module: type, options: MicroserviceOptions) -> MicroserviceApplication:
|
|
44
|
+
"""Mirrors ``NestFactory.createMicroservice()``: a message-driven app with no HTTP server."""
|
|
45
|
+
container = Container()
|
|
46
|
+
scan_module(root_module, container)
|
|
47
|
+
return MicroserviceApplication(container, options)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def attach_microservice(app: Any, options: MicroserviceOptions) -> MicroserviceServer:
|
|
51
|
+
"""Hybrid app: serve ``options`` from the same application as HTTP. Used by ``app.connect_microservice()``."""
|
|
52
|
+
return build_server(app._container, options) # noqa: SLF001
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Dispatches transport messages to ``@MessagePattern`` / ``@EventPattern`` handlers found on providers and
|
|
2
|
+
controllers, through guards, pipes and interceptors."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from austial.common.guards.base import ExecutionContext
|
|
11
|
+
from austial.common.interceptors.base import CallHandler
|
|
12
|
+
from austial.common.logger import Logger
|
|
13
|
+
from austial.common.pipes.validation_pipe import ArgumentMetadata
|
|
14
|
+
from austial.core.discovery import DiscoveryService
|
|
15
|
+
from austial.core.metadata import GUARDS_METADATA, INTERCEPTORS_METADATA, PIPES_METADATA, get_metadata
|
|
16
|
+
from austial.core.module_ref import ModuleRef
|
|
17
|
+
from austial.microservices.decorators import (
|
|
18
|
+
EVENT_PATTERN_METADATA,
|
|
19
|
+
MESSAGE_PATTERN_METADATA,
|
|
20
|
+
CtxMarker,
|
|
21
|
+
PayloadMarker,
|
|
22
|
+
)
|
|
23
|
+
from austial.microservices.transport import RpcException, ServerTransport, pattern_key
|
|
24
|
+
|
|
25
|
+
_logger = Logger("MicroserviceServer")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RpcContext:
|
|
30
|
+
pattern: str
|
|
31
|
+
kind: str # "request" | "event"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RpcExecutionContext(ExecutionContext):
|
|
35
|
+
"""``get_type()`` is ``"rpc"``; ``switch_to_rpc()`` gives the data and the :class:`RpcContext`."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, data: Any, rpc: RpcContext, controller_class: type, handler: Any):
|
|
38
|
+
super().__init__(None, controller_class, handler) # type: ignore[arg-type]
|
|
39
|
+
self._data, self._rpc = data, rpc
|
|
40
|
+
|
|
41
|
+
def get_type(self) -> str:
|
|
42
|
+
return "rpc"
|
|
43
|
+
|
|
44
|
+
def switch_to_rpc(self) -> RpcExecutionContext:
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def get_data(self) -> Any:
|
|
48
|
+
return self._data
|
|
49
|
+
|
|
50
|
+
def get_context(self) -> RpcContext:
|
|
51
|
+
return self._rpc
|
|
52
|
+
|
|
53
|
+
def switch_to_http(self) -> ExecutionContext:
|
|
54
|
+
raise NotImplementedError("This is an RPC execution context: use switch_to_rpc().")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _maybe(value: Any) -> Any:
|
|
58
|
+
return await value if inspect.isawaitable(value) else value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HandlerRegistry:
|
|
62
|
+
"""All the handlers of one application, discovered from its providers and controllers."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, discovery: DiscoveryService, module_ref: ModuleRef):
|
|
65
|
+
self.discovery, self.module_ref = discovery, module_ref
|
|
66
|
+
self._messages: dict[str, tuple[Any, Any]] = {}
|
|
67
|
+
self._events: dict[str, list[tuple[Any, Any]]] = {}
|
|
68
|
+
self._built = False
|
|
69
|
+
|
|
70
|
+
def build(self) -> None:
|
|
71
|
+
if self._built:
|
|
72
|
+
return
|
|
73
|
+
self._built = True
|
|
74
|
+
for controllers in (False, True):
|
|
75
|
+
for wrapper, name, pattern in self.discovery.get_methods_with_metadata(
|
|
76
|
+
MESSAGE_PATTERN_METADATA, controllers=controllers
|
|
77
|
+
):
|
|
78
|
+
key = pattern_key(pattern)
|
|
79
|
+
if key in self._messages:
|
|
80
|
+
raise RuntimeError(f"Two handlers for the message pattern {key}")
|
|
81
|
+
self._messages[key] = (wrapper.instance, getattr(wrapper.instance, name))
|
|
82
|
+
for wrapper, name, patterns in self.discovery.get_methods_with_metadata(
|
|
83
|
+
EVENT_PATTERN_METADATA, controllers=controllers
|
|
84
|
+
):
|
|
85
|
+
for pattern in patterns:
|
|
86
|
+
self._events.setdefault(pattern_key(pattern), []).append(
|
|
87
|
+
(wrapper.instance, getattr(wrapper.instance, name))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def patterns(self) -> dict[str, list[str]]:
|
|
91
|
+
"""The pattern keys this application handles, for transports that must subscribe to topics up front."""
|
|
92
|
+
self.build()
|
|
93
|
+
return {"request": list(self._messages), "event": list(self._events)}
|
|
94
|
+
|
|
95
|
+
async def handle(self, kind: str, pattern: str, data: Any) -> Any:
|
|
96
|
+
self.build()
|
|
97
|
+
context = RpcContext(pattern, kind)
|
|
98
|
+
if kind == "event":
|
|
99
|
+
for owner, handler in self._events.get(pattern, []):
|
|
100
|
+
try:
|
|
101
|
+
await self._run(owner, handler, data, context)
|
|
102
|
+
except Exception as exc: # noqa: BLE001
|
|
103
|
+
_logger.error(f"Event handler for {pattern} raised {type(exc).__name__}: {exc}")
|
|
104
|
+
return None
|
|
105
|
+
entry = self._messages.get(pattern)
|
|
106
|
+
if entry is None:
|
|
107
|
+
raise RpcException(f"There is no matching message handler for {pattern}")
|
|
108
|
+
owner, handler = entry
|
|
109
|
+
return await self._run(owner, handler, data, context)
|
|
110
|
+
|
|
111
|
+
def _components(self, key: str, owner: Any, handler: Any) -> list[Any]:
|
|
112
|
+
raw = [*get_metadata(key, type(owner), []), *get_metadata(key, handler, [])]
|
|
113
|
+
return [self.module_ref.get(c) if isinstance(c, type) else c for c in raw]
|
|
114
|
+
|
|
115
|
+
async def _run(self, owner: Any, handler: Any, data: Any, rpc: RpcContext) -> Any:
|
|
116
|
+
context = RpcExecutionContext(data, rpc, type(owner), handler)
|
|
117
|
+
for guard in self._components(GUARDS_METADATA, owner, handler):
|
|
118
|
+
if not await _maybe(guard.can_activate(context)):
|
|
119
|
+
raise RpcException("Forbidden resource")
|
|
120
|
+
pipes = self._components(PIPES_METADATA, owner, handler)
|
|
121
|
+
kwargs: dict[str, Any] = {}
|
|
122
|
+
data_used = False
|
|
123
|
+
for name, param in inspect.signature(handler, eval_str=True).parameters.items():
|
|
124
|
+
default = param.default
|
|
125
|
+
if isinstance(default, CtxMarker):
|
|
126
|
+
kwargs[name] = rpc
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(default, PayloadMarker):
|
|
129
|
+
value = data.get(default.key) if default.key and isinstance(data, dict) else data
|
|
130
|
+
elif not data_used and default is inspect.Parameter.empty:
|
|
131
|
+
value = data
|
|
132
|
+
else:
|
|
133
|
+
continue
|
|
134
|
+
data_used = True
|
|
135
|
+
for pipe in pipes:
|
|
136
|
+
value = await _maybe(pipe.transform(value, ArgumentMetadata(type="body", data=name, metatype=None)))
|
|
137
|
+
kwargs[name] = value
|
|
138
|
+
|
|
139
|
+
async def call() -> Any:
|
|
140
|
+
return await _maybe(handler(**kwargs))
|
|
141
|
+
|
|
142
|
+
chain = call
|
|
143
|
+
for interceptor in reversed(self._components(INTERCEPTORS_METADATA, owner, handler)):
|
|
144
|
+
chain = _wrap(interceptor, context, chain)
|
|
145
|
+
return await chain()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _wrap(interceptor: Any, context: RpcExecutionContext, next_call: Any):
|
|
149
|
+
async def wrapped() -> Any:
|
|
150
|
+
return await interceptor.intercept(context, CallHandler(next_call))
|
|
151
|
+
|
|
152
|
+
return wrapped
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class MicroserviceServer:
|
|
156
|
+
"""A transport plus the application's handlers. ``await start()`` / ``await stop()``."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, transport: ServerTransport, registry: HandlerRegistry):
|
|
159
|
+
self.transport, self.registry = transport, registry
|
|
160
|
+
self.running = False
|
|
161
|
+
|
|
162
|
+
async def start(self) -> None:
|
|
163
|
+
if self.running:
|
|
164
|
+
return
|
|
165
|
+
self.transport.patterns = self.registry.patterns()
|
|
166
|
+
await self.transport.listen(self.registry.handle)
|
|
167
|
+
self.running = True
|
|
168
|
+
|
|
169
|
+
async def stop(self) -> None:
|
|
170
|
+
if self.running:
|
|
171
|
+
await self.transport.close()
|
|
172
|
+
self.running = False
|