road-runner-common 0.21.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Internal (operations) audiences, addressed by name rather than by person.
//!
//! Some notifications are not for a customer — an unmatched bank transfer, a stuck
//! settlement — and there is no user to send them to. Those go to a **topic**: the
//! notification center owns the membership, so who is on call changes there, not in a
//! service's configuration.
//!
//! A service names the audience symbolically ([`AdminChannel::Alert`]) and never handles a
//! topic key. The key comes from this deployment's configuration, so re-pointing a
//! channel is an environment change in one place — not an edit in every repository that
//! sends to it.
//!
//! ```ignore
//! notifier.notify_detached(RecipientRef::Admin(AdminChannel::Alert), UnmatchedDeposit { … });
//! ```

use std::fmt;

/// A named internal audience.
///
/// Deliberately a closed enum rather than free-form strings: a typo in a topic key is a
/// message that silently goes nowhere, and the set of operations channels is small and
/// worth being explicit about. Adding a channel is one variant here plus its environment
/// variable — still a single place, still no change in the services that send to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AdminChannel {
    /// Something needs a human now — money is stuck, held, or unaccounted for.
    Alert,
    /// Something worth knowing, no action implied.
    Info,
}

impl AdminChannel {
    /// Every channel, so configuration can be validated or logged as a set.
    pub const ALL: [AdminChannel; 2] = [AdminChannel::Alert, AdminChannel::Info];

    /// The symbolic name, as it appears in code and logs.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Alert => "ADMIN_ALERT",
            Self::Info => "ADMIN_INFO",
        }
    }

    /// The environment variable carrying this channel's topic key.
    pub fn env_key(self) -> &'static str {
        match self {
            Self::Alert => "NOTIFICATION_TOPIC_ADMIN_ALERT",
            Self::Info => "NOTIFICATION_TOPIC_ADMIN_INFO",
        }
    }

    /// The topic key used when the environment does not override it. A working default
    /// beats a channel that silently resolves to nothing; create the topic under this key
    /// in the notification center and no service needs configuring at all.
    pub fn default_topic_key(self) -> &'static str {
        match self {
            Self::Alert => "admin-alert",
            Self::Info => "admin-info",
        }
    }
}

impl fmt::Display for AdminChannel {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// This deployment's channel → topic-key mapping.
#[derive(Debug, Clone)]
pub struct AdminTopics {
    alert: String,
    info: String,
}

impl AdminTopics {
    /// Read the mapping from the environment, falling back to
    /// [`AdminChannel::default_topic_key`] per channel.
    pub fn from_env() -> Self {
        Self {
            alert: topic_from_env(AdminChannel::Alert),
            info: topic_from_env(AdminChannel::Info),
        }
    }

    /// An explicit mapping, for tests and for services that configure it themselves.
    pub fn new(alert: impl Into<String>, info: impl Into<String>) -> Self {
        Self {
            alert: alert.into(),
            info: info.into(),
        }
    }

    /// The topic key this deployment routes `channel` to.
    pub fn key(&self, channel: AdminChannel) -> &str {
        match channel {
            AdminChannel::Alert => &self.alert,
            AdminChannel::Info => &self.info,
        }
    }
}

impl Default for AdminTopics {
    fn default() -> Self {
        Self::new(
            AdminChannel::Alert.default_topic_key(),
            AdminChannel::Info.default_topic_key(),
        )
    }
}

fn topic_from_env(channel: AdminChannel) -> String {
    std::env::var(channel.env_key())
        .ok()
        .map(|key| key.trim().to_string())
        .filter(|key| !key.is_empty())
        .unwrap_or_else(|| channel.default_topic_key().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_channel_names_its_own_environment_variable_and_default() {
        assert_eq!(AdminChannel::Alert.as_str(), "ADMIN_ALERT");
        assert_eq!(
            AdminChannel::Alert.env_key(),
            "NOTIFICATION_TOPIC_ADMIN_ALERT"
        );
        assert_eq!(AdminChannel::Alert.default_topic_key(), "admin-alert");
        assert_eq!(AdminChannel::Info.env_key(), "NOTIFICATION_TOPIC_ADMIN_INFO");
    }

    /// Every channel must resolve to a distinct key, or two audiences silently merge.
    #[test]
    fn channels_do_not_share_a_default_topic() {
        let topics = AdminTopics::default();
        let keys: std::collections::HashSet<_> =
            AdminChannel::ALL.iter().map(|c| topics.key(*c)).collect();
        assert_eq!(keys.len(), AdminChannel::ALL.len());
    }

    #[test]
    fn an_explicit_mapping_wins_over_the_defaults() {
        let topics = AdminTopics::new("topic-1", "topic-2");
        assert_eq!(topics.key(AdminChannel::Alert), "topic-1");
        assert_eq!(topics.key(AdminChannel::Info), "topic-2");
    }
}