use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SagaDefinition {
pub saga: String,
#[serde(default = "default_saga_version")]
pub version: u32,
pub steps: Vec<SagaStep>,
}
fn default_saga_version() -> u32 {
1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SagaStep {
pub name: String,
pub service: String,
pub action: String,
pub compensate: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<serde_json::Value>,
#[serde(default = "default_step_timeout")]
pub timeout_secs: u64,
}
fn default_step_timeout() -> u64 {
30
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SagaExecutionStatus {
Running,
Completed,
Compensating,
Compensated,
Failed,
}
impl std::fmt::Display for SagaExecutionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Compensating => write!(f, "compensating"),
Self::Compensated => write!(f, "compensated"),
Self::Failed => write!(f, "failed"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saga_definition_minimal() {
let json = r#"{
"saga": "create_order",
"steps": [
{
"name": "reserve_inventory",
"service": "inventory-api",
"action": "POST /v1/reservations",
"compensate": "DELETE /v1/reservations/:id"
}
]
}"#;
let saga: SagaDefinition = serde_json::from_str(json).unwrap();
assert_eq!(saga.saga, "create_order");
assert_eq!(saga.version, 1);
assert_eq!(saga.steps.len(), 1);
assert_eq!(saga.steps[0].timeout_secs, 30);
}
#[test]
fn saga_definition_full() {
let json = r#"{
"saga": "create_order",
"version": 2,
"steps": [
{
"name": "reserve",
"service": "inventory-api",
"action": "POST /v1/reservations",
"compensate": "DELETE /v1/reservations/:id",
"input": {"product_id": "from:order.product_id"},
"timeout_secs": 5
},
{
"name": "charge",
"service": "payments-api",
"action": "POST /v1/charges",
"compensate": "POST /v1/charges/:id/refund",
"timeout_secs": 10
}
]
}"#;
let saga: SagaDefinition = serde_json::from_str(json).unwrap();
assert_eq!(saga.version, 2);
assert_eq!(saga.steps.len(), 2);
assert_eq!(saga.steps[0].timeout_secs, 5);
assert!(saga.steps[0].input.is_some());
assert_eq!(saga.steps[1].service, "payments-api");
}
#[test]
fn saga_definition_unknown_field_fails() {
let json = r#"{
"saga": "test",
"steps": [],
"unknown": true
}"#;
let err = serde_json::from_str::<SagaDefinition>(json);
assert!(err.is_err());
}
#[test]
fn saga_definition_serde_roundtrip() {
let saga = SagaDefinition {
saga: "roundtrip".to_string(),
version: 1,
steps: vec![
SagaStep {
name: "step1".to_string(),
service: "svc-a".to_string(),
action: "POST /v1/items".to_string(),
compensate: "DELETE /v1/items/:id".to_string(),
input: None,
timeout_secs: 30,
},
SagaStep {
name: "step2".to_string(),
service: "svc-b".to_string(),
action: "POST /v1/records".to_string(),
compensate: "DELETE /v1/records/:id".to_string(),
input: Some(serde_json::json!({"key": "value"})),
timeout_secs: 15,
},
],
};
let json = serde_json::to_string(&saga).unwrap();
let back: SagaDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(saga, back);
}
#[test]
fn saga_execution_status_display() {
assert_eq!(SagaExecutionStatus::Running.to_string(), "running");
assert_eq!(SagaExecutionStatus::Completed.to_string(), "completed");
assert_eq!(
SagaExecutionStatus::Compensating.to_string(),
"compensating"
);
assert_eq!(SagaExecutionStatus::Compensated.to_string(), "compensated");
assert_eq!(SagaExecutionStatus::Failed.to_string(), "failed");
}
#[test]
fn saga_step_defaults() {
let json = r#"{
"name": "step",
"service": "svc",
"action": "POST /v1/x",
"compensate": "DELETE /v1/x/:id"
}"#;
let step: SagaStep = serde_json::from_str(json).unwrap();
assert_eq!(step.timeout_secs, 30);
assert!(step.input.is_none());
}
}