use crate::{Environment, RevolutXClient};
const ENV_API_KEY: &str = "REVOLUTX_API_KEY";
const ENV_PRIVATE_KEY_PEM: &str = "REVOLUTX_PRIVATE_KEY_PEM";
const ENV_PRIVATE_KEY_PATH: &str = "REVOLUTX_PRIVATE_KEY_PATH";
const ENV_ENVIRONMENT: &str = "REVOLUTX_ENVIRONMENT";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not read private key file '{path}': {source}")]
KeyFile {
path: String,
#[source]
source: std::io::Error,
},
#[error(
"an API key was provided but no private key (set {ENV_PRIVATE_KEY_PEM} or {ENV_PRIVATE_KEY_PATH})"
)]
MissingPrivateKey,
#[error("a private key was provided but no API key (set {ENV_API_KEY})")]
MissingApiKey,
#[error(transparent)]
Client(#[from] crate::Error),
}
#[derive(Default, Clone)]
pub struct ClientConfig {
pub environment: Option<Environment>,
pub api_key: Option<String>,
pub private_key_pem: Option<String>,
pub private_key_path: Option<String>,
}
impl std::fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redact = |v: &Option<String>| {
if v.is_some() {
"Some(<redacted>)"
} else {
"None"
}
};
f.debug_struct("ClientConfig")
.field("environment", &self.environment)
.field("api_key", &redact(&self.api_key))
.field("private_key_pem", &redact(&self.private_key_pem))
.field("private_key_path", &self.private_key_path)
.finish()
}
}
impl ClientConfig {
pub fn from_env() -> Self {
Self {
environment: env_var(ENV_ENVIRONMENT).and_then(|v| parse_environment(&v)),
api_key: env_var(ENV_API_KEY),
private_key_pem: env_var(ENV_PRIVATE_KEY_PEM),
private_key_path: env_var(ENV_PRIVATE_KEY_PATH),
}
}
#[must_use]
pub fn or_env(mut self) -> Self {
let env = Self::from_env();
self.environment = self.environment.or(env.environment);
self.api_key = self.api_key.or(env.api_key);
self.private_key_pem = self.private_key_pem.or(env.private_key_pem);
self.private_key_path = self.private_key_path.or(env.private_key_path);
self
}
pub fn build(self) -> Result<RevolutXClient, ConfigError> {
let environment = self.environment.unwrap_or(Environment::Production);
let mut builder = RevolutXClient::builder().environment(environment);
let pem = match self.private_key_pem {
Some(pem) => Some(pem),
None => match self.private_key_path {
Some(path) => Some(
std::fs::read_to_string(&path)
.map_err(|source| ConfigError::KeyFile { path, source })?,
),
None => None,
},
};
match (self.api_key, pem) {
(Some(api_key), Some(pem)) => {
builder = builder.api_key(api_key).private_key_pem(pem);
}
(None, None) => {}
(Some(_), None) => return Err(ConfigError::MissingPrivateKey),
(None, Some(_)) => return Err(ConfigError::MissingApiKey),
}
Ok(builder.build()?)
}
}
pub fn client_from_env() -> Result<RevolutXClient, ConfigError> {
ClientConfig::from_env().build()
}
fn env_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}
fn parse_environment(value: &str) -> Option<Environment> {
match value.trim().to_ascii_lowercase().as_str() {
"dev" | "development" => Some(Environment::Dev),
"prod" | "production" => Some(Environment::Production),
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn parses_environment_aliases() {
assert_eq!(parse_environment("dev"), Some(Environment::Dev));
assert_eq!(parse_environment("DEVELOPMENT"), Some(Environment::Dev));
assert_eq!(parse_environment("prod"), Some(Environment::Production));
assert_eq!(
parse_environment("Production"),
Some(Environment::Production)
);
assert_eq!(parse_environment("mainnet"), None);
}
#[test]
fn no_credentials_builds_public_client() {
let client = ClientConfig::default().build().unwrap();
assert!(!client.is_authenticated());
assert_eq!(client.base_url(), Environment::Production.base_url());
}
#[test]
fn dev_environment_is_applied() {
let client = ClientConfig {
environment: Some(Environment::Dev),
..Default::default()
}
.build()
.unwrap();
assert_eq!(client.base_url(), Environment::Dev.base_url());
}
#[test]
fn half_credentials_are_rejected() {
let err = ClientConfig {
api_key: Some("k".into()),
..Default::default()
}
.build()
.unwrap_err();
assert!(matches!(err, ConfigError::MissingPrivateKey));
let err = ClientConfig {
private_key_pem: Some("pem".into()),
..Default::default()
}
.build()
.unwrap_err();
assert!(matches!(err, ConfigError::MissingApiKey));
}
#[test]
fn unreadable_key_path_is_reported() {
let err = ClientConfig {
api_key: Some("k".into()),
private_key_path: Some("/nonexistent/revolutx-key.pem".into()),
..Default::default()
}
.build()
.unwrap_err();
assert!(matches!(err, ConfigError::KeyFile { .. }));
}
}