package-events-bus 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.
- package_events_bus-0.1.0/LICENSE +21 -0
- package_events_bus-0.1.0/PKG-INFO +278 -0
- package_events_bus-0.1.0/README.md +258 -0
- package_events_bus-0.1.0/events_bus/__init__.py +4 -0
- package_events_bus-0.1.0/events_bus/aws/event_bridge_publisher.py +43 -0
- package_events_bus-0.1.0/events_bus/aws/sqs_dispatcher.py +136 -0
- package_events_bus-0.1.0/events_bus/config.py +36 -0
- package_events_bus-0.1.0/events_bus/core/__init__.py +19 -0
- package_events_bus-0.1.0/events_bus/core/contracts/base_event.py +52 -0
- package_events_bus-0.1.0/events_bus/core/contracts/deduplication.py +42 -0
- package_events_bus-0.1.0/events_bus/core/contracts/handler.py +52 -0
- package_events_bus-0.1.0/events_bus/core/infrastructure/event_bus_publisher.py +30 -0
- package_events_bus-0.1.0/events_bus/core/infrastructure/event_serializer.py +112 -0
- package_events_bus-0.1.0/events_bus/core/infrastructure/redis_deduplication.py +30 -0
- package_events_bus-0.1.0/events_bus/core/runtime/event_register.py +28 -0
- package_events_bus-0.1.0/events_bus/exceptions.py +31 -0
- package_events_bus-0.1.0/pyproject.toml +20 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FK2.0
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: package-events-bus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Una librería para construir microservicios desacoplados en Python usando AWS EventBridge y SQS, donde cada caso de uso maneja su propia cola y se despacha automáticamente mediante handlers tipados.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Jose Luis Rosales Meza
|
|
7
|
+
Author-email: jose.rosales@finkargo.com
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: boto3 (>=1.38.13,<2.0.0)
|
|
16
|
+
Requires-Dist: python-dotenv (>=1.1.0,<2.0.0)
|
|
17
|
+
Requires-Dist: redis (>=5.0,<6.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# 📦 Package Events Bus
|
|
21
|
+
|
|
22
|
+
Una librería para construir servicios desacoplados en Python usando AWS EventBridge y SQS, donde cada caso de uso maneja su propia cola.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## � Tabla de Contenidos
|
|
27
|
+
|
|
28
|
+
- [📦 Package Events Bus](#-package-events-bus)
|
|
29
|
+
- [� Tabla de Contenidos](#-tabla-de-contenidos)
|
|
30
|
+
- [📝 Descripción](#-descripción)
|
|
31
|
+
- [⚙️ Instalación](#️-instalación)
|
|
32
|
+
- [🗂️ Estructura del Proyecto](#️-estructura-del-proyecto)
|
|
33
|
+
- [💡 ¿Cómo usarlo?](#-cómo-usarlo)
|
|
34
|
+
- [📤 Publicar eventos](#-publicar-eventos)
|
|
35
|
+
- [📥 Consumir eventos](#-consumir-eventos)
|
|
36
|
+
- [⚡ ¿Cómo implementar el dispatcher en FastAPI?](#-cómo-implementar-el-dispatcher-en-fastapi)
|
|
37
|
+
- [🛡️ Opcional: Configuración de Redis para deduplicación](#️-opcional-configuración-de-redis-para-deduplicación)
|
|
38
|
+
- [⚙️ Variables por Defecto](#️-variables-por-defecto)
|
|
39
|
+
- [🏷️ Definición de Topics](#️-definición-de-topics)
|
|
40
|
+
- [🛠️ TODO](#️-todo)
|
|
41
|
+
- [📝 Buenas Prácticas](#-buenas-prácticas)
|
|
42
|
+
- [📄 Licencia](#-licencia)
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 📝 Descripción
|
|
47
|
+
|
|
48
|
+
Esta librería permite construir microservicios desacoplados en Python, facilitando la publicación y consumo de eventos a través de AWS EventBridge y SQS. Cada caso de uso maneja su propia cola, permitiendo escalabilidad y mantenibilidad.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## ⚙️ Instalación
|
|
53
|
+
|
|
54
|
+
Instala la librería usando **pip**:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install package-events-bus
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
O usando **poetry**:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
poetry add package-events-bus
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 🗂️ Estructura del Proyecto
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
package_events_bus/
|
|
72
|
+
├── __init__.py
|
|
73
|
+
├── config.py
|
|
74
|
+
├── exceptions.py
|
|
75
|
+
├── .vscode/settings.json
|
|
76
|
+
├── aws/
|
|
77
|
+
│ ├── event_bridge_publisher.py
|
|
78
|
+
│ └── sqs_dispatcher.py
|
|
79
|
+
├── core/
|
|
80
|
+
│ ├── __init__.py
|
|
81
|
+
│ ├── contracts/
|
|
82
|
+
│ │ ├── base_event.py
|
|
83
|
+
│ │ ├── deduplication.py
|
|
84
|
+
│ │ └── handler.py
|
|
85
|
+
│ ├── infrastructure/
|
|
86
|
+
│ │ ├── event_bus_publisher.py
|
|
87
|
+
│ │ ├── event_serializer.py
|
|
88
|
+
│ │ └── redis_deduplication.py
|
|
89
|
+
│ └── runtime/
|
|
90
|
+
│ └── event_register.py
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- **/aws:** Implementaciones específicas para servicios de AWS.
|
|
94
|
+
- **event_bridge_publisher.py:** Publica eventos a AWS EventBridge.
|
|
95
|
+
- **sqs_dispatcher.py:** Despacha mensajes de colas SQS y procesa eventos.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 💡 ¿Cómo usarlo?
|
|
100
|
+
|
|
101
|
+
### 📤 Publicar eventos
|
|
102
|
+
|
|
103
|
+
1. Crea una instancia de `EventBridgePublisher` y llama a `publish` con el evento deseado.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from events_bus.aws.event_bridge_publisher import EventBridgePublisher
|
|
107
|
+
|
|
108
|
+
event_bridge_publisher = EventBridgePublisher(
|
|
109
|
+
bus_name="finkargo-events",
|
|
110
|
+
source="my.service",
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
2. Define tu evento heredando de `BaseEvent`:
|
|
115
|
+
Reemplaza `DisbursementCreatedEvent` con el nombre de tu evento, recuerda sobreescribir el método `to_dict` para serializar los atributos del evento que deseas enviar.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from events_bus.core.contracts.base_event import BaseEvent
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class DisbursementCreatedEvent(BaseEvent):
|
|
122
|
+
event_name = 'finkargo.portfolio.1.event.disbursement.created'
|
|
123
|
+
|
|
124
|
+
def __init__(self, disbursement):
|
|
125
|
+
super().__init__(event_name=self.event_name)
|
|
126
|
+
self.id_disbursement = disbursement.id_disbursement
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict:
|
|
129
|
+
return {
|
|
130
|
+
'id_disbursement': self.id_disbursement
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, event_id: str, occurred_on, attributes: dict) -> "DisbursementCreatedEvent":
|
|
135
|
+
return cls(disbursement_id=attributes.get('id_disbursement'))
|
|
136
|
+
|
|
137
|
+
event_bridge_publisher.publish(event=DisbursementCreatedEvent(instance))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### 📥 Consumir eventos
|
|
143
|
+
|
|
144
|
+
1. Hereda de `AsyncHandler` o `SyncHandler` y sobrescribe el método `handle`.
|
|
145
|
+
El método `handle` recibe el evento como parámetro, recuerda que el evento debe heredar de `BaseEvent`,
|
|
146
|
+
y el método `from_dict` debe estar implementado para poder deserializar el evento.
|
|
147
|
+
|
|
148
|
+
Usa `AsyncHandler` para manejar eventos de forma asíncrona y `SyncHandler` para eventos sincrónicos.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from events_bus.core import AsyncHandler
|
|
152
|
+
|
|
153
|
+
class CustomAsyncHandler(AsyncHandler):
|
|
154
|
+
async def handle(self, data: DisbursementCreatedEvent):
|
|
155
|
+
print(f"Handling event: {data.event_name}")
|
|
156
|
+
print(f"Finished handling event: {data.event_name}")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
2. Registra el handler en el bus de eventos:
|
|
160
|
+
Usa `EventHandlerRegister` para registrar el handler y la cola SQS asociada, recuerda que el target debe ser configurado previamente en AWS.
|
|
161
|
+
Para mas información sobre la configuración de AWS, consulta la [documentación oficial](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-targets.html).
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from events_bus.core import EventHandlerRegister
|
|
165
|
+
from events_bus.aws.sqs_dispatcher import SQSDispatcher
|
|
166
|
+
|
|
167
|
+
dispatcher = SQSDispatcher()
|
|
168
|
+
EventHandlerRegister.register(
|
|
169
|
+
queue_url='https://sqs.us-east-1.amazonaws.com/123456789012/my-queue',
|
|
170
|
+
handler=CustomAsyncHandler()
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
### ⚡ ¿Cómo implementar el dispatcher en FastAPI?
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@asynccontextmanager
|
|
180
|
+
async def lifespan(app: FastAPI):
|
|
181
|
+
await dispatcher.start()
|
|
182
|
+
yield
|
|
183
|
+
dispatcher.stop()
|
|
184
|
+
|
|
185
|
+
app = FastAPI(lifespan=lifespan)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### 🛡️ Opcional: Configuración de Redis para deduplicación
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from events_bus.core import RedisDeduplication
|
|
194
|
+
from events_bus.aws.sqs_dispatcher import SQSDispatcher
|
|
195
|
+
|
|
196
|
+
deduplication = RedisDeduplication(
|
|
197
|
+
url='redis://localhost:6379/0',
|
|
198
|
+
ttl=3600,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
dispatcher = SQSDispatcher(deduplication=deduplication)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
> Si no deseas usar Redis, puedes heredar de `BaseDeduplication` e implementar tu propia lógica.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## ⚙️ Variables por Defecto
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from events_bus import CONFIG
|
|
212
|
+
CONFIG.set_envs(os.environ, env_file='.env')
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Puedes modificar las variables por defecto de la librería usando un archivo `.env` o configurando las variables de entorno directamente. Las variables configurables son:
|
|
216
|
+
|
|
217
|
+
| Variable | Tipo | Por defecto |Descripción |
|
|
218
|
+
|-------------------------------|---------|----------------|-------------------------------------------------------------------------------|
|
|
219
|
+
| MAX_NUMBER_OF_MESSAGES | int | 5 | Número máximo de mensajes a recibir por llamada. |
|
|
220
|
+
| WAIT_TIME_SECONDS | int | 10 | Tiempo de espera para recibir mensajes. |
|
|
221
|
+
| VISIBILITY_TIMEOUT | int | 30 | Tiempo de visibilidad del mensaje en la cola. |
|
|
222
|
+
| AWS_CLIENT_URL | str | None | URL del cliente de AWS (útil para LocalStack u otros entornos locales). |
|
|
223
|
+
| AWS_REGION_NAME | str | us-east-1 | Región de AWS. |
|
|
224
|
+
| SLEEP_BETWEEN_MESSAGES_SECONDS | float | 0.1 | Tiempo de espera entre mensajes. |
|
|
225
|
+
| ERROR_SLEEP_SECONDS | int | 5 | Tiempo de espera en caso de error. |
|
|
226
|
+
|
|
227
|
+
> ℹ️ **Tip:** Usa `.env` para mantener tu configuración fuera del código fuente.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
## 🏷️ Definición de Topics
|
|
231
|
+
|
|
232
|
+
Los topics deben seguir la convención:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
COMPANY.SERVICE.VERSION.MESSAGE_TYPE.RESOURCE_NAME.(EVENT_COMMAND_NAME)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
- **COMPANY:** Nombre de la empresa (`Finkargo` para internos).
|
|
239
|
+
- **SERVICE:** Servicio generador del evento.
|
|
240
|
+
- **VERSION:** Versión del topic.
|
|
241
|
+
- **MESSAGE_TYPE:** `command` o `event`.
|
|
242
|
+
- **RESOURCE_NAME:** Entidad relacionada.
|
|
243
|
+
- **EVENT_COMMAND_NAME:** Verbo en pasado para eventos (`created`), infinitivo para comandos (`create`).
|
|
244
|
+
|
|
245
|
+
**Regex de validación:**
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
TOPIC_REGEX = "^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\.\d+\.(command|event)\.[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$"
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Basado en: [Topics Definition | AsyncApi](https://github.com/fmvilas/topic-definition)
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 🛠️ TODO
|
|
256
|
+
|
|
257
|
+
- [ ] Mejorar el control de excepciones en la publicación de eventos.
|
|
258
|
+
- [ ] Agregar más ejemplos de integración con otros frameworks.
|
|
259
|
+
- [ ] Documentar casos de uso avanzados.
|
|
260
|
+
- [ ] Implementar métricas y logging para los eventos publicados y consumidos.
|
|
261
|
+
- [ ] Añadir pruebas unitarias y de integración.
|
|
262
|
+
- [ ] Mejorar la documentación en inglés y español.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 📝 Buenas Prácticas
|
|
267
|
+
|
|
268
|
+
- Usa nombres de eventos claros y consistentes.
|
|
269
|
+
- Implementa deduplicación para evitar procesar eventos repetidos.
|
|
270
|
+
- Mantén tus handlers simples y enfocados en una sola responsabilidad.
|
|
271
|
+
- Documenta tus eventos y handlers.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
## 📄 Licencia
|
|
275
|
+
|
|
276
|
+
Este proyecto está bajo la licencia MIT.
|
|
277
|
+
|
|
278
|
+
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# 📦 Package Events Bus
|
|
2
|
+
|
|
3
|
+
Una librería para construir servicios desacoplados en Python usando AWS EventBridge y SQS, donde cada caso de uso maneja su propia cola.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## � Tabla de Contenidos
|
|
8
|
+
|
|
9
|
+
- [📦 Package Events Bus](#-package-events-bus)
|
|
10
|
+
- [� Tabla de Contenidos](#-tabla-de-contenidos)
|
|
11
|
+
- [📝 Descripción](#-descripción)
|
|
12
|
+
- [⚙️ Instalación](#️-instalación)
|
|
13
|
+
- [🗂️ Estructura del Proyecto](#️-estructura-del-proyecto)
|
|
14
|
+
- [💡 ¿Cómo usarlo?](#-cómo-usarlo)
|
|
15
|
+
- [📤 Publicar eventos](#-publicar-eventos)
|
|
16
|
+
- [📥 Consumir eventos](#-consumir-eventos)
|
|
17
|
+
- [⚡ ¿Cómo implementar el dispatcher en FastAPI?](#-cómo-implementar-el-dispatcher-en-fastapi)
|
|
18
|
+
- [🛡️ Opcional: Configuración de Redis para deduplicación](#️-opcional-configuración-de-redis-para-deduplicación)
|
|
19
|
+
- [⚙️ Variables por Defecto](#️-variables-por-defecto)
|
|
20
|
+
- [🏷️ Definición de Topics](#️-definición-de-topics)
|
|
21
|
+
- [🛠️ TODO](#️-todo)
|
|
22
|
+
- [📝 Buenas Prácticas](#-buenas-prácticas)
|
|
23
|
+
- [📄 Licencia](#-licencia)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 📝 Descripción
|
|
28
|
+
|
|
29
|
+
Esta librería permite construir microservicios desacoplados en Python, facilitando la publicación y consumo de eventos a través de AWS EventBridge y SQS. Cada caso de uso maneja su propia cola, permitiendo escalabilidad y mantenibilidad.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## ⚙️ Instalación
|
|
34
|
+
|
|
35
|
+
Instala la librería usando **pip**:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install package-events-bus
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
O usando **poetry**:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
poetry add package-events-bus
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🗂️ Estructura del Proyecto
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
package_events_bus/
|
|
53
|
+
├── __init__.py
|
|
54
|
+
├── config.py
|
|
55
|
+
├── exceptions.py
|
|
56
|
+
├── .vscode/settings.json
|
|
57
|
+
├── aws/
|
|
58
|
+
│ ├── event_bridge_publisher.py
|
|
59
|
+
│ └── sqs_dispatcher.py
|
|
60
|
+
├── core/
|
|
61
|
+
│ ├── __init__.py
|
|
62
|
+
│ ├── contracts/
|
|
63
|
+
│ │ ├── base_event.py
|
|
64
|
+
│ │ ├── deduplication.py
|
|
65
|
+
│ │ └── handler.py
|
|
66
|
+
│ ├── infrastructure/
|
|
67
|
+
│ │ ├── event_bus_publisher.py
|
|
68
|
+
│ │ ├── event_serializer.py
|
|
69
|
+
│ │ └── redis_deduplication.py
|
|
70
|
+
│ └── runtime/
|
|
71
|
+
│ └── event_register.py
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **/aws:** Implementaciones específicas para servicios de AWS.
|
|
75
|
+
- **event_bridge_publisher.py:** Publica eventos a AWS EventBridge.
|
|
76
|
+
- **sqs_dispatcher.py:** Despacha mensajes de colas SQS y procesa eventos.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 💡 ¿Cómo usarlo?
|
|
81
|
+
|
|
82
|
+
### 📤 Publicar eventos
|
|
83
|
+
|
|
84
|
+
1. Crea una instancia de `EventBridgePublisher` y llama a `publish` con el evento deseado.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from events_bus.aws.event_bridge_publisher import EventBridgePublisher
|
|
88
|
+
|
|
89
|
+
event_bridge_publisher = EventBridgePublisher(
|
|
90
|
+
bus_name="finkargo-events",
|
|
91
|
+
source="my.service",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
2. Define tu evento heredando de `BaseEvent`:
|
|
96
|
+
Reemplaza `DisbursementCreatedEvent` con el nombre de tu evento, recuerda sobreescribir el método `to_dict` para serializar los atributos del evento que deseas enviar.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from events_bus.core.contracts.base_event import BaseEvent
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class DisbursementCreatedEvent(BaseEvent):
|
|
103
|
+
event_name = 'finkargo.portfolio.1.event.disbursement.created'
|
|
104
|
+
|
|
105
|
+
def __init__(self, disbursement):
|
|
106
|
+
super().__init__(event_name=self.event_name)
|
|
107
|
+
self.id_disbursement = disbursement.id_disbursement
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict:
|
|
110
|
+
return {
|
|
111
|
+
'id_disbursement': self.id_disbursement
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_dict(cls, event_id: str, occurred_on, attributes: dict) -> "DisbursementCreatedEvent":
|
|
116
|
+
return cls(disbursement_id=attributes.get('id_disbursement'))
|
|
117
|
+
|
|
118
|
+
event_bridge_publisher.publish(event=DisbursementCreatedEvent(instance))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### 📥 Consumir eventos
|
|
124
|
+
|
|
125
|
+
1. Hereda de `AsyncHandler` o `SyncHandler` y sobrescribe el método `handle`.
|
|
126
|
+
El método `handle` recibe el evento como parámetro, recuerda que el evento debe heredar de `BaseEvent`,
|
|
127
|
+
y el método `from_dict` debe estar implementado para poder deserializar el evento.
|
|
128
|
+
|
|
129
|
+
Usa `AsyncHandler` para manejar eventos de forma asíncrona y `SyncHandler` para eventos sincrónicos.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from events_bus.core import AsyncHandler
|
|
133
|
+
|
|
134
|
+
class CustomAsyncHandler(AsyncHandler):
|
|
135
|
+
async def handle(self, data: DisbursementCreatedEvent):
|
|
136
|
+
print(f"Handling event: {data.event_name}")
|
|
137
|
+
print(f"Finished handling event: {data.event_name}")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
2. Registra el handler en el bus de eventos:
|
|
141
|
+
Usa `EventHandlerRegister` para registrar el handler y la cola SQS asociada, recuerda que el target debe ser configurado previamente en AWS.
|
|
142
|
+
Para mas información sobre la configuración de AWS, consulta la [documentación oficial](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-targets.html).
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from events_bus.core import EventHandlerRegister
|
|
146
|
+
from events_bus.aws.sqs_dispatcher import SQSDispatcher
|
|
147
|
+
|
|
148
|
+
dispatcher = SQSDispatcher()
|
|
149
|
+
EventHandlerRegister.register(
|
|
150
|
+
queue_url='https://sqs.us-east-1.amazonaws.com/123456789012/my-queue',
|
|
151
|
+
handler=CustomAsyncHandler()
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
### ⚡ ¿Cómo implementar el dispatcher en FastAPI?
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
@asynccontextmanager
|
|
161
|
+
async def lifespan(app: FastAPI):
|
|
162
|
+
await dispatcher.start()
|
|
163
|
+
yield
|
|
164
|
+
dispatcher.stop()
|
|
165
|
+
|
|
166
|
+
app = FastAPI(lifespan=lifespan)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### 🛡️ Opcional: Configuración de Redis para deduplicación
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from events_bus.core import RedisDeduplication
|
|
175
|
+
from events_bus.aws.sqs_dispatcher import SQSDispatcher
|
|
176
|
+
|
|
177
|
+
deduplication = RedisDeduplication(
|
|
178
|
+
url='redis://localhost:6379/0',
|
|
179
|
+
ttl=3600,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
dispatcher = SQSDispatcher(deduplication=deduplication)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
> Si no deseas usar Redis, puedes heredar de `BaseDeduplication` e implementar tu propia lógica.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## ⚙️ Variables por Defecto
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from events_bus import CONFIG
|
|
193
|
+
CONFIG.set_envs(os.environ, env_file='.env')
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Puedes modificar las variables por defecto de la librería usando un archivo `.env` o configurando las variables de entorno directamente. Las variables configurables son:
|
|
197
|
+
|
|
198
|
+
| Variable | Tipo | Por defecto |Descripción |
|
|
199
|
+
|-------------------------------|---------|----------------|-------------------------------------------------------------------------------|
|
|
200
|
+
| MAX_NUMBER_OF_MESSAGES | int | 5 | Número máximo de mensajes a recibir por llamada. |
|
|
201
|
+
| WAIT_TIME_SECONDS | int | 10 | Tiempo de espera para recibir mensajes. |
|
|
202
|
+
| VISIBILITY_TIMEOUT | int | 30 | Tiempo de visibilidad del mensaje en la cola. |
|
|
203
|
+
| AWS_CLIENT_URL | str | None | URL del cliente de AWS (útil para LocalStack u otros entornos locales). |
|
|
204
|
+
| AWS_REGION_NAME | str | us-east-1 | Región de AWS. |
|
|
205
|
+
| SLEEP_BETWEEN_MESSAGES_SECONDS | float | 0.1 | Tiempo de espera entre mensajes. |
|
|
206
|
+
| ERROR_SLEEP_SECONDS | int | 5 | Tiempo de espera en caso de error. |
|
|
207
|
+
|
|
208
|
+
> ℹ️ **Tip:** Usa `.env` para mantener tu configuración fuera del código fuente.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
## 🏷️ Definición de Topics
|
|
212
|
+
|
|
213
|
+
Los topics deben seguir la convención:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
COMPANY.SERVICE.VERSION.MESSAGE_TYPE.RESOURCE_NAME.(EVENT_COMMAND_NAME)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **COMPANY:** Nombre de la empresa (`Finkargo` para internos).
|
|
220
|
+
- **SERVICE:** Servicio generador del evento.
|
|
221
|
+
- **VERSION:** Versión del topic.
|
|
222
|
+
- **MESSAGE_TYPE:** `command` o `event`.
|
|
223
|
+
- **RESOURCE_NAME:** Entidad relacionada.
|
|
224
|
+
- **EVENT_COMMAND_NAME:** Verbo en pasado para eventos (`created`), infinitivo para comandos (`create`).
|
|
225
|
+
|
|
226
|
+
**Regex de validación:**
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
TOPIC_REGEX = "^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\.\d+\.(command|event)\.[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Basado en: [Topics Definition | AsyncApi](https://github.com/fmvilas/topic-definition)
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 🛠️ TODO
|
|
237
|
+
|
|
238
|
+
- [ ] Mejorar el control de excepciones en la publicación de eventos.
|
|
239
|
+
- [ ] Agregar más ejemplos de integración con otros frameworks.
|
|
240
|
+
- [ ] Documentar casos de uso avanzados.
|
|
241
|
+
- [ ] Implementar métricas y logging para los eventos publicados y consumidos.
|
|
242
|
+
- [ ] Añadir pruebas unitarias y de integración.
|
|
243
|
+
- [ ] Mejorar la documentación en inglés y español.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 📝 Buenas Prácticas
|
|
248
|
+
|
|
249
|
+
- Usa nombres de eventos claros y consistentes.
|
|
250
|
+
- Implementa deduplicación para evitar procesar eventos repetidos.
|
|
251
|
+
- Mantén tus handlers simples y enfocados en una sola responsabilidad.
|
|
252
|
+
- Documenta tus eventos y handlers.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
## 📄 Licencia
|
|
256
|
+
|
|
257
|
+
Este proyecto está bajo la licencia MIT.
|
|
258
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
|
|
3
|
+
from .. import CONFIG
|
|
4
|
+
from ..core import (EventBusPublisher, BaseEvent,
|
|
5
|
+
EventJsonSerializerDeserializer)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EventBridgePublisher(EventBusPublisher):
|
|
9
|
+
def __init__(self, bus_name: str, source: str):
|
|
10
|
+
self.client = boto3.client('events',
|
|
11
|
+
region_name=CONFIG.AWS_REGION_NAME,
|
|
12
|
+
endpoint_url=CONFIG.AWS_CLIENT_URL)
|
|
13
|
+
self.bus_name = bus_name
|
|
14
|
+
self.source = source
|
|
15
|
+
|
|
16
|
+
def publish(self, event: BaseEvent):
|
|
17
|
+
"""Publish a single event to the EventBridge bus."""
|
|
18
|
+
# TODO: Add try/except for publish
|
|
19
|
+
self.client.put_events(
|
|
20
|
+
Entries=[
|
|
21
|
+
{
|
|
22
|
+
'Source': self.source,
|
|
23
|
+
'DetailType': event.event_name,
|
|
24
|
+
'Detail': EventJsonSerializerDeserializer.serialize(event),
|
|
25
|
+
'EventBusName': self.bus_name,
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def publish_batch(self, events: list[BaseEvent]):
|
|
31
|
+
"""Publish a batch of events to the EventBridge bus."""
|
|
32
|
+
# TODO: Add try/except for publish_batch
|
|
33
|
+
entries = []
|
|
34
|
+
for event in events:
|
|
35
|
+
entries.append(
|
|
36
|
+
{
|
|
37
|
+
'Source': self.source,
|
|
38
|
+
'DetailType': event.event_name,
|
|
39
|
+
'Detail': EventJsonSerializerDeserializer.serialize(event),
|
|
40
|
+
'EventBusName': self.bus_name,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
self.client.put_events(Entries=entries)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import traceback
|
|
5
|
+
|
|
6
|
+
from .. import CONFIG
|
|
7
|
+
from ..core import (EventHandlerRegistry, BaseHandler,
|
|
8
|
+
EventJsonSerializerDeserializer, AsyncHandler,
|
|
9
|
+
SyncHandler, BaseDeduplication)
|
|
10
|
+
from ..exceptions import (HandlerExecutionError, DeserializationEventError)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQSDispatcher:
|
|
14
|
+
"""Dispatcher for handling events from AWS SQS queues.
|
|
15
|
+
This class is responsible for polling SQS queues, deserializing events,
|
|
16
|
+
executing handlers, and managing deduplication of events.
|
|
17
|
+
""" # noqa: E501
|
|
18
|
+
|
|
19
|
+
def __init__(self, deduplication: BaseDeduplication = None):
|
|
20
|
+
"""
|
|
21
|
+
Initialize the SQSDispatcher.
|
|
22
|
+
Args:
|
|
23
|
+
deduplication (BaseDeduplication, optional): An instance of a deduplication strategy.
|
|
24
|
+
If provided, it will be used to check for duplicate events.
|
|
25
|
+
Defaults to None. Can be an instance of `RedisDeduplication`
|
|
26
|
+
or any other class that implements the `BaseDeduplication` interface.. Example:
|
|
27
|
+
>>> from events_bus.core.infrastructure.redis_deduplication import RedisDeduplication
|
|
28
|
+
>>> deduplication = RedisDeduplication(url='redis://localhost:6379/0')
|
|
29
|
+
>>> dispatcher = SQSDispatcher(deduplication=deduplication)
|
|
30
|
+
""" # noqa: E501
|
|
31
|
+
self.client = boto3.client('sqs',
|
|
32
|
+
region_name=CONFIG.AWS_REGION_NAME,
|
|
33
|
+
endpoint_url=CONFIG.AWS_CLIENT_URL)
|
|
34
|
+
self._stop_event = asyncio.Event()
|
|
35
|
+
self.deduplication = deduplication
|
|
36
|
+
|
|
37
|
+
def stop(self):
|
|
38
|
+
self._stop_event.set()
|
|
39
|
+
|
|
40
|
+
async def start(self):
|
|
41
|
+
"""Start the SQS dispatcher.
|
|
42
|
+
This method initializes the SQS client and starts polling the queues
|
|
43
|
+
registered in the `EventHandlerRegistry`. It creates a separate task
|
|
44
|
+
for each queue to handle events concurrently.
|
|
45
|
+
""" # noqa: E501
|
|
46
|
+
for queue_url, handler in EventHandlerRegistry.get_handlers().items():
|
|
47
|
+
task = asyncio.create_task(self._poll_queue(queue_url, handler)) # noqa: E501
|
|
48
|
+
task.add_done_callback(self._handle_task_exception)
|
|
49
|
+
|
|
50
|
+
def consume(self):
|
|
51
|
+
"""Consume messages from the SQS queues.
|
|
52
|
+
"""
|
|
53
|
+
for queue_url, handler in EventHandlerRegistry.get_handlers().items():
|
|
54
|
+
task = asyncio.create_task(self._execute(queue_url, handler))
|
|
55
|
+
task.add_done_callback(self._handle_task_exception)
|
|
56
|
+
|
|
57
|
+
def _handle_task_exception(self, task: asyncio.Task):
|
|
58
|
+
try:
|
|
59
|
+
exc = task.exception()
|
|
60
|
+
if exc:
|
|
61
|
+
logging.error(
|
|
62
|
+
f"An error occurred in task {task.get_name()}: {exc}")
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
logging.info(f"The task {task.get_name()} was cancelled.")
|
|
65
|
+
|
|
66
|
+
async def _poll_queue(self, queue_url, handler: BaseHandler): # noqa: E501
|
|
67
|
+
while not self._stop_event.is_set():
|
|
68
|
+
await self._execute(
|
|
69
|
+
queue_url=queue_url,
|
|
70
|
+
handler=handler
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def _execute(self, queue_url, handler: BaseHandler):
|
|
74
|
+
event_name = None
|
|
75
|
+
try:
|
|
76
|
+
response = await asyncio.to_thread(
|
|
77
|
+
self.client.receive_message,
|
|
78
|
+
QueueUrl=queue_url,
|
|
79
|
+
MaxNumberOfMessages=CONFIG.MAX_NUMBER_OF_MESSAGES,
|
|
80
|
+
WaitTimeSeconds=CONFIG.WAIT_TIME_SECONDS,
|
|
81
|
+
VisibilityTimeout=CONFIG.VISIBILITY_TIMEOUT,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
for msg in response.get('Messages', []):
|
|
85
|
+
event_name = await self._process_message(queue_url, msg, handler) # noqa: E50
|
|
86
|
+
await asyncio.sleep(CONFIG.SLEEP_BETWEEN_MESSAGES_SECONDS)
|
|
87
|
+
except (Exception, HandlerExecutionError, DeserializationEventError): # noqa: E501
|
|
88
|
+
logging.error(traceback.format_exc())
|
|
89
|
+
logging.error(
|
|
90
|
+
f"""Error processing message from queue {queue_url}\n 'EventName': {event_name}""" # noqa: E501
|
|
91
|
+
)
|
|
92
|
+
await asyncio.sleep(CONFIG.ERROR_SLEEP_SECONDS) # noqa: E501
|
|
93
|
+
|
|
94
|
+
async def _execute_handler(self, handler: BaseHandler, event, event_name: str): # noqa: E501
|
|
95
|
+
try:
|
|
96
|
+
if isinstance(handler, AsyncHandler):
|
|
97
|
+
await handler.handle(event)
|
|
98
|
+
elif isinstance(handler, SyncHandler):
|
|
99
|
+
await asyncio.to_thread(handler.handle, event)
|
|
100
|
+
else:
|
|
101
|
+
raise TypeError(
|
|
102
|
+
f"Handler {handler.__class__.__name__} is not a valid handler type." # noqa: E501
|
|
103
|
+
)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise HandlerExecutionError(
|
|
106
|
+
handler=handler,
|
|
107
|
+
event_name=event_name
|
|
108
|
+
) from e
|
|
109
|
+
|
|
110
|
+
async def _process_message(self,
|
|
111
|
+
queue_url: str,
|
|
112
|
+
msg: dict,
|
|
113
|
+
handler: BaseHandler) -> str:
|
|
114
|
+
"""Process a single message from the SQS queue."""
|
|
115
|
+
event = EventJsonSerializerDeserializer.deserialize(
|
|
116
|
+
event_json=msg['Body'],
|
|
117
|
+
handler=handler
|
|
118
|
+
)
|
|
119
|
+
event_name = event.event_name
|
|
120
|
+
|
|
121
|
+
if self.deduplication and self.deduplication.is_duplicate(event.event_id): # noqa: E501
|
|
122
|
+
logging.info(
|
|
123
|
+
f"Duplicate event detected: {event.event_id} for handler {handler.__class__.__name__}" # noqa: E501
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
await self._execute_handler(handler, event, event_name)
|
|
127
|
+
|
|
128
|
+
if self.deduplication:
|
|
129
|
+
self.deduplication.mark_as_processed(event.event_id)
|
|
130
|
+
|
|
131
|
+
await asyncio.to_thread(
|
|
132
|
+
self.client.delete_message,
|
|
133
|
+
QueueUrl=queue_url,
|
|
134
|
+
ReceiptHandle=msg['ReceiptHandle']
|
|
135
|
+
)
|
|
136
|
+
return event_name
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from os import _Environ
|
|
3
|
+
import dotenv
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config:
|
|
7
|
+
def __init__(self,
|
|
8
|
+
envs: _Environ[str] = None,
|
|
9
|
+
env_file: str = 'localhost.env',):
|
|
10
|
+
self.__envs = envs
|
|
11
|
+
self.__env_file = env_file
|
|
12
|
+
self.__load()
|
|
13
|
+
|
|
14
|
+
def set_envs(self, envs: _Environ[str], env_file: str = None):
|
|
15
|
+
"""Set the environment variables."""
|
|
16
|
+
self.__envs = envs
|
|
17
|
+
if env_file is not None:
|
|
18
|
+
self.__env_file = env_file
|
|
19
|
+
self.__load()
|
|
20
|
+
|
|
21
|
+
def __load(self):
|
|
22
|
+
if self.__envs is None:
|
|
23
|
+
dotenv.load_dotenv(self.__env_file)
|
|
24
|
+
self.__envs = os.environ
|
|
25
|
+
self.MAX_NUMBER_OF_MESSAGES = int(
|
|
26
|
+
self.__envs.get('MAX_NUMBER_OF_MESSAGES', 5))
|
|
27
|
+
self.WAIT_TIME_SECONDS = int(
|
|
28
|
+
self.__envs.get('WAIT_TIME_SECONDS', 10))
|
|
29
|
+
self.VISIBILITY_TIMEOUT = int(
|
|
30
|
+
self.__envs.get('VISIBILITY_TIMEOUT', 30))
|
|
31
|
+
self.AWS_CLIENT_URL = self.__envs.get('AWS_CLIENT_URL', None)
|
|
32
|
+
self.AWS_REGION_NAME = self.__envs.get('AWS_REGION_NAME', 'us-east-1')
|
|
33
|
+
self.SLEEP_BETWEEN_MESSAGES_SECONDS = int(
|
|
34
|
+
self.__envs.get('SLEEP_BETWEEN_MESSAGES_SECONDS', 0.1))
|
|
35
|
+
self.ERROR_SLEEP_SECONDS = int(
|
|
36
|
+
self.__envs.get('ERROR_SLEEP_SECONDS', 5))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .contracts.base_event import BaseEvent
|
|
2
|
+
from .contracts.deduplication import BaseDeduplication
|
|
3
|
+
from .contracts.handler import (BaseHandler, SyncHandler, AsyncHandler)
|
|
4
|
+
from .infrastructure.event_bus_publisher import EventBusPublisher
|
|
5
|
+
from .infrastructure.event_serializer import EventJsonSerializerDeserializer
|
|
6
|
+
from .runtime.event_register import EventHandlerRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseEvent",
|
|
11
|
+
"EventHandlerRegistry",
|
|
12
|
+
"EventBusPublisher",
|
|
13
|
+
"EventJsonSerializerDeserializer",
|
|
14
|
+
"EventRegistry",
|
|
15
|
+
"BaseHandler",
|
|
16
|
+
"SyncHandler",
|
|
17
|
+
"AsyncHandler",
|
|
18
|
+
"BaseDeduplication"
|
|
19
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import (datetime, timezone)
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__TOPIC_REGEX = r'^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\.\d+\.(command|event)\.[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$' # noqa: E501
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseEvent(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Base class for all events.
|
|
12
|
+
|
|
13
|
+
This class defines the interface for all events in the system.
|
|
14
|
+
It provides methods to convert events to and from dictionaries,
|
|
15
|
+
and to get the event type.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
event_name (str): The fully qualified name of the event topic.
|
|
19
|
+
Must follow the format:
|
|
20
|
+
[company].[service].[version].[message_type].[resource_name].[event_command_name]
|
|
21
|
+
Where:
|
|
22
|
+
- company (str): Name of the company (e.g., 'finkargo').
|
|
23
|
+
- service (str): Name of the service emitting the event (e.g., 'portfolio', 'documents').
|
|
24
|
+
- version (int): Numeric version of the event (e.g., 1, 2).
|
|
25
|
+
- message_type (str): Either 'command' or 'event'.
|
|
26
|
+
- resource_name (str): The resource/entity the event is related to (e.g., 'disbursement').
|
|
27
|
+
- event_command_name (str):
|
|
28
|
+
- If message_type is 'event': must be a verb in past tense (e.g., 'created', 'updated', 'deleted').
|
|
29
|
+
- If message_type is 'command': must be a verb in infinitive form (e.g., 'create', 'send').
|
|
30
|
+
event_id (str): A unique identifier for the event.
|
|
31
|
+
|
|
32
|
+
occurred_on (datetime): The timestamp indicating when the event occurred.
|
|
33
|
+
|
|
34
|
+
""" # noqa: E501
|
|
35
|
+
|
|
36
|
+
def __init__(self,
|
|
37
|
+
event_name: str,
|
|
38
|
+
event_id: str | None = None,
|
|
39
|
+
occurred_on: datetime | None = None):
|
|
40
|
+
self.event_name = event_name
|
|
41
|
+
self.event_id = uuid.uuid4().hex if event_id is None else event_id
|
|
42
|
+
self.occurred_on = datetime.now(
|
|
43
|
+
tz=timezone.utc) if occurred_on is None else occurred_on
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def from_dict(cls, event_id: str, occurred_on: datetime, attributes: dict) -> "BaseEvent": # noqa: E501
|
|
52
|
+
pass
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseDeduplication(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class for deduplication strategies.
|
|
7
|
+
This class defines the interface for deduplication strategies.
|
|
8
|
+
Subclasses should implement the `is_duplicate` method to check for duplicates
|
|
9
|
+
and the `mark_as_processed` method to mark events as processed.
|
|
10
|
+
Attributes:
|
|
11
|
+
event_id (str): The unique identifier for the event.
|
|
12
|
+
This is used to identify events and check for duplicates.
|
|
13
|
+
""" # noqa: E501
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
"""
|
|
17
|
+
Initialize the BaseDeduplication instance.
|
|
18
|
+
This method is called when an instance of a subclass is created.
|
|
19
|
+
It can be used to set up any necessary resources or configurations.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def is_duplicate(self, event_id: str) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Check if the given event ID is a duplicate.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
event_id (str): The event ID to check for duplication.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
bool: True if the event ID is a duplicate, False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def mark_as_processed(self, event_id: str):
|
|
36
|
+
"""
|
|
37
|
+
Mark the given event ID as processed.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
event_id (str): The event ID to mark as processed.
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from .. import BaseEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseHandler(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Abstract base class for event handlers.
|
|
9
|
+
This class defines the interface for handling events.
|
|
10
|
+
Subclasses should implement the `handle` method to process the event.
|
|
11
|
+
Attributes:
|
|
12
|
+
event_type (type[BaseEvent]):
|
|
13
|
+
The type of event that this handler can process.
|
|
14
|
+
"""
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def handle(self, data: BaseEvent) -> None:
|
|
17
|
+
"""Handle the event.
|
|
18
|
+
Args:
|
|
19
|
+
data (BaseEvent): The event data to handle.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SyncHandler(BaseHandler):
|
|
25
|
+
"""
|
|
26
|
+
Synchronous event handler.
|
|
27
|
+
This class implements the `handle` method to process events synchronously.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def handle(self, data: BaseEvent) -> None:
|
|
31
|
+
"""Handle the event synchronously.
|
|
32
|
+
Args:
|
|
33
|
+
data (BaseEvent): The event data to handle.
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncHandler(BaseHandler):
|
|
39
|
+
"""
|
|
40
|
+
Asynchronous event handler.
|
|
41
|
+
This class implements the `handle` method to process events asynchronously.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
async def handle(self, data: BaseEvent) -> None:
|
|
45
|
+
"""Handle the event asynchronously.
|
|
46
|
+
Args:
|
|
47
|
+
data (BaseEvent): The event data to handle.
|
|
48
|
+
"""
|
|
49
|
+
pass
|
|
50
|
+
# Implement asynchronous event handling logic here
|
|
51
|
+
# For example, you can process the event data and perform actions
|
|
52
|
+
# based on the event type and data.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from .. import BaseEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EventBusPublisher(ABC):
|
|
7
|
+
"""Abstract base class for event bus publishers.
|
|
8
|
+
This class defines the interface for publishing events to an event bus.
|
|
9
|
+
Subclasses should implement the `publish` and `publish_batch` methods
|
|
10
|
+
to publish events to the appropriate event bus
|
|
11
|
+
(e.g., RabbitMQ, Kafka, EventBridge, etc.).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def publish(self, event: BaseEvent) -> None:
|
|
16
|
+
"""Publish an event to the event bus.
|
|
17
|
+
This method should be implemented by subclasses to publish the event
|
|
18
|
+
to the appropriate event bus
|
|
19
|
+
(e.g., RabbitMQ, Kafka, EventBridge, etc.).
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def publish_batch(self, events: list[BaseEvent]) -> None:
|
|
25
|
+
"""Publish a batch of events to the event bus.
|
|
26
|
+
This method should be implemented by subclasses to publish the events
|
|
27
|
+
to the appropriate event bus
|
|
28
|
+
(e.g., RabbitMQ, Kafka, EventBridge, etc.).
|
|
29
|
+
"""
|
|
30
|
+
pass
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import get_type_hints
|
|
4
|
+
|
|
5
|
+
from .. import (BaseEvent, BaseHandler)
|
|
6
|
+
from ...exceptions import DeserializationEventError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EventJsonSerializerDeserializer:
|
|
10
|
+
"""A class for serializing and deserializing events to and from JSON
|
|
11
|
+
format. This class provides methods to convert event objects to JSON
|
|
12
|
+
strings and to create event objects from JSON strings.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def serialize(event: type[BaseEvent]) -> str:
|
|
17
|
+
"""Convert an event object to a JSON string.
|
|
18
|
+
Args:
|
|
19
|
+
event (BaseEvent): The event object to serialize.
|
|
20
|
+
Returns:
|
|
21
|
+
str: The JSON string representation of the event.
|
|
22
|
+
"""
|
|
23
|
+
if not isinstance(event, BaseEvent):
|
|
24
|
+
raise TypeError(
|
|
25
|
+
f"Expected an instance of BaseEvent, got {type(event)}"
|
|
26
|
+
)
|
|
27
|
+
return json.dumps(
|
|
28
|
+
{
|
|
29
|
+
'data': {
|
|
30
|
+
'id': event.event_id,
|
|
31
|
+
'type': event.event_name,
|
|
32
|
+
'occurred_on': event.occurred_on.isoformat(),
|
|
33
|
+
'attributes': event.to_dict(),
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
default=str,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def deserialize(event_json: str, handler: BaseHandler) -> BaseEvent:
|
|
41
|
+
"""Convert a JSON string to an event object.
|
|
42
|
+
Args:
|
|
43
|
+
event_json (str): The JSON string to deserialize.
|
|
44
|
+
handler (BaseHandler): The handler to determine the event type.
|
|
45
|
+
Returns:
|
|
46
|
+
BaseEvent: The event object created from the JSON string.
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(event_json, str):
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Expected a JSON string, got {type(event_json)}"
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
json_obj = json.loads(event_json)
|
|
54
|
+
except json.JSONDecodeError as e:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Invalid JSON format: {e.msg}"
|
|
57
|
+
) from e
|
|
58
|
+
if 'detail' not in json_obj or 'data' not in json_obj['detail']:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"Invalid JSON format: 'detail' or 'data' key not found."
|
|
61
|
+
)
|
|
62
|
+
data = EventJsonSerializerDeserializer._validate_and_extract_data(json_obj) # noqa: E501
|
|
63
|
+
attributes = data['attributes']
|
|
64
|
+
event_id = data['id']
|
|
65
|
+
occurred_on = datetime.fromisoformat(data['occurred_on'])
|
|
66
|
+
event_class = EventJsonSerializerDeserializer._get_event_type_from_handler(handler) # noqa: E501
|
|
67
|
+
try:
|
|
68
|
+
return event_class.from_dict(
|
|
69
|
+
attributes=attributes,
|
|
70
|
+
event_id=event_id,
|
|
71
|
+
occurred_on=occurred_on,
|
|
72
|
+
)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise DeserializationEventError(handler) from e
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _get_event_type_from_handler(handler: BaseHandler) -> type[BaseEvent]: # noqa: E501
|
|
78
|
+
"""Get the event type from the handler."""
|
|
79
|
+
if not isinstance(handler, BaseHandler):
|
|
80
|
+
raise TypeError(
|
|
81
|
+
f"Expected an instance of BaseHandler, got {type(handler)}"
|
|
82
|
+
)
|
|
83
|
+
cls = handler.__class__
|
|
84
|
+
|
|
85
|
+
method = getattr(cls, 'handle', None)
|
|
86
|
+
if method is None:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Handler {cls.__name__} does not have a 'handle' method.")
|
|
89
|
+
|
|
90
|
+
type_hints = get_type_hints(method)
|
|
91
|
+
event = type_hints.get('data', None)
|
|
92
|
+
if not issubclass(event, BaseEvent):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Handler {cls.__name__} does not handle a valid BaseEvent type." # noqa: E501
|
|
95
|
+
)
|
|
96
|
+
return event
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _validate_and_extract_data(json_obj: dict) -> dict:
|
|
100
|
+
"""Validate and extract data from the detail dictionary."""
|
|
101
|
+
if 'detail' not in json_obj or 'data' not in json_obj['detail']:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
'Invalid JSON format: "detail" or "data" key not found.' # noqa: E501
|
|
104
|
+
)
|
|
105
|
+
data = json_obj['detail']['data']
|
|
106
|
+
missing_keys = [key for key in ['attributes',
|
|
107
|
+
'id', 'occurred_on'] if key not in data] # noqa: E501
|
|
108
|
+
if missing_keys:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
'Invalid JSON format: Missing keys in "data": ' + ', '.join(missing_keys) # noqa: E501
|
|
111
|
+
)
|
|
112
|
+
return data
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import redis
|
|
2
|
+
from ..contracts.deduplication import BaseDeduplication
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RedisDeduplication(BaseDeduplication):
|
|
6
|
+
"""
|
|
7
|
+
Deduplication strategy using Redis.
|
|
8
|
+
This class implements the deduplication strategy using Redis as the backend.
|
|
9
|
+
It checks for duplicates and marks events as processed using Redis.
|
|
10
|
+
""" # noqa: E501
|
|
11
|
+
|
|
12
|
+
def __init__(self, url: str, ttl: int = 3600):
|
|
13
|
+
"""
|
|
14
|
+
Initialize the RedisDeduplication instance.
|
|
15
|
+
Args:
|
|
16
|
+
url (str): The Redis server URL. This should be a valid Redis URL
|
|
17
|
+
in the format 'redis://[:password]@hostname:port/db_number'.
|
|
18
|
+
Review the Redis documentation for more details on the URL format.
|
|
19
|
+
ttl (int): Time to live for the event in seconds.
|
|
20
|
+
Default is 3600 seconds (1 hour).
|
|
21
|
+
"""
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.redis_client = redis.Redis.from_url(url)
|
|
24
|
+
self.ttl = ttl
|
|
25
|
+
|
|
26
|
+
def is_duplicate(self, event_id: str) -> bool:
|
|
27
|
+
return self.redis_client.exists(event_id) > 0
|
|
28
|
+
|
|
29
|
+
def mark_as_processed(self, event_id: str):
|
|
30
|
+
self.redis_client.set(event_id, "processed", ex=self.ttl)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .. import BaseHandler
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EventHandlerRegistry:
|
|
5
|
+
"""Registry for event handlers.
|
|
6
|
+
This class is responsible for registering event handlers and
|
|
7
|
+
providing access to them.
|
|
8
|
+
"""
|
|
9
|
+
_registry: dict[str, BaseHandler] = {}
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def register(cls, queue_url: str, handler: BaseHandler):
|
|
13
|
+
"""Register an event handler with the registry.
|
|
14
|
+
Args:
|
|
15
|
+
queue_url (str): The URL of the queue to which the handler is
|
|
16
|
+
associated.
|
|
17
|
+
handler (BaseHandler): The event handler to register.
|
|
18
|
+
"""
|
|
19
|
+
cls._registry[queue_url] = handler
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def get_handlers(cls) -> dict[str, BaseHandler]:
|
|
23
|
+
"""Get all registered event handlers.
|
|
24
|
+
Returns:
|
|
25
|
+
dict[str, BaseHandler]: A dictionary mapping queue URLs to
|
|
26
|
+
their associated event handlers.
|
|
27
|
+
"""
|
|
28
|
+
return cls._registry
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .core.contracts.handler import BaseHandler
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HandlerExecutionError(BaseException):
|
|
5
|
+
"""
|
|
6
|
+
Exception raised when an event handler fails to execute.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, handler: BaseHandler, event_name: str):
|
|
10
|
+
super().__init__()
|
|
11
|
+
self.handler = handler
|
|
12
|
+
self.event_name = event_name
|
|
13
|
+
|
|
14
|
+
def __str__(self):
|
|
15
|
+
return (
|
|
16
|
+
f"Error processing event {self.event_name} with handler "
|
|
17
|
+
f"{self.handler.__class__.__name__}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeserializationEventError(BaseException):
|
|
22
|
+
"""
|
|
23
|
+
Exception raised when an event cannot be deserialized.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, handler: BaseHandler):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.handler = handler
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return f'Error deserializing event with handler {self.handler.__class__.__name__}' # noqa: E501
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "package-events-bus"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Una librería para construir microservicios desacoplados en Python usando AWS EventBridge y SQS, donde cada caso de uso maneja su propia cola y se despacha automáticamente mediante handlers tipados."
|
|
5
|
+
authors = ["Jose Luis Rosales Meza <jose.rosales@finkargo.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
|
|
9
|
+
[[tool.poetry.packages]]
|
|
10
|
+
include = "events_bus"
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
python = "^3.10"
|
|
14
|
+
boto3 = "^1.38.13"
|
|
15
|
+
python-dotenv = "^1.1.0"
|
|
16
|
+
redis = "^5.0"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["poetry-core"]
|
|
20
|
+
build-backend = "poetry.core.masonry.api"
|