pub mod delivery;
pub mod handler;
pub mod transport;
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::de::DeserializeOwned;
use serde_json::Value;
use thiserror::Error;
use uuid::Uuid;
pub use handler::{
EventHandler, HandlerOutcome, REASON_MAX_DELIVERIES_EXCEEDED, REASON_PAYLOAD_INVALID,
REASON_PERMANENT_ERROR,
};
pub trait DomainEvent {
fn event_type(&self) -> &'static str;
fn aggregate_type(&self) -> &'static str;
fn aggregate_id(&self) -> Uuid;
fn schema_version(&self) -> u16;
fn payload(&self) -> Value;
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct EventEnvelope {
pub id: Uuid,
pub event_type: String,
pub aggregate_type: String,
pub aggregate_id: Uuid,
pub schema_version: u16,
pub occurred_at: DateTime<Utc>,
pub payload: Value,
pub headers: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EnvelopeParts {
pub id: Uuid,
pub event_type: String,
pub aggregate_type: String,
pub aggregate_id: Uuid,
pub schema_version: u16,
pub occurred_at: DateTime<Utc>,
pub payload: Value,
pub headers: BTreeMap<String, String>,
}
impl EventEnvelope {
pub fn new(event: &impl DomainEvent) -> Self {
Self {
id: Uuid::now_v7(),
event_type: event.event_type().to_string(),
aggregate_type: event.aggregate_type().to_string(),
aggregate_id: event.aggregate_id(),
schema_version: event.schema_version(),
occurred_at: Utc::now(),
payload: event.payload(),
headers: BTreeMap::new(),
}
}
pub fn from_parts(parts: EnvelopeParts) -> Self {
Self {
id: parts.id,
event_type: parts.event_type,
aggregate_type: parts.aggregate_type,
aggregate_id: parts.aggregate_id,
schema_version: parts.schema_version,
occurred_at: parts.occurred_at,
payload: parts.payload,
headers: parts.headers,
}
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
pub fn decode<T: DeserializeOwned>(&self) -> Result<T, DecodeError> {
serde_json::from_value(self.payload.clone()).map_err(|err| DecodeError {
event_type: self.event_type.clone(),
detail: err.to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("event.payload_invalid")]
pub struct DecodeError {
pub event_type: String,
pub detail: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct ProductSnapshot {
id: Uuid,
name: String,
price: i64,
}
struct ProductCreated(ProductSnapshot);
impl DomainEvent for ProductCreated {
fn event_type(&self) -> &'static str {
"product_created"
}
fn aggregate_type(&self) -> &'static str {
"product"
}
fn aggregate_id(&self) -> Uuid {
self.0.id
}
fn schema_version(&self) -> u16 {
3
}
fn payload(&self) -> Value {
serde_json::to_value(&self.0).unwrap_or(Value::Null)
}
}
fn product_created() -> ProductCreated {
ProductCreated(ProductSnapshot {
id: Uuid::now_v7(),
name: "Desk lamp".into(),
price: 2_500,
})
}
#[test]
fn should_carry_the_event_metadata_when_built_from_a_domain_event() {
let event = product_created();
let before = Utc::now();
let envelope = EventEnvelope::new(&event);
assert_eq!(envelope.event_type, "product_created");
assert_eq!(envelope.aggregate_type, "product");
assert_eq!(envelope.aggregate_id, event.0.id);
assert_eq!(envelope.schema_version, 3);
assert_eq!(envelope.payload, event.payload());
assert!(envelope.headers.is_empty());
assert!(envelope.occurred_at >= before && envelope.occurred_at <= Utc::now());
assert_eq!(envelope.id.get_version_num(), 7);
}
#[test]
fn should_give_every_envelope_its_own_id_when_built_from_the_same_event() {
let event = product_created();
assert_ne!(EventEnvelope::new(&event).id, EventEnvelope::new(&event).id);
}
#[test]
fn should_keep_every_field_when_restored_from_its_parts() {
let parts = EnvelopeParts {
id: Uuid::now_v7(),
event_type: "product_updated".into(),
aggregate_type: "product".into(),
aggregate_id: Uuid::now_v7(),
schema_version: 2,
occurred_at: Utc::now(),
payload: json!({ "name": "Desk lamp" }),
headers: BTreeMap::from([("correlation_id".into(), "req-1".into())]),
};
let envelope = EventEnvelope::from_parts(parts.clone());
assert_eq!(envelope.id, parts.id);
assert_eq!(envelope.event_type, parts.event_type);
assert_eq!(envelope.aggregate_type, parts.aggregate_type);
assert_eq!(envelope.aggregate_id, parts.aggregate_id);
assert_eq!(envelope.schema_version, parts.schema_version);
assert_eq!(envelope.occurred_at, parts.occurred_at);
assert_eq!(envelope.payload, parts.payload);
assert_eq!(envelope.headers, parts.headers);
}
#[test]
fn should_add_a_header_when_one_is_given() {
let envelope = EventEnvelope::new(&product_created())
.with_header("correlation_id", "req-1")
.with_header("correlation_id", "req-2");
assert_eq!(
envelope.headers,
BTreeMap::from([("correlation_id".to_string(), "req-2".to_string())])
);
}
#[test]
fn should_round_trip_the_payload_when_decoded_as_its_snapshot() {
let event = product_created();
let envelope = EventEnvelope::new(&event);
let decoded = envelope.decode::<ProductSnapshot>();
assert_eq!(decoded, Ok(event.0));
}
#[test]
fn should_reject_the_payload_when_decoded_as_another_shape() {
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct OrderSnapshot {
total: i64,
}
let envelope = EventEnvelope::new(&product_created());
let result = envelope.decode::<OrderSnapshot>();
match result {
Err(err) => {
assert_eq!(err.to_string(), "event.payload_invalid");
assert_eq!(err.event_type, "product_created");
assert!(err.detail.contains("total"), "detail: {}", err.detail);
}
Ok(other) => panic!("decoded as another shape: {other:?}"),
}
}
}