1pub mod delivery;
6pub mod handler;
7pub mod transport;
8
9use std::collections::BTreeMap;
10
11use chrono::{DateTime, Utc};
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14use thiserror::Error;
15use uuid::Uuid;
16
17pub use handler::{
18 EventHandler, HandlerOutcome, REASON_MAX_DELIVERIES_EXCEEDED, REASON_PAYLOAD_INVALID,
19 REASON_PERMANENT_ERROR,
20};
21
22pub trait DomainEvent {
25 fn event_type(&self) -> &'static str;
27 fn aggregate_type(&self) -> &'static str;
29 fn aggregate_id(&self) -> Uuid;
30 fn schema_version(&self) -> u16;
32 fn payload(&self) -> Value;
33}
34
35#[non_exhaustive]
41#[derive(Debug, Clone, PartialEq)]
42pub struct EventEnvelope {
43 pub id: Uuid,
44 pub event_type: String,
45 pub aggregate_type: String,
46 pub aggregate_id: Uuid,
47 pub schema_version: u16,
48 pub occurred_at: DateTime<Utc>,
49 pub payload: Value,
50 pub headers: BTreeMap<String, String>,
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct EnvelopeParts {
57 pub id: Uuid,
58 pub event_type: String,
59 pub aggregate_type: String,
60 pub aggregate_id: Uuid,
61 pub schema_version: u16,
62 pub occurred_at: DateTime<Utc>,
63 pub payload: Value,
64 pub headers: BTreeMap<String, String>,
65}
66
67impl EventEnvelope {
68 pub fn new(event: &impl DomainEvent) -> Self {
70 Self {
71 id: Uuid::now_v7(),
72 event_type: event.event_type().to_string(),
73 aggregate_type: event.aggregate_type().to_string(),
74 aggregate_id: event.aggregate_id(),
75 schema_version: event.schema_version(),
76 occurred_at: Utc::now(),
77 payload: event.payload(),
78 headers: BTreeMap::new(),
79 }
80 }
81
82 pub fn from_parts(parts: EnvelopeParts) -> Self {
83 Self {
84 id: parts.id,
85 event_type: parts.event_type,
86 aggregate_type: parts.aggregate_type,
87 aggregate_id: parts.aggregate_id,
88 schema_version: parts.schema_version,
89 occurred_at: parts.occurred_at,
90 payload: parts.payload,
91 headers: parts.headers,
92 }
93 }
94
95 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
96 self.headers.insert(key.into(), value.into());
97 self
98 }
99
100 pub fn decode<T: DeserializeOwned>(&self) -> Result<T, DecodeError> {
102 serde_json::from_value(self.payload.clone()).map_err(|err| DecodeError {
103 event_type: self.event_type.clone(),
104 detail: err.to_string(),
105 })
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Error)]
112#[error("event.payload_invalid")]
113pub struct DecodeError {
114 pub event_type: String,
115 pub detail: String,
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use serde::{Deserialize, Serialize};
123 use serde_json::json;
124
125 #[derive(Debug, PartialEq, Serialize, Deserialize)]
126 struct ProductSnapshot {
127 id: Uuid,
128 name: String,
129 price: i64,
130 }
131
132 struct ProductCreated(ProductSnapshot);
133
134 impl DomainEvent for ProductCreated {
135 fn event_type(&self) -> &'static str {
136 "product_created"
137 }
138 fn aggregate_type(&self) -> &'static str {
139 "product"
140 }
141 fn aggregate_id(&self) -> Uuid {
142 self.0.id
143 }
144 fn schema_version(&self) -> u16 {
145 3
146 }
147 fn payload(&self) -> Value {
148 serde_json::to_value(&self.0).unwrap_or(Value::Null)
149 }
150 }
151
152 fn product_created() -> ProductCreated {
153 ProductCreated(ProductSnapshot {
154 id: Uuid::now_v7(),
155 name: "Desk lamp".into(),
156 price: 2_500,
157 })
158 }
159
160 #[test]
161 fn should_carry_the_event_metadata_when_built_from_a_domain_event() {
162 let event = product_created();
164 let before = Utc::now();
165
166 let envelope = EventEnvelope::new(&event);
168
169 assert_eq!(envelope.event_type, "product_created");
171 assert_eq!(envelope.aggregate_type, "product");
172 assert_eq!(envelope.aggregate_id, event.0.id);
173 assert_eq!(envelope.schema_version, 3);
174 assert_eq!(envelope.payload, event.payload());
175 assert!(envelope.headers.is_empty());
176 assert!(envelope.occurred_at >= before && envelope.occurred_at <= Utc::now());
177 assert_eq!(envelope.id.get_version_num(), 7);
178 }
179
180 #[test]
181 fn should_give_every_envelope_its_own_id_when_built_from_the_same_event() {
182 let event = product_created();
183 assert_ne!(EventEnvelope::new(&event).id, EventEnvelope::new(&event).id);
184 }
185
186 #[test]
187 fn should_keep_every_field_when_restored_from_its_parts() {
188 let parts = EnvelopeParts {
189 id: Uuid::now_v7(),
190 event_type: "product_updated".into(),
191 aggregate_type: "product".into(),
192 aggregate_id: Uuid::now_v7(),
193 schema_version: 2,
194 occurred_at: Utc::now(),
195 payload: json!({ "name": "Desk lamp" }),
196 headers: BTreeMap::from([("correlation_id".into(), "req-1".into())]),
197 };
198
199 let envelope = EventEnvelope::from_parts(parts.clone());
200
201 assert_eq!(envelope.id, parts.id);
202 assert_eq!(envelope.event_type, parts.event_type);
203 assert_eq!(envelope.aggregate_type, parts.aggregate_type);
204 assert_eq!(envelope.aggregate_id, parts.aggregate_id);
205 assert_eq!(envelope.schema_version, parts.schema_version);
206 assert_eq!(envelope.occurred_at, parts.occurred_at);
207 assert_eq!(envelope.payload, parts.payload);
208 assert_eq!(envelope.headers, parts.headers);
209 }
210
211 #[test]
212 fn should_add_a_header_when_one_is_given() {
213 let envelope = EventEnvelope::new(&product_created())
214 .with_header("correlation_id", "req-1")
215 .with_header("correlation_id", "req-2");
216
217 assert_eq!(
218 envelope.headers,
219 BTreeMap::from([("correlation_id".to_string(), "req-2".to_string())])
220 );
221 }
222
223 #[test]
224 fn should_round_trip_the_payload_when_decoded_as_its_snapshot() {
225 let event = product_created();
226 let envelope = EventEnvelope::new(&event);
227
228 let decoded = envelope.decode::<ProductSnapshot>();
229
230 assert_eq!(decoded, Ok(event.0));
231 }
232
233 #[test]
234 fn should_reject_the_payload_when_decoded_as_another_shape() {
235 #[derive(Debug, Deserialize)]
236 #[allow(dead_code)]
237 struct OrderSnapshot {
238 total: i64,
239 }
240 let envelope = EventEnvelope::new(&product_created());
241
242 let result = envelope.decode::<OrderSnapshot>();
243
244 match result {
245 Err(err) => {
246 assert_eq!(err.to_string(), "event.payload_invalid");
247 assert_eq!(err.event_type, "product_created");
248 assert!(err.detail.contains("total"), "detail: {}", err.detail);
249 }
250 Ok(other) => panic!("decoded as another shape: {other:?}"),
251 }
252 }
253}