feat: event infrastructure — payload, transport, NATS adapter
- EventPublisher now takes &DomainEvent (11 call sites + 3 impls updated) - EventEnvelope + EventConsumer port in domain - event-payload: serializable DomainEvent mirror with subject routing - event-transport: generic Transport/MessageSource traits, publisher/consumer adapters - adapters-nats: JetStream publish + durable pull consumer
This commit is contained in:
252
crates/adapters/event-payload/src/lib.rs
Normal file
252
crates/adapters/event-payload/src/lib.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use domain::{errors::DomainError, events::DomainEvent, value_objects::SystemId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum EventPayload {
|
||||
AssetIngested {
|
||||
asset_id: String,
|
||||
owner_user_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
MetadataUpdated {
|
||||
asset_id: String,
|
||||
updated_by: String,
|
||||
timestamp: String,
|
||||
},
|
||||
AssetDeleted {
|
||||
asset_id: String,
|
||||
deleted_by: String,
|
||||
timestamp: String,
|
||||
},
|
||||
ShareCreated {
|
||||
scope_id: String,
|
||||
shareable_id: String,
|
||||
created_by: String,
|
||||
timestamp: String,
|
||||
},
|
||||
ShareRevoked {
|
||||
scope_id: String,
|
||||
revoked_by: String,
|
||||
timestamp: String,
|
||||
},
|
||||
SidecarSyncRequested {
|
||||
asset_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
JobEnqueued {
|
||||
job_id: String,
|
||||
job_type: String,
|
||||
timestamp: String,
|
||||
},
|
||||
JobCompleted {
|
||||
job_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
JobFailed {
|
||||
job_id: String,
|
||||
error: String,
|
||||
timestamp: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
pub fn subject(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AssetIngested { .. } => "assets.ingested",
|
||||
Self::MetadataUpdated { .. } => "metadata.updated",
|
||||
Self::AssetDeleted { .. } => "assets.deleted",
|
||||
Self::ShareCreated { .. } => "shares.created",
|
||||
Self::ShareRevoked { .. } => "shares.revoked",
|
||||
Self::SidecarSyncRequested { .. } => "sidecars.sync_requested",
|
||||
Self::JobEnqueued { .. } => "jobs.enqueued",
|
||||
Self::JobCompleted { .. } => "jobs.completed",
|
||||
Self::JobFailed { .. } => "jobs.failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DomainEvent> for EventPayload {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
DomainEvent::AssetIngested {
|
||||
asset_id,
|
||||
owner_user_id,
|
||||
timestamp,
|
||||
} => Self::AssetIngested {
|
||||
asset_id: asset_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::MetadataUpdated {
|
||||
asset_id,
|
||||
updated_by,
|
||||
timestamp,
|
||||
} => Self::MetadataUpdated {
|
||||
asset_id: asset_id.to_string(),
|
||||
updated_by: updated_by.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::AssetDeleted {
|
||||
asset_id,
|
||||
deleted_by,
|
||||
timestamp,
|
||||
} => Self::AssetDeleted {
|
||||
asset_id: asset_id.to_string(),
|
||||
deleted_by: deleted_by.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::ShareCreated {
|
||||
scope_id,
|
||||
shareable_id,
|
||||
created_by,
|
||||
timestamp,
|
||||
} => Self::ShareCreated {
|
||||
scope_id: scope_id.to_string(),
|
||||
shareable_id: shareable_id.to_string(),
|
||||
created_by: created_by.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::ShareRevoked {
|
||||
scope_id,
|
||||
revoked_by,
|
||||
timestamp,
|
||||
} => Self::ShareRevoked {
|
||||
scope_id: scope_id.to_string(),
|
||||
revoked_by: revoked_by.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::SidecarSyncRequested {
|
||||
asset_id,
|
||||
timestamp,
|
||||
} => Self::SidecarSyncRequested {
|
||||
asset_id: asset_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::JobEnqueued {
|
||||
job_id,
|
||||
job_type,
|
||||
timestamp,
|
||||
} => Self::JobEnqueued {
|
||||
job_id: job_id.to_string(),
|
||||
job_type: job_type.clone(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::JobCompleted { job_id, timestamp } => Self::JobCompleted {
|
||||
job_id: job_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::JobFailed {
|
||||
job_id,
|
||||
error,
|
||||
timestamp,
|
||||
} => Self::JobFailed {
|
||||
job_id: job_id.to_string(),
|
||||
error: error.clone(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
|
||||
}
|
||||
|
||||
fn parse_timestamp(s: &str) -> Result<domain::value_objects::DateTimeStamp, DomainError> {
|
||||
use chrono::DateTime;
|
||||
let dt = DateTime::parse_from_rfc3339(s)
|
||||
.map_err(|_| DomainError::Internal(format!("invalid timestamp: {s}")))?;
|
||||
Ok(domain::value_objects::DateTimeStamp::from_datetime(
|
||||
dt.with_timezone(&chrono::Utc),
|
||||
))
|
||||
}
|
||||
|
||||
impl TryFrom<EventPayload> for DomainEvent {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
|
||||
Ok(match p {
|
||||
EventPayload::AssetIngested {
|
||||
asset_id,
|
||||
owner_user_id,
|
||||
timestamp,
|
||||
} => DomainEvent::AssetIngested {
|
||||
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||
owner_user_id: SystemId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::MetadataUpdated {
|
||||
asset_id,
|
||||
updated_by,
|
||||
timestamp,
|
||||
} => DomainEvent::MetadataUpdated {
|
||||
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||
updated_by: SystemId::from_uuid(parse_uuid(&updated_by, "updated_by")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::AssetDeleted {
|
||||
asset_id,
|
||||
deleted_by,
|
||||
timestamp,
|
||||
} => DomainEvent::AssetDeleted {
|
||||
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||
deleted_by: SystemId::from_uuid(parse_uuid(&deleted_by, "deleted_by")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::ShareCreated {
|
||||
scope_id,
|
||||
shareable_id,
|
||||
created_by,
|
||||
timestamp,
|
||||
} => DomainEvent::ShareCreated {
|
||||
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
|
||||
shareable_id: SystemId::from_uuid(parse_uuid(&shareable_id, "shareable_id")?),
|
||||
created_by: SystemId::from_uuid(parse_uuid(&created_by, "created_by")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::ShareRevoked {
|
||||
scope_id,
|
||||
revoked_by,
|
||||
timestamp,
|
||||
} => DomainEvent::ShareRevoked {
|
||||
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
|
||||
revoked_by: SystemId::from_uuid(parse_uuid(&revoked_by, "revoked_by")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::SidecarSyncRequested {
|
||||
asset_id,
|
||||
timestamp,
|
||||
} => DomainEvent::SidecarSyncRequested {
|
||||
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::JobEnqueued {
|
||||
job_id,
|
||||
job_type,
|
||||
timestamp,
|
||||
} => DomainEvent::JobEnqueued {
|
||||
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
|
||||
job_type,
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::JobCompleted { job_id, timestamp } => DomainEvent::JobCompleted {
|
||||
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::JobFailed {
|
||||
job_id,
|
||||
error,
|
||||
timestamp,
|
||||
} => DomainEvent::JobFailed {
|
||||
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
|
||||
error,
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
89
crates/adapters/event-payload/src/tests.rs
Normal file
89
crates/adapters/event-payload/src/tests.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use crate::EventPayload;
|
||||
use domain::{events::DomainEvent, value_objects::SystemId};
|
||||
|
||||
fn make_timestamp() -> domain::value_objects::DateTimeStamp {
|
||||
domain::value_objects::DateTimeStamp::now()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_mapping() {
|
||||
let cases = vec![
|
||||
(
|
||||
DomainEvent::AssetIngested {
|
||||
asset_id: SystemId::new(),
|
||||
owner_user_id: SystemId::new(),
|
||||
timestamp: make_timestamp(),
|
||||
},
|
||||
"assets.ingested",
|
||||
),
|
||||
(
|
||||
DomainEvent::JobEnqueued {
|
||||
job_id: SystemId::new(),
|
||||
job_type: "extract_metadata".into(),
|
||||
timestamp: make_timestamp(),
|
||||
},
|
||||
"jobs.enqueued",
|
||||
),
|
||||
(
|
||||
DomainEvent::JobFailed {
|
||||
job_id: SystemId::new(),
|
||||
error: "boom".into(),
|
||||
timestamp: make_timestamp(),
|
||||
},
|
||||
"jobs.failed",
|
||||
),
|
||||
];
|
||||
|
||||
for (event, expected_subject) in cases {
|
||||
let payload = EventPayload::from(&event);
|
||||
assert_eq!(payload.subject(), expected_subject);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_asset_ingested() {
|
||||
let id = SystemId::new();
|
||||
let owner = SystemId::new();
|
||||
let event = DomainEvent::AssetIngested {
|
||||
asset_id: id,
|
||||
owner_user_id: owner,
|
||||
timestamp: make_timestamp(),
|
||||
};
|
||||
|
||||
let payload = EventPayload::from(&event);
|
||||
let json = serde_json::to_vec(&payload).unwrap();
|
||||
let back: EventPayload = serde_json::from_slice(&json).unwrap();
|
||||
let restored = DomainEvent::try_from(back).unwrap();
|
||||
|
||||
if let DomainEvent::AssetIngested {
|
||||
asset_id,
|
||||
owner_user_id,
|
||||
..
|
||||
} = restored
|
||||
{
|
||||
assert_eq!(asset_id, id);
|
||||
assert_eq!(owner_user_id, owner);
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_job_failed() {
|
||||
let jid = SystemId::new();
|
||||
let event = DomainEvent::JobFailed {
|
||||
job_id: jid,
|
||||
error: "plugin crashed".into(),
|
||||
timestamp: make_timestamp(),
|
||||
};
|
||||
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = DomainEvent::try_from(payload).unwrap();
|
||||
|
||||
if let DomainEvent::JobFailed { job_id, error, .. } = back {
|
||||
assert_eq!(job_id, jid);
|
||||
assert_eq!(error, "plugin crashed");
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user