Skip to main content

soaprs_core/
message.rs

1//! Transport-independent message identity and correlation metadata.
2
3use std::{fmt, time::SystemTime};
4
5macro_rules! string_identifier {
6    ($name:ident, $description:literal) => {
7        #[doc = $description]
8        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
9        pub struct $name(String);
10
11        impl $name {
12            /// Wraps an identifier generated by the application or an adapter.
13            pub fn new(value: impl Into<String>) -> Self {
14                Self(value.into())
15            }
16
17            /// Returns the identifier as text.
18            pub fn as_str(&self) -> &str {
19                &self.0
20            }
21        }
22
23        impl fmt::Display for $name {
24            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25                formatter.write_str(&self.0)
26            }
27        }
28
29        impl From<String> for $name {
30            fn from(value: String) -> Self {
31                Self::new(value)
32            }
33        }
34
35        impl From<&str> for $name {
36            fn from(value: &str) -> Self {
37                Self::new(value)
38            }
39        }
40    };
41}
42
43string_identifier!(
44    MessageId,
45    "Stable identity of a command, query, or event message."
46);
47string_identifier!(
48    CorrelationId,
49    "Identity shared by messages that belong to one application flow."
50);
51string_identifier!(
52    CausationId,
53    "Identity of the message that directly caused another message."
54);
55
56/// Metadata carried independently from a strongly typed message payload.
57///
58/// IDs are strings deliberately: applications may generate UUIDs, ULIDs, or
59/// another stable representation without forcing a generator into the core.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct MessageMetadata {
62    id: MessageId,
63    created_at: SystemTime,
64    correlation_id: Option<CorrelationId>,
65    causation_id: Option<CausationId>,
66    initiated_by: Option<String>,
67    source: Option<String>,
68}
69
70impl MessageMetadata {
71    /// Creates metadata using identity and time supplied by an application
72    /// boundary. The core intentionally does not read the clock or generate IDs.
73    pub fn new(id: impl Into<MessageId>, created_at: SystemTime) -> Self {
74        Self {
75            id: id.into(),
76            created_at,
77            correlation_id: None,
78            causation_id: None,
79            initiated_by: None,
80            source: None,
81        }
82    }
83
84    /// Attaches a flow correlation identity.
85    #[must_use]
86    pub fn with_correlation_id(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
87        self.correlation_id = Some(correlation_id.into());
88        self
89    }
90
91    /// Attaches the identity of the directly causing message.
92    #[must_use]
93    pub fn with_causation_id(mut self, causation_id: impl Into<CausationId>) -> Self {
94        self.causation_id = Some(causation_id.into());
95        self
96    }
97
98    /// Attaches the actor or system that initiated the flow.
99    #[must_use]
100    pub fn with_initiated_by(mut self, initiated_by: impl Into<String>) -> Self {
101        self.initiated_by = Some(initiated_by.into());
102        self
103    }
104
105    /// Attaches the logical source component.
106    #[must_use]
107    pub fn with_source(mut self, source: impl Into<String>) -> Self {
108        self.source = Some(source.into());
109        self
110    }
111
112    /// Returns the message identity.
113    pub const fn id(&self) -> &MessageId {
114        &self.id
115    }
116
117    /// Returns the externally supplied creation time.
118    pub const fn created_at(&self) -> SystemTime {
119        self.created_at
120    }
121
122    /// Returns the optional correlation identity.
123    pub const fn correlation_id(&self) -> Option<&CorrelationId> {
124        self.correlation_id.as_ref()
125    }
126
127    /// Returns the optional causation identity.
128    pub const fn causation_id(&self) -> Option<&CausationId> {
129        self.causation_id.as_ref()
130    }
131
132    /// Returns the optional initiating actor.
133    pub fn initiated_by(&self) -> Option<&str> {
134        self.initiated_by.as_deref()
135    }
136
137    /// Returns the optional logical source component.
138    pub fn source(&self) -> Option<&str> {
139        self.source.as_deref()
140    }
141}
142
143/// A strongly typed message together with tracing metadata.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct MessageEnvelope<M> {
146    /// Strongly typed application message.
147    pub message: M,
148    /// Identity and correlation metadata.
149    pub metadata: MessageMetadata,
150}
151
152impl<M> MessageEnvelope<M> {
153    /// Wraps a message with caller-supplied metadata.
154    pub const fn new(message: M, metadata: MessageMetadata) -> Self {
155        Self { message, metadata }
156    }
157
158    /// Transforms the payload while preserving its metadata.
159    pub fn map<N>(self, transform: impl FnOnce(M) -> N) -> MessageEnvelope<N> {
160        MessageEnvelope {
161            message: transform(self.message),
162            metadata: self.metadata,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use std::time::UNIX_EPOCH;
170
171    use super::MessageMetadata;
172
173    #[test]
174    fn message_metadata_keeps_external_identity_and_trace_context() {
175        let metadata = MessageMetadata::new("message-1", UNIX_EPOCH)
176            .with_correlation_id("correlation-1")
177            .with_causation_id("cause-1")
178            .with_initiated_by("user-1")
179            .with_source("orders");
180
181        assert_eq!(metadata.id().as_str(), "message-1");
182        assert_eq!(
183            metadata.correlation_id().map(|value| value.as_str()),
184            Some("correlation-1")
185        );
186        assert_eq!(metadata.initiated_by(), Some("user-1"));
187    }
188}