use secrecy::SecretString;
#[derive(Clone)]
pub struct RelayConfig {
pub url: String,
pub api_key: SecretString,
pub callback_url: Option<String>,
pub instance_id: Option<String>,
pub request_timeout_secs: u64,
pub webhook_path: String,
}
impl std::fmt::Debug for RelayConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RelayConfig")
.field("url", &self.url)
.field("api_key", &"[REDACTED]")
.field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("webhook_path", &self.webhook_path)
.finish()
}
}
impl RelayConfig {
pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok())
}
pub fn from_values(url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
url: url.into(),
api_key: SecretString::from(api_key.into()),
callback_url: None,
instance_id: None,
request_timeout_secs: 30,
webhook_path: "/relay/events".into(),
}
}
fn from_env_reader(env: impl Fn(&str) -> Option<String>) -> Option<Self> {
let url = env("CHANNEL_RELAY_URL")?;
let api_key = SecretString::from(env("CHANNEL_RELAY_API_KEY")?);
Some(Self {
url,
api_key,
callback_url: env("IRONCLAW_OAUTH_CALLBACK_URL"),
instance_id: env("IRONCLAW_INSTANCE_ID"),
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(30),
webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_reader_returns_none_when_unset() {
let config = RelayConfig::from_env_reader(|_| None);
assert!(config.is_none());
}
#[test]
fn from_env_reader_requires_only_url_and_api_key() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
});
assert!(
config.is_some(),
"relay config should load with just URL + API key"
);
}
#[test]
fn from_env_reader_loads_all_required() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
})
.expect("config should be Some");
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.webhook_path, "/relay/events");
assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none());
}
#[test]
fn from_env_reader_loads_overrides() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://relay:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("secret".into()),
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
_ => None,
})
.expect("config should be Some");
assert_eq!(
config.callback_url.as_deref(),
Some("https://tunnel.example.com")
);
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.webhook_path, "/custom/events");
}
#[test]
fn from_values_builds_with_defaults() {
let config = RelayConfig::from_values("http://localhost:3001", "key");
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
}
#[test]
fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("super-secret"));
}
}