Skip to main content

qefro_backend_sdk/
business_events.rs

1//! Business Events an SDK connection may emit into the Qefro event bus.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct BusinessEventField {
9    pub path: String,
10    pub label: String,
11    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
12    pub field_type: Option<String>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct BusinessEventDefinition {
17    pub event_type: String,
18    #[serde(default = "default_version")]
19    pub version: u32,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub label: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub description: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub schema: Option<Value>,
26}
27
28fn default_version() -> u32 {
29    1
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct EmittedBusinessEvent {
34    pub event_type: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub version: Option<u32>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub event_id: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub customer: Option<Value>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub data: Option<Value>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub timestamp: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum BusinessEventError {
49    Message(String),
50}
51
52impl std::fmt::Display for BusinessEventError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            BusinessEventError::Message(m) => write!(f, "{m}"),
56        }
57    }
58}
59
60impl std::error::Error for BusinessEventError {}
61
62fn err(msg: impl Into<String>) -> BusinessEventError {
63    BusinessEventError::Message(msg.into())
64}
65
66pub fn is_business_event_type(name: &str) -> bool {
67    let t = name.trim().to_ascii_lowercase();
68    if t.contains('(') {
69        return false;
70    }
71    let mut chars = t.chars();
72    let Some(first) = chars.next() else {
73        return false;
74    };
75    if !first.is_ascii_lowercase() {
76        return false;
77    }
78    let rest: String = chars.collect();
79    if !rest
80        .chars()
81        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.')
82    {
83        return false;
84    }
85    let parts: Vec<&str> = t.split('.').collect();
86    parts.len() >= 2 && parts.iter().all(|p| !p.is_empty())
87}
88
89pub fn normalize_business_event(
90    def: BusinessEventDefinition,
91) -> Result<BusinessEventDefinition, BusinessEventError> {
92    let event_type = def.event_type.trim().to_ascii_lowercase();
93    if !is_business_event_type(&event_type) {
94        return Err(err(format!(
95            "businessEvent() requires a Business Event name such as quotation.created (not a capability like createQuotation); got \"{}\"",
96            def.event_type
97        )));
98    }
99    let version = if def.version > 0 { def.version } else { 1 };
100    let label = def
101        .label
102        .map(|s| s.trim().to_string())
103        .filter(|s| !s.is_empty());
104    let description = def
105        .description
106        .map(|s| s.trim().to_string())
107        .filter(|s| !s.is_empty());
108    Ok(BusinessEventDefinition {
109        event_type,
110        version,
111        label,
112        description,
113        schema: def.schema,
114    })
115}
116
117/// Stable id within a source: `{event_type}:{entity_id}`.
118pub fn stable_event_id(event_type: &str, entity_id: &str) -> Result<String, BusinessEventError> {
119    let type_ = event_type.trim().to_ascii_lowercase();
120    let id = entity_id.trim();
121    if id.is_empty() {
122        return Err(err("stableEventId requires an entity id"));
123    }
124    if type_.is_empty() {
125        return Ok(id.to_string());
126    }
127    let prefix = format!("{type_}:");
128    if id.starts_with(&prefix) {
129        Ok(id.to_string())
130    } else {
131        Ok(format!("{prefix}{id}"))
132    }
133}
134
135pub fn normalize_emitted_event(
136    event: EmittedBusinessEvent,
137    declared: Option<&BusinessEventDefinition>,
138) -> Result<EmittedBusinessEvent, BusinessEventError> {
139    let event_type = event.event_type.trim().to_ascii_lowercase();
140    if !is_business_event_type(&event_type) {
141        return Err(err(format!(
142            "ctx.emit() requires a Business Event such as quotation.created, not a capability; got \"{}\"",
143            event.event_type
144        )));
145    }
146    let version = match event.version {
147        Some(v) if v > 0 => v,
148        _ => declared.map(|d| d.version).filter(|v| *v > 0).unwrap_or(1),
149    };
150    let raw_id = event
151        .event_id
152        .as_deref()
153        .unwrap_or("")
154        .trim()
155        .to_string();
156    let event_id = if raw_id.is_empty() {
157        format!("evt_{}", Uuid::new_v4())
158    } else {
159        stable_event_id(&event_type, &raw_id)?
160    };
161    Ok(EmittedBusinessEvent {
162        event_type,
163        version: Some(version),
164        event_id: Some(event_id),
165        customer: event.customer,
166        data: event.data,
167        timestamp: event.timestamp,
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn stable_ids_are_unique_per_type() {
177        assert_eq!(
178            stable_event_id("quotation.created", "Q-1001").unwrap(),
179            "quotation.created:Q-1001"
180        );
181        assert_eq!(
182            stable_event_id("order.created", "Q-1001").unwrap(),
183            "order.created:Q-1001"
184        );
185        assert_eq!(
186            stable_event_id("quotation.created", "quotation.created:Q-1001").unwrap(),
187            "quotation.created:Q-1001"
188        );
189    }
190
191    #[test]
192    fn rejects_capability_names() {
193        assert!(!is_business_event_type("createQuotation"));
194        assert!(is_business_event_type("quotation.created"));
195    }
196}