road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Standard client for triggering notification events through the internal
//! cex-notification API (`/admin/api/v1/events/trigger`, Novu-shaped).
//!
//! Every service that needs to notify a user (OTP codes, alerts, …) builds a
//! [`TriggerRequest`] and calls [`NotificationClient::trigger`] instead of
//! hand-rolling the HTTP POST, so the wire contract lives in one place. The call
//! is an internal cluster request over `http://` (mesh mTLS handles transport).

use std::time::Duration;

use serde::Serialize;

/// Default timeout for a single trigger call.
const TRIGGER_TIMEOUT: Duration = Duration::from_secs(10);

/// A failed notification trigger.
#[derive(Debug, thiserror::Error)]
pub enum NotificationError {
    #[error("notification center unreachable: {0}")]
    Request(#[from] reqwest::Error),
    #[error("notification center returned {0}")]
    Status(reqwest::StatusCode),
}

/// The recipient of a notification. `subscriber_id` is the Novu subscriber key;
/// `email` / `phone` select the delivery channel (set exactly one).
#[derive(Debug, Clone, Serialize)]
pub struct Recipient {
    #[serde(rename = "subscriberId")]
    pub subscriber_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,
    pub locale: String,
}

impl Recipient {
    /// An email recipient.
    pub fn email(
        subscriber_id: impl Into<String>,
        email: impl Into<String>,
        locale: impl Into<String>,
    ) -> Self {
        Self {
            subscriber_id: subscriber_id.into(),
            email: Some(email.into()),
            phone: None,
            locale: locale.into(),
        }
    }

    /// An SMS recipient.
    pub fn phone(
        subscriber_id: impl Into<String>,
        phone: impl Into<String>,
        locale: impl Into<String>,
    ) -> Self {
        Self {
            subscriber_id: subscriber_id.into(),
            email: None,
            phone: Some(phone.into()),
            locale: locale.into(),
        }
    }
}

/// A workflow trigger: the workflow name, the recipient, and the template payload.
#[derive(Debug, Clone, Serialize)]
pub struct TriggerRequest {
    #[serde(rename = "name")]
    pub workflow: String,
    pub to: Recipient,
    pub payload: serde_json::Value,
}

/// Reusable client bound to the internal cex-notification trigger endpoint.
#[derive(Clone)]
pub struct NotificationClient {
    http: reqwest::Client,
    trigger_url: String,
}

impl NotificationClient {
    /// Build a client for the given trigger URL (e.g. the value of
    /// `NOTIFICATION_API_URL`).
    pub fn new(trigger_url: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            trigger_url: trigger_url.into(),
        }
    }

    /// Build a client reusing an existing `reqwest::Client` (shared connection pool).
    pub fn with_client(http: reqwest::Client, trigger_url: impl Into<String>) -> Self {
        Self {
            http,
            trigger_url: trigger_url.into(),
        }
    }

    /// Fire the workflow. Returns `Err` on transport failure or a non-2xx status.
    pub async fn trigger(&self, request: &TriggerRequest) -> Result<(), NotificationError> {
        let response = self
            .http
            .post(&self.trigger_url)
            .json(request)
            .timeout(TRIGGER_TIMEOUT)
            .send()
            .await?;
        if !response.status().is_success() {
            return Err(NotificationError::Status(response.status()));
        }
        Ok(())
    }
}

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

    #[test]
    fn serializes_to_the_novu_trigger_shape() {
        let request = TriggerRequest {
            workflow: "action-confirmation".to_string(),
            to: Recipient::email("sub-1", "alice@example.com", "tr"),
            payload: serde_json::json!({ "otp": "123456" }),
        };
        let value = serde_json::to_value(&request).unwrap();

        assert_eq!(value["name"], "action-confirmation");
        assert_eq!(value["to"]["subscriberId"], "sub-1");
        assert_eq!(value["to"]["email"], "alice@example.com");
        // The unused channel is omitted, not sent as null.
        assert!(value["to"].get("phone").is_none());
        assert_eq!(value["to"]["locale"], "tr");
        assert_eq!(value["payload"]["otp"], "123456");
    }

    #[test]
    fn phone_recipient_omits_email() {
        let value = serde_json::to_value(Recipient::phone("sub-2", "+905551234567", "tr")).unwrap();
        assert_eq!(value["phone"], "+905551234567");
        assert!(value.get("email").is_none());
    }
}