qefro_backend_sdk/
business_events.rs1use 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
117pub 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}