eventable 0.1.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.
- eventable-0.1.0/PKG-INFO +281 -0
- eventable-0.1.0/README.md +267 -0
- eventable-0.1.0/pyproject.toml +49 -0
- eventable-0.1.0/src/eventable/__init__.py +41 -0
- eventable-0.1.0/src/eventable/aggregate_root.py +16 -0
- eventable-0.1.0/src/eventable/domain_event.py +61 -0
- eventable-0.1.0/src/eventable/event_handlers.py +8 -0
- eventable-0.1.0/src/eventable/event_managment/event_collector.py +16 -0
- eventable-0.1.0/src/eventable/event_managment/event_dispatcher.py +38 -0
- eventable-0.1.0/src/eventable/event_managment/event_publisher.py +17 -0
- eventable-0.1.0/src/eventable/event_managment/event_subscriber.py +19 -0
- eventable-0.1.0/src/eventable/infrastructure/fastapi/eventable.py +41 -0
- eventable-0.1.0/src/eventable/infrastructure/fastapi/middleware.py +55 -0
- eventable-0.1.0/src/eventable/infrastructure/rabbitmq/event_publisher.py +70 -0
- eventable-0.1.0/src/eventable/infrastructure/rabbitmq/event_subscriber.py +109 -0
- eventable-0.1.0/src/eventable/py.typed +0 -0
- eventable-0.1.0/src/eventable/value_object.py +10 -0
eventable-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: eventable
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Domain Event Message Infrastructure
|
|
5
|
+
Author: Ioannis-Andreas Philippas
|
|
6
|
+
Author-email: Ioannis-Andreas Philippas <ioannis.philippas@entaxilabs.gr>
|
|
7
|
+
Requires-Dist: fastapi>=0.135.3
|
|
8
|
+
Requires-Dist: pika>=1.3.2
|
|
9
|
+
Requires-Dist: pydantic>=2.12.5
|
|
10
|
+
Requires-Dist: ruff>=0.15.9
|
|
11
|
+
Requires-Dist: starlette>=1.0.0
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# eventable
|
|
16
|
+
|
|
17
|
+
Domain event infrastructure for Python microservices.
|
|
18
|
+
|
|
19
|
+
Provides the building blocks to implement **domain events** following Domain-Driven Design: aggregate roots that collect events, an in-process dispatcher, and a RabbitMQ transport — with an optional plug-and-play FastAPI integration.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- Frozen, serializable `DomainEvent` dataclasses
|
|
26
|
+
- `AggregateRoot` (Pydantic) that accumulates and releases domain events
|
|
27
|
+
- `EventDispatcher` — runs in-process handlers first, then publishes to the broker
|
|
28
|
+
- RabbitMQ publisher & subscriber backed by a durable topic exchange
|
|
29
|
+
- **FastAPI plugin**: one-line lifespan setup + pure-ASGI middleware that dispatches events after every successful request without swallowing exceptions
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install eventable
|
|
37
|
+
# or with uv
|
|
38
|
+
uv add eventable
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Requirements:** Python 3.12+, a running RabbitMQ broker (for the infrastructure layer).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Core concepts
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
Request
|
|
49
|
+
└─ AggregateRoot.add_domain_event(event) # collect during business logic
|
|
50
|
+
└─ EventCollector.collect_events() # pull after request completes
|
|
51
|
+
└─ EventDispatcher.dispatch(event)
|
|
52
|
+
├─ in-process handlers # same bounded context
|
|
53
|
+
└─ RabbitMQPublisher # cross-context via broker
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
### 1. Define a domain event
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from uuid import UUID
|
|
65
|
+
from eventable.domain_event import DomainEvent
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class OrderPlaced(DomainEvent):
|
|
69
|
+
order_id: UUID
|
|
70
|
+
total: str
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 2. Define an aggregate root
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from uuid import uuid4
|
|
77
|
+
from datetime import datetime
|
|
78
|
+
from eventable.aggregate_root import AggregateRoot
|
|
79
|
+
from eventable.domain_event import generate_event_id
|
|
80
|
+
|
|
81
|
+
class Order(AggregateRoot):
|
|
82
|
+
order_id: UUID
|
|
83
|
+
total: str
|
|
84
|
+
|
|
85
|
+
def place(self) -> None:
|
|
86
|
+
self.add_domain_event(
|
|
87
|
+
OrderPlaced(
|
|
88
|
+
event_id=generate_event_id(),
|
|
89
|
+
occurred_at=datetime.now(),
|
|
90
|
+
order_id=self.order_id,
|
|
91
|
+
total=self.total,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 3. Register in-process handlers
|
|
97
|
+
|
|
98
|
+
Edit `event_handlers.py` (generated alongside your app):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from eventable.event_managment.event_dispatcher import EventDispatcher
|
|
102
|
+
from myapp.events import OrderPlaced
|
|
103
|
+
|
|
104
|
+
def handle_order_placed(event: OrderPlaced) -> None:
|
|
105
|
+
print(f"Order {event.order_id} placed — sending confirmation email")
|
|
106
|
+
|
|
107
|
+
def register_handlers(dispatcher: EventDispatcher) -> None:
|
|
108
|
+
dispatcher.register(OrderPlaced, handle_order_placed)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## FastAPI integration
|
|
114
|
+
|
|
115
|
+
### Lifespan setup
|
|
116
|
+
|
|
117
|
+
`Eventable` connects to RabbitMQ on startup, registers your in-process handlers, and disconnects cleanly on shutdown. Wire it into FastAPI's async lifespan:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from contextlib import asynccontextmanager
|
|
121
|
+
from fastapi import FastAPI
|
|
122
|
+
from eventable.infrastructure.fastapi.eventable import Eventable, EventableSettings
|
|
123
|
+
from eventable.infrastructure.fastapi.middleware import DomainEventMiddleware
|
|
124
|
+
|
|
125
|
+
settings = EventableSettings(
|
|
126
|
+
rabbit_url="amqp://guest:guest@localhost/",
|
|
127
|
+
exchange_name="domain_events", # default
|
|
128
|
+
)
|
|
129
|
+
eventable = Eventable(settings)
|
|
130
|
+
|
|
131
|
+
@asynccontextmanager
|
|
132
|
+
async def lifespan(app: FastAPI):
|
|
133
|
+
await eventable.startup(app) # connects publisher, wires dispatcher
|
|
134
|
+
yield
|
|
135
|
+
await eventable.shutdown() # closes RabbitMQ connection
|
|
136
|
+
|
|
137
|
+
app = FastAPI(lifespan=lifespan)
|
|
138
|
+
app.add_middleware(DomainEventMiddleware)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
After startup, `app.state.publisher` and `app.state.dispatcher` are available throughout the application.
|
|
142
|
+
|
|
143
|
+
### Middleware
|
|
144
|
+
|
|
145
|
+
`DomainEventMiddleware` is a **pure ASGI middleware** (not `BaseHTTPMiddleware`) so exceptions from your route handlers propagate cleanly to FastAPI's exception handlers — nothing is swallowed.
|
|
146
|
+
|
|
147
|
+
Per-request lifecycle:
|
|
148
|
+
|
|
149
|
+
1. Attaches a fresh `EventCollector` to `request.state.collector`.
|
|
150
|
+
2. Runs the route handler normally.
|
|
151
|
+
3. After the response is sent, if the status code is **< 400**, pulls all collected events and dispatches them.
|
|
152
|
+
4. On 4xx/5xx the collector is discarded — no events are dispatched for failed requests.
|
|
153
|
+
|
|
154
|
+
### Collecting events in a route
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from fastapi import Request
|
|
158
|
+
from eventable.event_managment.event_collector import EventCollector
|
|
159
|
+
|
|
160
|
+
@app.post("/orders")
|
|
161
|
+
def place_order(request: Request):
|
|
162
|
+
collector: EventCollector = request.state.collector
|
|
163
|
+
|
|
164
|
+
order = Order(order_id=uuid4(), total="99.99")
|
|
165
|
+
collector.track(order) # middleware will pull events from this aggregate
|
|
166
|
+
order.place()
|
|
167
|
+
|
|
168
|
+
return {"order_id": str(order.order_id)}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## RabbitMQ subscriber (cross-context consumer)
|
|
174
|
+
|
|
175
|
+
Run a long-lived consumer process in a separate service to handle events published by another bounded context:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from eventable import rabbit_subscription
|
|
179
|
+
from myapp.events import OrderPlaced
|
|
180
|
+
|
|
181
|
+
def handle_order_placed(event: OrderPlaced) -> None:
|
|
182
|
+
# react to the event in this bounded context
|
|
183
|
+
...
|
|
184
|
+
|
|
185
|
+
rabbit_subscription(
|
|
186
|
+
consumer_name="inventory-service",
|
|
187
|
+
rabbit_url="amqp://guest:guest@localhost/",
|
|
188
|
+
queue_name="inventory.order_placed",
|
|
189
|
+
handlers_map={
|
|
190
|
+
OrderPlaced: (handle_order_placed, {}),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`rabbit_subscription` blocks, handles `SIGTERM`/`SIGINT` for graceful shutdown, and nacks messages that fail processing (sending them to the dead-letter queue if configured).
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Event serialization
|
|
200
|
+
|
|
201
|
+
Events are serialized to JSON automatically. The following field types are supported out of the box:
|
|
202
|
+
|
|
203
|
+
| Python type | JSON representation |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `UUID` | `string` |
|
|
206
|
+
| `datetime` | ISO 8601 string |
|
|
207
|
+
| `Decimal` | `string` |
|
|
208
|
+
| `ValueObject` | `get_value()` result |
|
|
209
|
+
| `dict`, `list` | passed through recursively |
|
|
210
|
+
|
|
211
|
+
### ValueObject
|
|
212
|
+
|
|
213
|
+
Implement the `ValueObject` protocol to have your value objects serialize transparently:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from eventable.value_object import ValueObject
|
|
217
|
+
|
|
218
|
+
class Money:
|
|
219
|
+
def __init__(self, amount: str):
|
|
220
|
+
self.value = amount
|
|
221
|
+
|
|
222
|
+
def get_value(self) -> str:
|
|
223
|
+
return self.value
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Architecture overview
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
eventable/
|
|
232
|
+
├── domain_event.py # DomainEvent base dataclass + serialize/deserialize
|
|
233
|
+
├── aggregate_root.py # AggregateRoot (Pydantic BaseModel)
|
|
234
|
+
├── value_object.py # ValueObject protocol
|
|
235
|
+
├── event_handlers.py # register_handlers() — your in-process handler hook
|
|
236
|
+
│
|
|
237
|
+
├── event_managment/
|
|
238
|
+
│ ├── event_collector.py # Tracks aggregates, pulls events after request
|
|
239
|
+
│ ├── event_dispatcher.py # Runs handlers then publishes to broker
|
|
240
|
+
│ ├── event_publisher.py # Abstract EventPublisher
|
|
241
|
+
│ └── event_subscriber.py # Abstract EventSubscriber
|
|
242
|
+
│
|
|
243
|
+
└── infrastructure/
|
|
244
|
+
├── rabbitmq/
|
|
245
|
+
│ ├── event_publisher.py # RabbitMQPublisher (thread-local channels)
|
|
246
|
+
│ └── event_subscriber.py # RabbitMQSubscriber (blocking consumer)
|
|
247
|
+
└── fastapi/
|
|
248
|
+
├── eventable.py # Eventable + EventableSettings (lifespan plugin)
|
|
249
|
+
└── middleware.py # DomainEventMiddleware (pure ASGI)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**RabbitMQ topology:** a single durable **topic exchange** (`domain_events` by default). Each event type is routed by its class name as the routing key. Consumers bind a durable queue to the exchange for the routing keys they care about.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Configuration reference
|
|
257
|
+
|
|
258
|
+
### `EventableSettings`
|
|
259
|
+
|
|
260
|
+
| Field | Type | Default | Description |
|
|
261
|
+
|---|---|---|---|
|
|
262
|
+
| `rabbit_url` | `str` | — | AMQP connection URL |
|
|
263
|
+
| `exchange_name` | `str` | `"domain_events"` | Topic exchange name |
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Running the tests
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
uv run pytest tests/unit/ # pure unit tests, no broker needed
|
|
271
|
+
uv run pytest tests/infrastructure/ -k "not rabbitmq" # FastAPI plugin tests only
|
|
272
|
+
|
|
273
|
+
# Full suite including RabbitMQ integration tests:
|
|
274
|
+
RABBITMQ_URL=amqp://admin:admin@localhost:5672/ uv run pytest
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
MIT
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# eventable
|
|
2
|
+
|
|
3
|
+
Domain event infrastructure for Python microservices.
|
|
4
|
+
|
|
5
|
+
Provides the building blocks to implement **domain events** following Domain-Driven Design: aggregate roots that collect events, an in-process dispatcher, and a RabbitMQ transport — with an optional plug-and-play FastAPI integration.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Frozen, serializable `DomainEvent` dataclasses
|
|
12
|
+
- `AggregateRoot` (Pydantic) that accumulates and releases domain events
|
|
13
|
+
- `EventDispatcher` — runs in-process handlers first, then publishes to the broker
|
|
14
|
+
- RabbitMQ publisher & subscriber backed by a durable topic exchange
|
|
15
|
+
- **FastAPI plugin**: one-line lifespan setup + pure-ASGI middleware that dispatches events after every successful request without swallowing exceptions
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install eventable
|
|
23
|
+
# or with uv
|
|
24
|
+
uv add eventable
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Requirements:** Python 3.12+, a running RabbitMQ broker (for the infrastructure layer).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Core concepts
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Request
|
|
35
|
+
└─ AggregateRoot.add_domain_event(event) # collect during business logic
|
|
36
|
+
└─ EventCollector.collect_events() # pull after request completes
|
|
37
|
+
└─ EventDispatcher.dispatch(event)
|
|
38
|
+
├─ in-process handlers # same bounded context
|
|
39
|
+
└─ RabbitMQPublisher # cross-context via broker
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
### 1. Define a domain event
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from uuid import UUID
|
|
51
|
+
from eventable.domain_event import DomainEvent
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class OrderPlaced(DomainEvent):
|
|
55
|
+
order_id: UUID
|
|
56
|
+
total: str
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Define an aggregate root
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from uuid import uuid4
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
from eventable.aggregate_root import AggregateRoot
|
|
65
|
+
from eventable.domain_event import generate_event_id
|
|
66
|
+
|
|
67
|
+
class Order(AggregateRoot):
|
|
68
|
+
order_id: UUID
|
|
69
|
+
total: str
|
|
70
|
+
|
|
71
|
+
def place(self) -> None:
|
|
72
|
+
self.add_domain_event(
|
|
73
|
+
OrderPlaced(
|
|
74
|
+
event_id=generate_event_id(),
|
|
75
|
+
occurred_at=datetime.now(),
|
|
76
|
+
order_id=self.order_id,
|
|
77
|
+
total=self.total,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Register in-process handlers
|
|
83
|
+
|
|
84
|
+
Edit `event_handlers.py` (generated alongside your app):
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from eventable.event_managment.event_dispatcher import EventDispatcher
|
|
88
|
+
from myapp.events import OrderPlaced
|
|
89
|
+
|
|
90
|
+
def handle_order_placed(event: OrderPlaced) -> None:
|
|
91
|
+
print(f"Order {event.order_id} placed — sending confirmation email")
|
|
92
|
+
|
|
93
|
+
def register_handlers(dispatcher: EventDispatcher) -> None:
|
|
94
|
+
dispatcher.register(OrderPlaced, handle_order_placed)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## FastAPI integration
|
|
100
|
+
|
|
101
|
+
### Lifespan setup
|
|
102
|
+
|
|
103
|
+
`Eventable` connects to RabbitMQ on startup, registers your in-process handlers, and disconnects cleanly on shutdown. Wire it into FastAPI's async lifespan:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from contextlib import asynccontextmanager
|
|
107
|
+
from fastapi import FastAPI
|
|
108
|
+
from eventable.infrastructure.fastapi.eventable import Eventable, EventableSettings
|
|
109
|
+
from eventable.infrastructure.fastapi.middleware import DomainEventMiddleware
|
|
110
|
+
|
|
111
|
+
settings = EventableSettings(
|
|
112
|
+
rabbit_url="amqp://guest:guest@localhost/",
|
|
113
|
+
exchange_name="domain_events", # default
|
|
114
|
+
)
|
|
115
|
+
eventable = Eventable(settings)
|
|
116
|
+
|
|
117
|
+
@asynccontextmanager
|
|
118
|
+
async def lifespan(app: FastAPI):
|
|
119
|
+
await eventable.startup(app) # connects publisher, wires dispatcher
|
|
120
|
+
yield
|
|
121
|
+
await eventable.shutdown() # closes RabbitMQ connection
|
|
122
|
+
|
|
123
|
+
app = FastAPI(lifespan=lifespan)
|
|
124
|
+
app.add_middleware(DomainEventMiddleware)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
After startup, `app.state.publisher` and `app.state.dispatcher` are available throughout the application.
|
|
128
|
+
|
|
129
|
+
### Middleware
|
|
130
|
+
|
|
131
|
+
`DomainEventMiddleware` is a **pure ASGI middleware** (not `BaseHTTPMiddleware`) so exceptions from your route handlers propagate cleanly to FastAPI's exception handlers — nothing is swallowed.
|
|
132
|
+
|
|
133
|
+
Per-request lifecycle:
|
|
134
|
+
|
|
135
|
+
1. Attaches a fresh `EventCollector` to `request.state.collector`.
|
|
136
|
+
2. Runs the route handler normally.
|
|
137
|
+
3. After the response is sent, if the status code is **< 400**, pulls all collected events and dispatches them.
|
|
138
|
+
4. On 4xx/5xx the collector is discarded — no events are dispatched for failed requests.
|
|
139
|
+
|
|
140
|
+
### Collecting events in a route
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from fastapi import Request
|
|
144
|
+
from eventable.event_managment.event_collector import EventCollector
|
|
145
|
+
|
|
146
|
+
@app.post("/orders")
|
|
147
|
+
def place_order(request: Request):
|
|
148
|
+
collector: EventCollector = request.state.collector
|
|
149
|
+
|
|
150
|
+
order = Order(order_id=uuid4(), total="99.99")
|
|
151
|
+
collector.track(order) # middleware will pull events from this aggregate
|
|
152
|
+
order.place()
|
|
153
|
+
|
|
154
|
+
return {"order_id": str(order.order_id)}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## RabbitMQ subscriber (cross-context consumer)
|
|
160
|
+
|
|
161
|
+
Run a long-lived consumer process in a separate service to handle events published by another bounded context:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from eventable import rabbit_subscription
|
|
165
|
+
from myapp.events import OrderPlaced
|
|
166
|
+
|
|
167
|
+
def handle_order_placed(event: OrderPlaced) -> None:
|
|
168
|
+
# react to the event in this bounded context
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
rabbit_subscription(
|
|
172
|
+
consumer_name="inventory-service",
|
|
173
|
+
rabbit_url="amqp://guest:guest@localhost/",
|
|
174
|
+
queue_name="inventory.order_placed",
|
|
175
|
+
handlers_map={
|
|
176
|
+
OrderPlaced: (handle_order_placed, {}),
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`rabbit_subscription` blocks, handles `SIGTERM`/`SIGINT` for graceful shutdown, and nacks messages that fail processing (sending them to the dead-letter queue if configured).
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Event serialization
|
|
186
|
+
|
|
187
|
+
Events are serialized to JSON automatically. The following field types are supported out of the box:
|
|
188
|
+
|
|
189
|
+
| Python type | JSON representation |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `UUID` | `string` |
|
|
192
|
+
| `datetime` | ISO 8601 string |
|
|
193
|
+
| `Decimal` | `string` |
|
|
194
|
+
| `ValueObject` | `get_value()` result |
|
|
195
|
+
| `dict`, `list` | passed through recursively |
|
|
196
|
+
|
|
197
|
+
### ValueObject
|
|
198
|
+
|
|
199
|
+
Implement the `ValueObject` protocol to have your value objects serialize transparently:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from eventable.value_object import ValueObject
|
|
203
|
+
|
|
204
|
+
class Money:
|
|
205
|
+
def __init__(self, amount: str):
|
|
206
|
+
self.value = amount
|
|
207
|
+
|
|
208
|
+
def get_value(self) -> str:
|
|
209
|
+
return self.value
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Architecture overview
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
eventable/
|
|
218
|
+
├── domain_event.py # DomainEvent base dataclass + serialize/deserialize
|
|
219
|
+
├── aggregate_root.py # AggregateRoot (Pydantic BaseModel)
|
|
220
|
+
├── value_object.py # ValueObject protocol
|
|
221
|
+
├── event_handlers.py # register_handlers() — your in-process handler hook
|
|
222
|
+
│
|
|
223
|
+
├── event_managment/
|
|
224
|
+
│ ├── event_collector.py # Tracks aggregates, pulls events after request
|
|
225
|
+
│ ├── event_dispatcher.py # Runs handlers then publishes to broker
|
|
226
|
+
│ ├── event_publisher.py # Abstract EventPublisher
|
|
227
|
+
│ └── event_subscriber.py # Abstract EventSubscriber
|
|
228
|
+
│
|
|
229
|
+
└── infrastructure/
|
|
230
|
+
├── rabbitmq/
|
|
231
|
+
│ ├── event_publisher.py # RabbitMQPublisher (thread-local channels)
|
|
232
|
+
│ └── event_subscriber.py # RabbitMQSubscriber (blocking consumer)
|
|
233
|
+
└── fastapi/
|
|
234
|
+
├── eventable.py # Eventable + EventableSettings (lifespan plugin)
|
|
235
|
+
└── middleware.py # DomainEventMiddleware (pure ASGI)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**RabbitMQ topology:** a single durable **topic exchange** (`domain_events` by default). Each event type is routed by its class name as the routing key. Consumers bind a durable queue to the exchange for the routing keys they care about.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Configuration reference
|
|
243
|
+
|
|
244
|
+
### `EventableSettings`
|
|
245
|
+
|
|
246
|
+
| Field | Type | Default | Description |
|
|
247
|
+
|---|---|---|---|
|
|
248
|
+
| `rabbit_url` | `str` | — | AMQP connection URL |
|
|
249
|
+
| `exchange_name` | `str` | `"domain_events"` | Topic exchange name |
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Running the tests
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
uv run pytest tests/unit/ # pure unit tests, no broker needed
|
|
257
|
+
uv run pytest tests/infrastructure/ -k "not rabbitmq" # FastAPI plugin tests only
|
|
258
|
+
|
|
259
|
+
# Full suite including RabbitMQ integration tests:
|
|
260
|
+
RABBITMQ_URL=amqp://admin:admin@localhost:5672/ uv run pytest
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "eventable"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Domain Event Message Infrastructure"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Ioannis-Andreas Philippas", email = "ioannis.philippas@entaxilabs.gr" },
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.135.3",
|
|
12
|
+
"pika>=1.3.2",
|
|
13
|
+
"pydantic>=2.12.5",
|
|
14
|
+
"ruff>=0.15.9",
|
|
15
|
+
"starlette>=1.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
#[tool.pyright]
|
|
23
|
+
#include = ["src"]
|
|
24
|
+
#venvPath = "."
|
|
25
|
+
#venv = ".venv"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
# Ignore missing docstring warnings (D1xx rules)
|
|
30
|
+
# F401: imported but unused (common with pytest fixtures)
|
|
31
|
+
# F811: redefinition of unused (common with pytest fixtures)
|
|
32
|
+
lint.ignore = ["D1", "F841", "PT019", "F401", "F811"]
|
|
33
|
+
src = ["src"]
|
|
34
|
+
line-length = 110
|
|
35
|
+
indent-width = 4
|
|
36
|
+
exclude = [
|
|
37
|
+
".venv",
|
|
38
|
+
".vscode",
|
|
39
|
+
"__pypackages__",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"site-packages",
|
|
42
|
+
"venv",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pika-stubs>=0.1.3",
|
|
48
|
+
"pytest>=9.0.2",
|
|
49
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import signal
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from eventable.domain_event import DomainEvent
|
|
6
|
+
from eventable.infrastructure.rabbitmq.event_publisher import RabbitMQPublisher
|
|
7
|
+
from eventable.infrastructure.rabbitmq.event_subscriber import RabbitMQSubscriber
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rabbit_subscription(
|
|
14
|
+
consumer_name: str,
|
|
15
|
+
rabbit_url: str,
|
|
16
|
+
queue_name: str,
|
|
17
|
+
handlers_map: dict[type[DomainEvent], tuple[Callable, dict[str, Any]]],
|
|
18
|
+
) -> None:
|
|
19
|
+
publisher = RabbitMQPublisher(
|
|
20
|
+
url=rabbit_url,
|
|
21
|
+
)
|
|
22
|
+
# event_collector = EventCollector()
|
|
23
|
+
publisher.connect()
|
|
24
|
+
|
|
25
|
+
subscriber = RabbitMQSubscriber(
|
|
26
|
+
url=rabbit_url,
|
|
27
|
+
queue_name=queue_name, # unique per bounded context
|
|
28
|
+
publisher=publisher,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Register handlers for this service:
|
|
32
|
+
# subscriber.subscribe(OrderPlaced, handle_order_placed)
|
|
33
|
+
for event_name, (handler, kwargs) in handlers_map.items():
|
|
34
|
+
subscriber.subscribe(event_name, handler, **kwargs)
|
|
35
|
+
|
|
36
|
+
# Graceful shutdown
|
|
37
|
+
signal.signal(signal.SIGTERM, lambda s, f: subscriber.stop())
|
|
38
|
+
signal.signal(signal.SIGINT, lambda s, f: subscriber.stop())
|
|
39
|
+
|
|
40
|
+
logger.info(f"{consumer_name} Consumer starting...")
|
|
41
|
+
subscriber.start() #
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from eventable.domain_event import DomainEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AggregateRoot(BaseModel):
|
|
7
|
+
_domain_events: list[DomainEvent] = [] # private, excluded from serialization
|
|
8
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
9
|
+
|
|
10
|
+
def add_domain_event(self, event: DomainEvent):
|
|
11
|
+
self._domain_events.append(event)
|
|
12
|
+
|
|
13
|
+
def pull_domain_events(self) -> list[DomainEvent]:
|
|
14
|
+
events = self._domain_events.copy()
|
|
15
|
+
self._domain_events.clear()
|
|
16
|
+
return events
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass, fields, is_dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from typing import Any, Type
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from eventable.value_object import ValueObject
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DomainEvent:
|
|
12
|
+
event_id: UUID
|
|
13
|
+
occurred_at: datetime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_event_id() -> UUID:
|
|
17
|
+
return uuid4()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _convert(obj: Any) -> Any:
|
|
21
|
+
"""Recursively convert non-JSON-serializable types."""
|
|
22
|
+
if isinstance(obj, UUID):
|
|
23
|
+
return str(obj)
|
|
24
|
+
if isinstance(obj, datetime):
|
|
25
|
+
return obj.isoformat()
|
|
26
|
+
if isinstance(obj, Decimal):
|
|
27
|
+
return str(obj)
|
|
28
|
+
if isinstance(obj, dict):
|
|
29
|
+
return {k: _convert(v) for k, v in obj.items()}
|
|
30
|
+
if isinstance(obj, list):
|
|
31
|
+
return [_convert(i) for i in obj]
|
|
32
|
+
if isinstance(obj, ValueObject):
|
|
33
|
+
return obj.get_value()
|
|
34
|
+
return obj
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serialize(event: Any) -> dict:
|
|
38
|
+
if is_dataclass(event) and not isinstance(event, type):
|
|
39
|
+
return _convert(asdict(event)) # 👈 pass through converter
|
|
40
|
+
raise TypeError(f"Cannot serialize event of type {type(event)}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def deserialize(payload: dict, event_type: Type) -> Any:
|
|
44
|
+
if not is_dataclass(event_type):
|
|
45
|
+
raise TypeError(f"{event_type} is not a dataclass")
|
|
46
|
+
|
|
47
|
+
kwargs = {}
|
|
48
|
+
type_hints = {f.name: f.type for f in fields(event_type)}
|
|
49
|
+
|
|
50
|
+
for field_name, field_type in type_hints.items():
|
|
51
|
+
if field_name not in payload:
|
|
52
|
+
continue
|
|
53
|
+
value = payload[field_name]
|
|
54
|
+
if field_type in (UUID, "UUID"):
|
|
55
|
+
kwargs[field_name] = UUID(value)
|
|
56
|
+
elif field_type in (datetime, "datetime"):
|
|
57
|
+
kwargs[field_name] = datetime.fromisoformat(value)
|
|
58
|
+
else:
|
|
59
|
+
kwargs[field_name] = value
|
|
60
|
+
|
|
61
|
+
return event_type(**kwargs)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from eventable.event_managment.event_dispatcher import EventDispatcher
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def register_handlers(dispatcher: EventDispatcher) -> None:
|
|
5
|
+
# Same bounded context handlers — register here:
|
|
6
|
+
# dispatcher.register(OrderPlaced, handle_order_placed_internally)
|
|
7
|
+
# dispatcher.register(PatientAdmited, handle_patient_admited)
|
|
8
|
+
...
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from eventable.aggregate_root import AggregateRoot
|
|
2
|
+
from eventable.domain_event import DomainEvent
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EventCollector:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._aggregates: list[AggregateRoot] = []
|
|
8
|
+
|
|
9
|
+
def track(self, aggregate: AggregateRoot) -> None:
|
|
10
|
+
self._aggregates.append(aggregate)
|
|
11
|
+
|
|
12
|
+
def collect_events(self) -> list[DomainEvent]:
|
|
13
|
+
events = []
|
|
14
|
+
for aggregate in self._aggregates:
|
|
15
|
+
events.extend(aggregate.pull_domain_events())
|
|
16
|
+
return events
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Callable, Type
|
|
3
|
+
|
|
4
|
+
from eventable.domain_event import DomainEvent
|
|
5
|
+
from eventable.event_managment.event_publisher import EventPublisher
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventDispatcher:
|
|
12
|
+
def __init__(self, publisher: EventPublisher):
|
|
13
|
+
self._publisher = publisher
|
|
14
|
+
self._handlers: dict[Type[DomainEvent], list[Callable]] = {}
|
|
15
|
+
|
|
16
|
+
def register(self, event_type: Type[DomainEvent], handler: Callable) -> None:
|
|
17
|
+
self._handlers.setdefault(event_type, []).append(handler)
|
|
18
|
+
logger.debug(
|
|
19
|
+
f"Registered handler '{handler.__name__}' for '{event_type.__name__}'"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def dispatch(self, event: DomainEvent) -> None:
|
|
23
|
+
event_type = type(event)
|
|
24
|
+
|
|
25
|
+
# 1. Same-context in-process handlers
|
|
26
|
+
for handler in self._handlers.get(event_type, []):
|
|
27
|
+
try:
|
|
28
|
+
handler(event)
|
|
29
|
+
except Exception:
|
|
30
|
+
logger.exception(
|
|
31
|
+
f"In-process handler '{handler.__name__}' failed for '{event_type.__name__}'"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# 2. Cross-context via broker
|
|
35
|
+
try:
|
|
36
|
+
self._publisher.publish(event)
|
|
37
|
+
except Exception:
|
|
38
|
+
logger.exception(f"Failed to publish '{event_type.__name__}' to broker")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
|
|
3
|
+
from eventable.domain_event import DomainEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EventPublisher(abc.ABC):
|
|
7
|
+
@abc.abstractmethod
|
|
8
|
+
def publish(self, event: DomainEvent) -> None:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
@abc.abstractmethod
|
|
12
|
+
def connect(self) -> None:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@abc.abstractmethod
|
|
16
|
+
def disconnect(self) -> None:
|
|
17
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from typing import Callable, Type
|
|
3
|
+
|
|
4
|
+
from eventable.domain_event import DomainEvent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EventSubscriber(abc.ABC):
|
|
8
|
+
@abc.abstractmethod
|
|
9
|
+
def subscribe(
|
|
10
|
+
self,
|
|
11
|
+
event_type: Type[DomainEvent],
|
|
12
|
+
handler: Callable,
|
|
13
|
+
) -> None: ...
|
|
14
|
+
|
|
15
|
+
@abc.abstractmethod
|
|
16
|
+
def start(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
@abc.abstractmethod
|
|
19
|
+
def stop(self) -> None: ...
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
import logging
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from eventable.event_handlers import register_handlers
|
|
7
|
+
from eventable.event_managment.event_dispatcher import EventDispatcher
|
|
8
|
+
from eventable.infrastructure.rabbitmq.event_publisher import RabbitMQPublisher
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventableSettings(BaseModel):
|
|
14
|
+
rabbit_url: str
|
|
15
|
+
exchange_name: str = "domain_events"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Eventable:
|
|
19
|
+
def __init__(self, settings: EventableSettings) -> None:
|
|
20
|
+
self.settings = settings
|
|
21
|
+
self.publisher: RabbitMQPublisher | None = None
|
|
22
|
+
|
|
23
|
+
async def startup(self, app: FastAPI):
|
|
24
|
+
self.publisher = RabbitMQPublisher(
|
|
25
|
+
url=self.settings.rabbit_url,
|
|
26
|
+
exchange_name=self.settings.exchange_name,
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
self.publisher.connect()
|
|
30
|
+
except Exception:
|
|
31
|
+
logger.warning("RabbitMQ unavailable at startup — will retry on first publish")
|
|
32
|
+
|
|
33
|
+
dispatcher = EventDispatcher(self.publisher)
|
|
34
|
+
register_handlers(dispatcher)
|
|
35
|
+
|
|
36
|
+
app.state.publisher = self.publisher
|
|
37
|
+
app.state.dispatcher = dispatcher
|
|
38
|
+
|
|
39
|
+
async def shutdown(self):
|
|
40
|
+
if self.publisher:
|
|
41
|
+
self.publisher.disconnect()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# STEP 10 — infrastructure/messaging/middleware.py
|
|
3
|
+
# Attaches a fresh EventCollector to every request.
|
|
4
|
+
# After the response is built, pulls all collected events
|
|
5
|
+
# from tracked aggregates and dispatches them.
|
|
6
|
+
# Registered in main.py via app.add_middleware().
|
|
7
|
+
# ============================================================
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from eventable.event_managment.event_collector import EventCollector
|
|
11
|
+
from eventable.event_managment.event_dispatcher import EventDispatcher
|
|
12
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DomainEventMiddleware:
|
|
19
|
+
"""
|
|
20
|
+
Pure ASGI middleware — avoids BaseHTTPMiddleware's exception swallowing.
|
|
21
|
+
Exceptions propagate cleanly to FastAPI's exception handlers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, app: ASGIApp):
|
|
25
|
+
self.app = app
|
|
26
|
+
|
|
27
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
28
|
+
if scope["type"] != "http":
|
|
29
|
+
await self.app(scope, receive, send)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
from starlette.requests import Request
|
|
33
|
+
|
|
34
|
+
request = Request(scope, receive, send)
|
|
35
|
+
request.state.collector = EventCollector()
|
|
36
|
+
|
|
37
|
+
# Track response status to avoid dispatching on errors
|
|
38
|
+
status_code = 500
|
|
39
|
+
|
|
40
|
+
async def send_wrapper(message):
|
|
41
|
+
nonlocal status_code
|
|
42
|
+
if message["type"] == "http.response.start":
|
|
43
|
+
status_code = message["status"]
|
|
44
|
+
await send(message)
|
|
45
|
+
|
|
46
|
+
# Let exceptions propagate naturally — no wrapping
|
|
47
|
+
await self.app(scope, receive, send_wrapper)
|
|
48
|
+
|
|
49
|
+
if status_code < 400:
|
|
50
|
+
dispatcher: EventDispatcher = scope["app"].state.dispatcher
|
|
51
|
+
for event in request.state.collector.collect_events():
|
|
52
|
+
try:
|
|
53
|
+
dispatcher.dispatch(event)
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.exception(f"Failed to dispatch '{type(event).__name__}'")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
import logging
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
import pika
|
|
7
|
+
from pika.exchange_type import ExchangeType
|
|
8
|
+
from pika import DeliveryMode
|
|
9
|
+
|
|
10
|
+
from eventable.domain_event import DomainEvent, serialize
|
|
11
|
+
from eventable.event_managment.event_publisher import EventPublisher
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RabbitMQPublisher(EventPublisher):
|
|
17
|
+
def __init__(self, url: str, exchange_name: str = "domain_events"):
|
|
18
|
+
self._url = url
|
|
19
|
+
self._exchange_name = exchange_name
|
|
20
|
+
self._local = threading.local()
|
|
21
|
+
|
|
22
|
+
def connect(self) -> None:
|
|
23
|
+
self._get_channel()
|
|
24
|
+
logger.info("RabbitMQ publisher connected")
|
|
25
|
+
|
|
26
|
+
def disconnect(self) -> None:
|
|
27
|
+
conn = getattr(self._local, "connection", None)
|
|
28
|
+
if conn and not conn.is_closed:
|
|
29
|
+
conn.close()
|
|
30
|
+
logger.info("RabbitMQ publisher disconnected")
|
|
31
|
+
|
|
32
|
+
def publish(self, event: DomainEvent) -> None:
|
|
33
|
+
channel = self._get_channel()
|
|
34
|
+
routing_key = type(event).__name__
|
|
35
|
+
|
|
36
|
+
payload = {
|
|
37
|
+
"event_type": routing_key,
|
|
38
|
+
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
|
39
|
+
"payload": serialize(event),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
channel.basic_publish(
|
|
43
|
+
exchange=self._exchange_name,
|
|
44
|
+
routing_key=routing_key,
|
|
45
|
+
body=json.dumps(payload).encode(),
|
|
46
|
+
properties=pika.BasicProperties(
|
|
47
|
+
content_type="application/json",
|
|
48
|
+
delivery_mode=DeliveryMode.Persistent,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
logger.debug(f"Published: {routing_key}")
|
|
52
|
+
|
|
53
|
+
def _get_channel(self):
|
|
54
|
+
conn = getattr(self._local, "connection", None)
|
|
55
|
+
chan = getattr(self._local, "channel", None)
|
|
56
|
+
|
|
57
|
+
if not conn or conn.is_closed or not chan or not chan.is_open:
|
|
58
|
+
params = pika.URLParameters(self._url)
|
|
59
|
+
params.heartbeat = 60
|
|
60
|
+
params.blocked_connection_timeout = 300
|
|
61
|
+
self._local.connection = pika.BlockingConnection(params)
|
|
62
|
+
self._local.channel = self._local.connection.channel()
|
|
63
|
+
self._local.channel.exchange_declare(
|
|
64
|
+
exchange=self._exchange_name,
|
|
65
|
+
exchange_type=ExchangeType.topic,
|
|
66
|
+
durable=True,
|
|
67
|
+
)
|
|
68
|
+
logger.info(f"RabbitMQ channel opened on thread: {threading.current_thread().name}")
|
|
69
|
+
|
|
70
|
+
return self._local.channel
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Callable, Type
|
|
4
|
+
from functools import partial
|
|
5
|
+
|
|
6
|
+
import pika
|
|
7
|
+
from pika.exchange_type import ExchangeType
|
|
8
|
+
|
|
9
|
+
from eventable.domain_event import DomainEvent, deserialize
|
|
10
|
+
from eventable.event_managment.event_publisher import EventPublisher
|
|
11
|
+
from eventable.event_managment.event_subscriber import EventSubscriber
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Internal registry entry — keeps handler and its UoW class together
|
|
17
|
+
type HandlerEntry = tuple[Type[DomainEvent], Callable]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RabbitMQSubscriber(EventSubscriber):
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
url: str,
|
|
24
|
+
queue_name: str,
|
|
25
|
+
publisher: EventPublisher,
|
|
26
|
+
exchange_name: str = "domain_events",
|
|
27
|
+
):
|
|
28
|
+
self._url = url
|
|
29
|
+
self._queue_name = queue_name
|
|
30
|
+
self._publisher = publisher
|
|
31
|
+
self._exchange_name = exchange_name
|
|
32
|
+
self._handlers: dict[str, HandlerEntry] = {}
|
|
33
|
+
self._connection: pika.BlockingConnection | None = None
|
|
34
|
+
self._channel = None
|
|
35
|
+
|
|
36
|
+
def subscribe(self, event_type: Type[DomainEvent], handler: Callable, **kwargs) -> None:
|
|
37
|
+
routing_key = event_type.__name__
|
|
38
|
+
|
|
39
|
+
handler_name = handler.__name__
|
|
40
|
+
if kwargs:
|
|
41
|
+
handler = partial(handler, **kwargs)
|
|
42
|
+
|
|
43
|
+
self._handlers[routing_key] = (event_type, handler)
|
|
44
|
+
logger.debug(f"Subscribed '{handler_name}' to '{routing_key}'")
|
|
45
|
+
|
|
46
|
+
def start(self) -> None:
|
|
47
|
+
self._connect()
|
|
48
|
+
logger.info(f"Consumer listening on queue: '{self._queue_name}'")
|
|
49
|
+
if not self._channel:
|
|
50
|
+
raise
|
|
51
|
+
self._channel.start_consuming()
|
|
52
|
+
|
|
53
|
+
def stop(self) -> None:
|
|
54
|
+
if self._channel and self._channel.is_open:
|
|
55
|
+
self._channel.stop_consuming()
|
|
56
|
+
if self._connection and not self._connection.is_closed:
|
|
57
|
+
self._connection.close()
|
|
58
|
+
logger.info("RabbitMQ subscriber stopped")
|
|
59
|
+
|
|
60
|
+
def _connect(self):
|
|
61
|
+
params = pika.URLParameters(self._url)
|
|
62
|
+
params.heartbeat = 60
|
|
63
|
+
params.blocked_connection_timeout = 300
|
|
64
|
+
|
|
65
|
+
self._connection = pika.BlockingConnection(params)
|
|
66
|
+
self._channel = self._connection.channel()
|
|
67
|
+
|
|
68
|
+
self._channel.exchange_declare(
|
|
69
|
+
exchange=self._exchange_name,
|
|
70
|
+
exchange_type=ExchangeType.topic,
|
|
71
|
+
durable=True,
|
|
72
|
+
)
|
|
73
|
+
self._channel.queue_declare(queue=self._queue_name, durable=True)
|
|
74
|
+
|
|
75
|
+
for routing_key in self._handlers:
|
|
76
|
+
self._channel.queue_bind(
|
|
77
|
+
queue=self._queue_name,
|
|
78
|
+
exchange=self._exchange_name,
|
|
79
|
+
routing_key=routing_key,
|
|
80
|
+
)
|
|
81
|
+
logger.info(f"Bound '{self._queue_name}' → '{routing_key}'")
|
|
82
|
+
|
|
83
|
+
self._channel.basic_qos(prefetch_count=1)
|
|
84
|
+
self._channel.basic_consume(
|
|
85
|
+
queue=self._queue_name,
|
|
86
|
+
on_message_callback=self._on_message,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _on_message(self, channel, method, properties, body: bytes):
|
|
90
|
+
routing_key = method.routing_key
|
|
91
|
+
|
|
92
|
+
if routing_key not in self._handlers:
|
|
93
|
+
logger.warning(f"No handler for routing key: '{routing_key}'")
|
|
94
|
+
channel.basic_nack(method.delivery_tag, requeue=False)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
event_type, handler = self._handlers[routing_key] # 👈 unpack uow_class
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
raw = json.loads(body)
|
|
101
|
+
event = deserialize(raw["payload"], event_type)
|
|
102
|
+
|
|
103
|
+
handler(event)
|
|
104
|
+
|
|
105
|
+
channel.basic_ack(method.delivery_tag)
|
|
106
|
+
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.exception(f"Handler failed for '{routing_key}' — nacking to DLQ")
|
|
109
|
+
channel.basic_nack(method.delivery_tag, requeue=False)
|
|
File without changes
|