soaprs-core 0.2.0

Core contracts for soaprs
Documentation
//! Transport-independent message identity and correlation metadata.

use std::{fmt, time::SystemTime};

macro_rules! string_identifier {
    ($name:ident, $description:literal) => {
        #[doc = $description]
        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
        pub struct $name(String);

        impl $name {
            /// Wraps an identifier generated by the application or an adapter.
            pub fn new(value: impl Into<String>) -> Self {
                Self(value.into())
            }

            /// Returns the identifier as text.
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(&self.0)
            }
        }

        impl From<String> for $name {
            fn from(value: String) -> Self {
                Self::new(value)
            }
        }

        impl From<&str> for $name {
            fn from(value: &str) -> Self {
                Self::new(value)
            }
        }
    };
}

string_identifier!(
    MessageId,
    "Stable identity of a command, query, or event message."
);
string_identifier!(
    CorrelationId,
    "Identity shared by messages that belong to one application flow."
);
string_identifier!(
    CausationId,
    "Identity of the message that directly caused another message."
);

/// Metadata carried independently from a strongly typed message payload.
///
/// IDs are strings deliberately: applications may generate UUIDs, ULIDs, or
/// another stable representation without forcing a generator into the core.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageMetadata {
    id: MessageId,
    created_at: SystemTime,
    correlation_id: Option<CorrelationId>,
    causation_id: Option<CausationId>,
    initiated_by: Option<String>,
    source: Option<String>,
}

impl MessageMetadata {
    /// Creates metadata using identity and time supplied by an application
    /// boundary. The core intentionally does not read the clock or generate IDs.
    pub fn new(id: impl Into<MessageId>, created_at: SystemTime) -> Self {
        Self {
            id: id.into(),
            created_at,
            correlation_id: None,
            causation_id: None,
            initiated_by: None,
            source: None,
        }
    }

    /// Attaches a flow correlation identity.
    #[must_use]
    pub fn with_correlation_id(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
        self.correlation_id = Some(correlation_id.into());
        self
    }

    /// Attaches the identity of the directly causing message.
    #[must_use]
    pub fn with_causation_id(mut self, causation_id: impl Into<CausationId>) -> Self {
        self.causation_id = Some(causation_id.into());
        self
    }

    /// Attaches the actor or system that initiated the flow.
    #[must_use]
    pub fn with_initiated_by(mut self, initiated_by: impl Into<String>) -> Self {
        self.initiated_by = Some(initiated_by.into());
        self
    }

    /// Attaches the logical source component.
    #[must_use]
    pub fn with_source(mut self, source: impl Into<String>) -> Self {
        self.source = Some(source.into());
        self
    }

    /// Returns the message identity.
    pub const fn id(&self) -> &MessageId {
        &self.id
    }

    /// Returns the externally supplied creation time.
    pub const fn created_at(&self) -> SystemTime {
        self.created_at
    }

    /// Returns the optional correlation identity.
    pub const fn correlation_id(&self) -> Option<&CorrelationId> {
        self.correlation_id.as_ref()
    }

    /// Returns the optional causation identity.
    pub const fn causation_id(&self) -> Option<&CausationId> {
        self.causation_id.as_ref()
    }

    /// Returns the optional initiating actor.
    pub fn initiated_by(&self) -> Option<&str> {
        self.initiated_by.as_deref()
    }

    /// Returns the optional logical source component.
    pub fn source(&self) -> Option<&str> {
        self.source.as_deref()
    }
}

/// A strongly typed message together with tracing metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageEnvelope<M> {
    /// Strongly typed application message.
    pub message: M,
    /// Identity and correlation metadata.
    pub metadata: MessageMetadata,
}

impl<M> MessageEnvelope<M> {
    /// Wraps a message with caller-supplied metadata.
    pub const fn new(message: M, metadata: MessageMetadata) -> Self {
        Self { message, metadata }
    }

    /// Transforms the payload while preserving its metadata.
    pub fn map<N>(self, transform: impl FnOnce(M) -> N) -> MessageEnvelope<N> {
        MessageEnvelope {
            message: transform(self.message),
            metadata: self.metadata,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::time::UNIX_EPOCH;

    use super::MessageMetadata;

    #[test]
    fn message_metadata_keeps_external_identity_and_trace_context() {
        let metadata = MessageMetadata::new("message-1", UNIX_EPOCH)
            .with_correlation_id("correlation-1")
            .with_causation_id("cause-1")
            .with_initiated_by("user-1")
            .with_source("orders");

        assert_eq!(metadata.id().as_str(), "message-1");
        assert_eq!(
            metadata.correlation_id().map(|value| value.as_str()),
            Some("correlation-1")
        );
        assert_eq!(metadata.initiated_by(), Some("user-1"));
    }
}