1use alloc::string::{String, ToString};
4use alloc::vec::Vec;
5use alloc::{collections::BTreeMap, format};
6use core::fmt;
7use core::str::FromStr;
8
9use chrono::{DateTime, Utc};
10#[cfg(feature = "schemars")]
11use schemars::JsonSchema;
12#[cfg(feature = "serde")]
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::{GResult, GreenticError, TenantCtx, validate_identifier};
17
18pub type EventMetadata = BTreeMap<String, String>;
20
21#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
23#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
24#[cfg_attr(feature = "schemars", derive(JsonSchema))]
25#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
26pub struct EventId(String);
27
28impl EventId {
29 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33
34 pub fn new(value: impl AsRef<str>) -> GResult<Self> {
36 value.as_ref().parse()
37 }
38
39 pub fn into_inner(self) -> String {
41 self.0
42 }
43}
44
45impl From<EventId> for String {
46 fn from(value: EventId) -> Self {
47 value.0
48 }
49}
50
51impl AsRef<str> for EventId {
52 fn as_ref(&self) -> &str {
53 self.as_str()
54 }
55}
56
57impl fmt::Display for EventId {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(self.as_str())
60 }
61}
62
63impl FromStr for EventId {
64 type Err = GreenticError;
65
66 fn from_str(value: &str) -> Result<Self, Self::Err> {
67 validate_identifier(value, "EventId")?;
68 Ok(Self(value.to_owned()))
69 }
70}
71
72impl TryFrom<String> for EventId {
73 type Error = GreenticError;
74
75 fn try_from(value: String) -> Result<Self, Self::Error> {
76 EventId::from_str(&value)
77 }
78}
79
80impl TryFrom<&str> for EventId {
81 type Error = GreenticError;
82
83 fn try_from(value: &str) -> Result<Self, Self::Error> {
84 EventId::from_str(value)
85 }
86}
87
88#[derive(Clone, Debug, PartialEq)]
90#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
91#[cfg_attr(feature = "schemars", derive(JsonSchema))]
92pub struct EventEnvelope {
93 pub id: EventId,
95 pub topic: String,
97 pub r#type: String,
99 pub source: String,
101 pub tenant: TenantCtx,
103 #[cfg_attr(
105 feature = "serde",
106 serde(default, skip_serializing_if = "Option::is_none")
107 )]
108 pub subject: Option<String>,
109 #[cfg_attr(
111 feature = "schemars",
112 schemars(with = "String", description = "RFC3339 timestamp in UTC")
113 )]
114 pub time: DateTime<Utc>,
115 #[cfg_attr(
117 feature = "serde",
118 serde(default, skip_serializing_if = "Option::is_none")
119 )]
120 pub correlation_id: Option<String>,
121 pub payload: Value,
123 #[cfg_attr(feature = "serde", serde(default))]
125 pub metadata: EventMetadata,
126}
127
128pub const BUSINESS_EVENT_SCHEMA: &str = "greentic.business-event.v1";
130
131const BUSINESS_EVENT_TYPE_PREFIX: &str = "cap://greentic/events/";
132
133pub fn parse_business_event_type(type_str: &str) -> Result<(String, String), String> {
136 let rest = type_str
137 .strip_prefix(BUSINESS_EVENT_TYPE_PREFIX)
138 .ok_or_else(|| {
139 format!("event type '{type_str}' must start with '{BUSINESS_EVENT_TYPE_PREFIX}'")
140 })?;
141 let segments: Vec<&str> = rest.split('/').collect();
142 if segments.len() != 2 {
143 return Err(format!(
144 "event type '{type_str}' must be cap://greentic/events/{{domain}}/{{name}} (got {} segment(s) after the prefix)",
145 segments.len()
146 ));
147 }
148 let domain = segments[0];
149 let name = segments[1];
150 if domain.is_empty() || name.is_empty() {
151 return Err(format!(
152 "event type '{type_str}' has an empty domain or name segment"
153 ));
154 }
155 Ok((domain.to_string(), name.to_string()))
156}
157
158pub fn validate_business_event(event: &EventEnvelope) -> Result<(), Vec<String>> {
161 let mut errors: Vec<String> = Vec::new();
162 match parse_business_event_type(&event.r#type) {
163 Ok((domain, _name)) => {
164 if event.topic != domain {
165 errors.push(format!(
166 "topic '{}' must equal the domain '{}' from the event type",
167 event.topic, domain
168 ));
169 }
170 }
171 Err(message) => errors.push(message),
172 }
173 for key in ["schema_version", "producer"] {
174 match event.metadata.get(key) {
175 Some(value) if !value.is_empty() => {}
176 _ => errors.push(format!(
177 "metadata['{key}'] is required and must be non-empty"
178 )),
179 }
180 }
181 if errors.is_empty() {
182 Ok(())
183 } else {
184 Err(errors)
185 }
186}
187
188pub struct BusinessEventBuilder {
192 domain: String,
193 name: String,
194 tenant: TenantCtx,
195 source: String,
196 producer: Option<String>,
197 schema_version: Option<String>,
198 payload: Value,
199 id: Option<String>,
200 subject: Option<String>,
201 correlation_id: Option<String>,
202 time: DateTime<Utc>,
203}
204
205impl BusinessEventBuilder {
206 pub fn new(domain: impl Into<String>, name: impl Into<String>, tenant: TenantCtx) -> Self {
208 Self {
209 domain: domain.into(),
210 name: name.into(),
211 tenant,
212 source: "greentic".to_string(),
213 producer: None,
214 schema_version: None,
215 payload: Value::Null,
216 id: None,
217 subject: None,
218 correlation_id: None,
219 time: DateTime::UNIX_EPOCH,
220 }
221 }
222
223 pub fn source(mut self, source: impl Into<String>) -> Self {
225 self.source = source.into();
226 self
227 }
228
229 pub fn producer(mut self, producer: impl Into<String>) -> Self {
231 self.producer = Some(producer.into());
232 self
233 }
234
235 pub fn schema_version(mut self, v: impl Into<String>) -> Self {
237 self.schema_version = Some(v.into());
238 self
239 }
240
241 pub fn payload(mut self, payload: Value) -> Self {
243 self.payload = payload;
244 self
245 }
246
247 pub fn id(mut self, id: impl Into<String>) -> Self {
249 self.id = Some(id.into());
250 self
251 }
252
253 pub fn subject(mut self, subject: impl Into<String>) -> Self {
255 self.subject = Some(subject.into());
256 self
257 }
258
259 pub fn correlation_id(mut self, c: impl Into<String>) -> Self {
261 self.correlation_id = Some(c.into());
262 self
263 }
264
265 pub fn time(mut self, time: DateTime<Utc>) -> Self {
267 self.time = time;
268 self
269 }
270
271 pub fn build(self) -> Result<EventEnvelope, Vec<String>> {
273 let mut errors = Vec::new();
274 let id_str = self.id.unwrap_or_default();
275 let id = match EventId::new(&id_str) {
276 Ok(id) => Some(id),
277 Err(err) => {
278 errors.push(format!("invalid event id '{id_str}': {err}"));
279 None
280 }
281 };
282 let mut metadata = EventMetadata::new();
283 match self.producer {
284 Some(p) if !p.is_empty() => {
285 metadata.insert("producer".to_string(), p);
286 }
287 _ => errors.push("metadata['producer'] is required and must be non-empty".to_string()),
288 }
289 match self.schema_version {
290 Some(v) if !v.is_empty() => {
291 metadata.insert("schema_version".to_string(), v);
292 }
293 _ => errors
294 .push("metadata['schema_version'] is required and must be non-empty".to_string()),
295 }
296 let id = match id {
297 Some(id) => id,
298 None => return Err(errors),
299 };
300 let event = EventEnvelope {
301 id,
302 topic: self.domain.clone(),
303 r#type: format!("{BUSINESS_EVENT_TYPE_PREFIX}{}/{}", self.domain, self.name),
304 source: self.source,
305 tenant: self.tenant,
306 subject: self.subject,
307 time: self.time,
308 correlation_id: self.correlation_id,
309 payload: self.payload,
310 metadata,
311 };
312 if !errors.is_empty() {
313 return Err(errors);
314 }
315 validate_business_event(&event)?;
316 Ok(event)
317 }
318}
319
320#[cfg(all(test, feature = "std"))]
321#[allow(clippy::unwrap_used, clippy::expect_used)]
322mod business_event_tests {
323 use super::*;
324 use crate::{EnvId, TenantCtx, TenantId};
325 use alloc::string::ToString;
326
327 fn ctx() -> TenantCtx {
328 TenantCtx::new(
329 EnvId::try_from("prod").unwrap(),
330 TenantId::try_from("tenant-1").unwrap(),
331 )
332 }
333
334 fn valid_event() -> EventEnvelope {
335 let mut metadata = EventMetadata::new();
336 metadata.insert("schema_version".to_string(), "1".to_string());
337 metadata.insert("producer".to_string(), "trigger:daily".to_string());
338 EventEnvelope {
339 id: EventId::new("evt-1").unwrap(),
340 topic: "tenancy".to_string(),
341 r#type: "cap://greentic/events/tenancy/daily-rent-reminder".to_string(),
342 source: "operala".to_string(),
343 tenant: ctx(),
344 subject: None,
345 time: chrono::DateTime::UNIX_EPOCH,
346 correlation_id: None,
347 payload: serde_json::json!({}),
348 metadata,
349 }
350 }
351
352 #[test]
353 fn parses_well_formed_business_event_type() {
354 let (domain, name) =
355 parse_business_event_type("cap://greentic/events/tenancy/daily-rent-reminder").unwrap();
356 assert_eq!(domain, "tenancy");
357 assert_eq!(name, "daily-rent-reminder");
358 }
359
360 #[test]
361 fn rejects_type_without_cap_prefix() {
362 assert!(parse_business_event_type("greentic/events/x/y").is_err());
363 }
364
365 #[test]
366 fn rejects_type_with_wrong_segment_count() {
367 assert!(parse_business_event_type("cap://greentic/events/onlyone").is_err());
368 assert!(parse_business_event_type("cap://greentic/events/a/b/c").is_err());
369 }
370
371 #[test]
372 fn valid_event_passes_validation() {
373 validate_business_event(&valid_event()).expect("valid event");
374 }
375
376 #[test]
377 fn topic_must_equal_domain() {
378 let mut e = valid_event();
379 e.topic = "wrong".to_string();
380 let errors = validate_business_event(&e).unwrap_err();
381 assert!(errors.iter().any(|m| m.contains("topic")), "{errors:?}");
382 }
383
384 #[test]
385 fn missing_metadata_keys_are_reported_together() {
386 let mut e = valid_event();
387 e.metadata.clear();
388 let errors = validate_business_event(&e).unwrap_err();
389 assert!(
390 errors.iter().any(|m| m.contains("schema_version")),
391 "{errors:?}"
392 );
393 assert!(errors.iter().any(|m| m.contains("producer")), "{errors:?}");
394 }
395
396 #[test]
397 fn builder_produces_a_valid_event() {
398 let event = BusinessEventBuilder::new("tenancy", "daily-rent-reminder", ctx())
399 .producer("trigger:daily")
400 .schema_version("1")
401 .source("operala")
402 .payload(serde_json::json!({"hello": "world"}))
403 .id("evt-42")
404 .time(chrono::DateTime::UNIX_EPOCH)
405 .build()
406 .expect("builds valid event");
407 assert_eq!(
408 event.r#type,
409 "cap://greentic/events/tenancy/daily-rent-reminder"
410 );
411 assert_eq!(event.topic, "tenancy");
412 assert_eq!(event.metadata.get("producer").unwrap(), "trigger:daily");
413 validate_business_event(&event).expect("builder output validates");
414 }
415
416 #[test]
417 fn builder_rejects_missing_producer() {
418 let errors = BusinessEventBuilder::new("tenancy", "x", ctx())
419 .schema_version("1")
420 .id("evt-1")
421 .build()
422 .unwrap_err();
423 assert!(errors.iter().any(|m| m.contains("producer")), "{errors:?}");
424 }
425}