Skip to main content

greentic_types/
events.rs

1//! Canonical Greentic event envelope shared across repos.
2
3use 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
18/// Map of metadata entries propagated with an event.
19pub type EventMetadata = BTreeMap<String, String>;
20
21/// Stable identifier for an event envelope.
22#[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    /// Returns the identifier as a string slice.
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33
34    /// Validates and constructs the identifier from the provided value.
35    pub fn new(value: impl AsRef<str>) -> GResult<Self> {
36        value.as_ref().parse()
37    }
38
39    /// Consumes the identifier returning the owned string.
40    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/// Generic envelope for cross-service events.
89#[derive(Clone, Debug, PartialEq)]
90#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
91#[cfg_attr(feature = "schemars", derive(JsonSchema))]
92pub struct EventEnvelope {
93    /// Stable identifier for the event.
94    pub id: EventId,
95    /// Logical topic for routing (for example `greentic.repo.build.status`).
96    pub topic: String,
97    /// Fully qualified event type identifier (for example `com.greentic.repo.build.status.v1`).
98    pub r#type: String,
99    /// Originator of the event (DID, URI, or service identifier).
100    pub source: String,
101    /// Tenant context propagated with the event.
102    pub tenant: TenantCtx,
103    /// Optional subject tied to the event (for example `repo:my-service`).
104    #[cfg_attr(
105        feature = "serde",
106        serde(default, skip_serializing_if = "Option::is_none")
107    )]
108    pub subject: Option<String>,
109    /// Event timestamp in UTC.
110    #[cfg_attr(
111        feature = "schemars",
112        schemars(with = "String", description = "RFC3339 timestamp in UTC")
113    )]
114    pub time: DateTime<Utc>,
115    /// Optional correlation identifier to link related messages.
116    #[cfg_attr(
117        feature = "serde",
118        serde(default, skip_serializing_if = "Option::is_none")
119    )]
120    pub correlation_id: Option<String>,
121    /// Opaque JSON payload representing the event body.
122    pub payload: Value,
123    /// Free-form metadata such as idempotency keys.
124    #[cfg_attr(feature = "serde", serde(default))]
125    pub metadata: EventMetadata,
126}
127
128/// Schema identifier for the business-event profile.
129pub const BUSINESS_EVENT_SCHEMA: &str = "greentic.business-event.v1";
130
131const BUSINESS_EVENT_TYPE_PREFIX: &str = "cap://greentic/events/";
132
133/// Parse a business-event `type` of the form
134/// `cap://greentic/events/{domain}/{name}` into `(domain, name)`.
135pub 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
158/// Validate an [`EventEnvelope`] against the `greentic.business-event.v1`
159/// profile. Returns ALL violations so callers can correct everything at once.
160pub 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
188/// Ergonomic builder for `greentic.business-event.v1` envelopes. Assembles the
189/// `type`/`topic` from `domain`/`name`, places `producer`/`schema_version` into
190/// metadata, and validates on `build`.
191pub 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    /// Create a new builder for the given `domain` and event `name` under a `tenant`.
207    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    /// Override the event `source` (defaults to `"greentic"`).
224    pub fn source(mut self, source: impl Into<String>) -> Self {
225        self.source = source.into();
226        self
227    }
228
229    /// Set the `producer` metadata field (required).
230    pub fn producer(mut self, producer: impl Into<String>) -> Self {
231        self.producer = Some(producer.into());
232        self
233    }
234
235    /// Set the `schema_version` metadata field (required).
236    pub fn schema_version(mut self, v: impl Into<String>) -> Self {
237        self.schema_version = Some(v.into());
238        self
239    }
240
241    /// Set the opaque JSON `payload`.
242    pub fn payload(mut self, payload: Value) -> Self {
243        self.payload = payload;
244        self
245    }
246
247    /// Set the event `id`. Must be a valid identifier (alphanumeric + hyphens).
248    pub fn id(mut self, id: impl Into<String>) -> Self {
249        self.id = Some(id.into());
250        self
251    }
252
253    /// Set an optional `subject` string for the event.
254    pub fn subject(mut self, subject: impl Into<String>) -> Self {
255        self.subject = Some(subject.into());
256        self
257    }
258
259    /// Set an optional `correlation_id` to link related messages.
260    pub fn correlation_id(mut self, c: impl Into<String>) -> Self {
261        self.correlation_id = Some(c.into());
262        self
263    }
264
265    /// Set the event timestamp (defaults to [`DateTime::UNIX_EPOCH`]).
266    pub fn time(mut self, time: DateTime<Utc>) -> Self {
267        self.time = time;
268        self
269    }
270
271    /// Assemble and validate. Returns all profile violations on failure.
272    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}