use std::env;
use std::time::Duration;
use athena_billing::grants::BillingAuthRightSyncRequest;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const ENV_NATIVE_RIGHT_SYNC_BASE_URL: &str = "ATHENA_NATIVE_RIGHT_SYNC_BASE_URL";
pub const ENV_NATIVE_RIGHT_SYNC_PATH: &str = "ATHENA_NATIVE_RIGHT_SYNC_PATH";
pub const ENV_NATIVE_RIGHT_SYNC_API_KEY: &str = "ATHENA_NATIVE_RIGHT_SYNC_API_KEY";
pub const ENV_NATIVE_RIGHT_SYNC_HEADER_NAME: &str = "ATHENA_NATIVE_RIGHT_SYNC_HEADER_NAME";
pub const ENV_NATIVE_RIGHT_SYNC_TIMEOUT_MS: &str = "ATHENA_NATIVE_RIGHT_SYNC_TIMEOUT_MS";
pub const ENV_NATIVE_RIGHT_SYNC_FAIL_MODE: &str = "ATHENA_NATIVE_RIGHT_SYNC_FAIL_MODE";
pub const ENV_NATIVE_GRANT_SYNC_BASE_URL: &str = "ATHENA_NATIVE_GRANT_SYNC_BASE_URL";
pub const ENV_NATIVE_GRANT_SYNC_PATH: &str = "ATHENA_NATIVE_GRANT_SYNC_PATH";
pub const ENV_NATIVE_GRANT_SYNC_API_KEY: &str = "ATHENA_NATIVE_GRANT_SYNC_API_KEY";
pub const ENV_NATIVE_GRANT_SYNC_HEADER_NAME: &str = "ATHENA_NATIVE_GRANT_SYNC_HEADER_NAME";
pub const ENV_NATIVE_GRANT_SYNC_TIMEOUT_MS: &str = "ATHENA_NATIVE_GRANT_SYNC_TIMEOUT_MS";
pub const ENV_NATIVE_GRANT_SYNC_FAIL_MODE: &str = "ATHENA_NATIVE_GRANT_SYNC_FAIL_MODE";
pub const ENV_BILLING_AUTH_SYNC_BASE_URL: &str = "ATHENA_BILLING_AUTH_SYNC_BASE_URL";
pub const ENV_BILLING_AUTH_SYNC_PATH: &str = "ATHENA_BILLING_AUTH_SYNC_PATH";
pub const ENV_BILLING_AUTH_SYNC_API_KEY: &str = "ATHENA_BILLING_AUTH_SYNC_API_KEY";
pub const ENV_BILLING_AUTH_SYNC_HEADER_NAME: &str = "ATHENA_BILLING_AUTH_SYNC_HEADER_NAME";
pub const ENV_BILLING_AUTH_SYNC_TIMEOUT_MS: &str = "ATHENA_BILLING_AUTH_SYNC_TIMEOUT_MS";
pub const ENV_BILLING_AUTH_SYNC_FAIL_MODE: &str = "ATHENA_BILLING_AUTH_SYNC_FAIL_MODE";
const DEFAULT_NATIVE_RIGHT_SYNC_PATH: &str = "/api/auth/admin/rights/sync-source";
const DEFAULT_BILLING_AUTH_SYNC_HEADER_NAME: &str = "x-api-key";
const DEFAULT_BILLING_AUTH_SYNC_TIMEOUT_MS: u64 = 5_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BillingAuthSyncFailMode {
BestEffort,
Required,
}
impl BillingAuthSyncFailMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::BestEffort => "best_effort",
Self::Required => "required",
}
}
}
#[derive(Debug, Clone)]
pub struct BillingAuthSyncConfig {
pub endpoint: String,
pub api_key: String,
pub header_name: String,
pub timeout_ms: u64,
pub fail_mode: BillingAuthSyncFailMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingAuthSyncStatus {
pub status: String,
pub configured: bool,
pub attempted: bool,
pub applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fail_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Error)]
pub enum BillingAuthSyncError {
#[error("billing auth sync config is invalid: {0}")]
InvalidConfig(String),
#[error("billing auth sync request failed: {0}")]
Request(String),
#[error("billing auth sync endpoint rejected the request with status {status}: {body}")]
Rejected { status: u16, body: String },
}
impl BillingAuthSyncConfig {
pub fn from_env() -> Result<Option<Self>, BillingAuthSyncError> {
Self::from_lookup(|key| env::var(key).ok())
}
fn from_lookup<F>(mut lookup: F) -> Result<Option<Self>, BillingAuthSyncError>
where
F: FnMut(&str) -> Option<String>,
{
let base_url = lookup_preferred(
&mut lookup,
&[
ENV_NATIVE_RIGHT_SYNC_BASE_URL,
ENV_NATIVE_GRANT_SYNC_BASE_URL,
ENV_BILLING_AUTH_SYNC_BASE_URL,
],
)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let api_key = lookup_preferred(
&mut lookup,
&[
ENV_NATIVE_RIGHT_SYNC_API_KEY,
ENV_NATIVE_GRANT_SYNC_API_KEY,
ENV_BILLING_AUTH_SYNC_API_KEY,
],
)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
match (base_url, api_key) {
(None, None) => Ok(None),
(Some(_), None) => Err(BillingAuthSyncError::InvalidConfig(format!(
"{} must be set when {} is configured",
describe_keys(&[
ENV_NATIVE_RIGHT_SYNC_API_KEY,
ENV_NATIVE_GRANT_SYNC_API_KEY,
ENV_BILLING_AUTH_SYNC_API_KEY,
]),
describe_keys(&[
ENV_NATIVE_RIGHT_SYNC_BASE_URL,
ENV_NATIVE_GRANT_SYNC_BASE_URL,
ENV_BILLING_AUTH_SYNC_BASE_URL,
]),
))),
(None, Some(_)) => Err(BillingAuthSyncError::InvalidConfig(format!(
"{} must be set when {} is configured",
describe_keys(&[
ENV_NATIVE_RIGHT_SYNC_BASE_URL,
ENV_NATIVE_GRANT_SYNC_BASE_URL,
ENV_BILLING_AUTH_SYNC_BASE_URL,
]),
describe_keys(&[
ENV_NATIVE_RIGHT_SYNC_API_KEY,
ENV_NATIVE_GRANT_SYNC_API_KEY,
ENV_BILLING_AUTH_SYNC_API_KEY,
]),
))),
(Some(base_url), Some(api_key)) => {
let path = lookup_preferred(
&mut lookup,
&[
ENV_NATIVE_RIGHT_SYNC_PATH,
ENV_NATIVE_GRANT_SYNC_PATH,
ENV_BILLING_AUTH_SYNC_PATH,
],
)
.unwrap_or_else(|| DEFAULT_NATIVE_RIGHT_SYNC_PATH.to_string());
let endpoint = resolve_endpoint(&base_url, &path)?;
let header_name = lookup_preferred(
&mut lookup,
&[
ENV_NATIVE_RIGHT_SYNC_HEADER_NAME,
ENV_NATIVE_GRANT_SYNC_HEADER_NAME,
ENV_BILLING_AUTH_SYNC_HEADER_NAME,
],
)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BILLING_AUTH_SYNC_HEADER_NAME.to_string());
let timeout_ms = lookup_preferred(
&mut lookup,
&[
ENV_NATIVE_RIGHT_SYNC_TIMEOUT_MS,
ENV_NATIVE_GRANT_SYNC_TIMEOUT_MS,
ENV_BILLING_AUTH_SYNC_TIMEOUT_MS,
],
)
.map(|value| parse_timeout_ms(&value))
.transpose()?
.unwrap_or(DEFAULT_BILLING_AUTH_SYNC_TIMEOUT_MS);
let fail_mode = lookup_preferred(
&mut lookup,
&[
ENV_NATIVE_RIGHT_SYNC_FAIL_MODE,
ENV_NATIVE_GRANT_SYNC_FAIL_MODE,
ENV_BILLING_AUTH_SYNC_FAIL_MODE,
],
)
.map(|value| parse_fail_mode(&value))
.transpose()?
.unwrap_or(BillingAuthSyncFailMode::BestEffort);
Ok(Some(Self {
endpoint,
api_key,
header_name,
timeout_ms,
fail_mode,
}))
}
}
}
}
fn lookup_preferred<F>(lookup: &mut F, keys: &[&str]) -> Option<String>
where
F: FnMut(&str) -> Option<String>,
{
keys.iter().find_map(|key| lookup(key))
}
fn describe_keys(keys: &[&str]) -> String {
keys.join(" or ")
}
pub fn billing_auth_sync_not_configured_status() -> BillingAuthSyncStatus {
BillingAuthSyncStatus {
status: "not_configured".to_string(),
configured: false,
attempted: false,
applied: false,
endpoint: None,
fail_mode: None,
response_status: None,
message: None,
}
}
pub fn billing_auth_sync_failure_status(
config: &BillingAuthSyncConfig,
error: &BillingAuthSyncError,
) -> BillingAuthSyncStatus {
BillingAuthSyncStatus {
status: "failed".to_string(),
configured: true,
attempted: true,
applied: false,
endpoint: Some(config.endpoint.clone()),
fail_mode: Some(config.fail_mode.as_str().to_string()),
response_status: error_response_status(error),
message: Some(error.to_string()),
}
}
pub async fn dispatch_billing_auth_right_sync(
client: &reqwest::Client,
config: &BillingAuthSyncConfig,
payload: &BillingAuthRightSyncRequest,
) -> Result<BillingAuthSyncStatus, BillingAuthSyncError> {
let response = client
.post(&config.endpoint)
.header(config.header_name.as_str(), config.api_key.as_str())
.timeout(Duration::from_millis(config.timeout_ms))
.json(payload)
.send()
.await
.map_err(|error| BillingAuthSyncError::Request(error.to_string()))?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "failed to read response body".to_string());
return Err(BillingAuthSyncError::Rejected {
status: status.as_u16(),
body,
});
}
Ok(BillingAuthSyncStatus {
status: "applied".to_string(),
configured: true,
attempted: true,
applied: true,
endpoint: Some(config.endpoint.clone()),
fail_mode: Some(config.fail_mode.as_str().to_string()),
response_status: Some(status.as_u16()),
message: None,
})
}
pub async fn dispatch_billing_auth_sync(
client: &reqwest::Client,
config: &BillingAuthSyncConfig,
payload: &BillingAuthRightSyncRequest,
) -> Result<BillingAuthSyncStatus, BillingAuthSyncError> {
dispatch_billing_auth_right_sync(client, config, payload).await
}
fn resolve_endpoint(base_url: &str, path: &str) -> Result<String, BillingAuthSyncError> {
let mut base = Url::parse(base_url)
.map_err(|error| BillingAuthSyncError::InvalidConfig(error.to_string()))?;
if !base.path().ends_with('/') {
let normalized = format!("{}/", base.path().trim_end_matches('/'));
base.set_path(&normalized);
}
let normalized_path = path.trim();
if normalized_path.is_empty() {
return Err(BillingAuthSyncError::InvalidConfig(format!(
"{} must not be empty",
describe_keys(&[ENV_NATIVE_GRANT_SYNC_PATH, ENV_BILLING_AUTH_SYNC_PATH]),
)));
}
base.join(normalized_path.trim_start_matches('/'))
.map(|url| url.to_string())
.map_err(|error| BillingAuthSyncError::InvalidConfig(error.to_string()))
}
fn parse_timeout_ms(raw: &str) -> Result<u64, BillingAuthSyncError> {
let timeout_ms = raw
.trim()
.parse::<u64>()
.map_err(|error| BillingAuthSyncError::InvalidConfig(error.to_string()))?;
if timeout_ms == 0 {
return Err(BillingAuthSyncError::InvalidConfig(format!(
"{} must be greater than 0",
describe_keys(&[
ENV_NATIVE_GRANT_SYNC_TIMEOUT_MS,
ENV_BILLING_AUTH_SYNC_TIMEOUT_MS,
]),
)));
}
Ok(timeout_ms)
}
fn parse_fail_mode(raw: &str) -> Result<BillingAuthSyncFailMode, BillingAuthSyncError> {
match raw.trim().to_ascii_lowercase().as_str() {
"best_effort" => Ok(BillingAuthSyncFailMode::BestEffort),
"required" => Ok(BillingAuthSyncFailMode::Required),
other => Err(BillingAuthSyncError::InvalidConfig(format!(
"{} must be 'best_effort' or 'required', got '{other}'",
describe_keys(&[
ENV_NATIVE_GRANT_SYNC_FAIL_MODE,
ENV_BILLING_AUTH_SYNC_FAIL_MODE,
]),
))),
}
}
fn error_response_status(error: &BillingAuthSyncError) -> Option<u16> {
match error {
BillingAuthSyncError::Rejected { status, .. } => Some(*status),
BillingAuthSyncError::InvalidConfig(_) | BillingAuthSyncError::Request(_) => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{
BillingAuthSyncConfig, BillingAuthSyncError, BillingAuthSyncFailMode,
ENV_BILLING_AUTH_SYNC_API_KEY, ENV_BILLING_AUTH_SYNC_BASE_URL,
ENV_BILLING_AUTH_SYNC_FAIL_MODE, ENV_BILLING_AUTH_SYNC_PATH,
ENV_BILLING_AUTH_SYNC_TIMEOUT_MS, ENV_NATIVE_GRANT_SYNC_API_KEY,
ENV_NATIVE_GRANT_SYNC_BASE_URL, ENV_NATIVE_GRANT_SYNC_PATH, ENV_NATIVE_RIGHT_SYNC_API_KEY,
ENV_NATIVE_RIGHT_SYNC_BASE_URL, ENV_NATIVE_RIGHT_SYNC_PATH,
};
fn config_from_pairs(
pairs: &[(&str, &str)],
) -> Result<Option<BillingAuthSyncConfig>, BillingAuthSyncError> {
let values = HashMap::<String, String>::from_iter(
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string())),
);
BillingAuthSyncConfig::from_lookup(|key| values.get(key).cloned())
}
#[test]
fn config_from_lookup_returns_none_when_unset() {
assert!(config_from_pairs(&[]).unwrap().is_none());
}
#[test]
fn config_from_lookup_rejects_partial_configuration() {
let error = config_from_pairs(&[(ENV_BILLING_AUTH_SYNC_BASE_URL, "https://auth.example")])
.unwrap_err();
assert!(error.to_string().contains(ENV_BILLING_AUTH_SYNC_API_KEY));
}
#[test]
fn config_from_lookup_applies_defaults_and_normalizes_endpoint() {
let config = config_from_pairs(&[
(ENV_BILLING_AUTH_SYNC_BASE_URL, "https://auth.example/root/"),
(ENV_BILLING_AUTH_SYNC_API_KEY, "secret"),
])
.unwrap()
.unwrap();
assert_eq!(
config.endpoint,
"https://auth.example/root/api/auth/admin/rights/sync-source"
);
assert_eq!(config.timeout_ms, 5_000);
assert_eq!(config.fail_mode, BillingAuthSyncFailMode::BestEffort);
}
#[test]
fn config_from_lookup_parses_fail_mode_and_timeout() {
let config = config_from_pairs(&[
(ENV_BILLING_AUTH_SYNC_BASE_URL, "https://auth.example"),
(ENV_BILLING_AUTH_SYNC_API_KEY, "secret"),
(ENV_BILLING_AUTH_SYNC_PATH, "/internal/billing/sync"),
(ENV_BILLING_AUTH_SYNC_TIMEOUT_MS, "9000"),
(ENV_BILLING_AUTH_SYNC_FAIL_MODE, "required"),
])
.unwrap()
.unwrap();
assert_eq!(
config.endpoint,
"https://auth.example/internal/billing/sync"
);
assert_eq!(config.timeout_ms, 9_000);
assert_eq!(config.fail_mode, BillingAuthSyncFailMode::Required);
}
#[test]
fn config_from_lookup_prefers_native_right_env_names_over_grant_and_billing_aliases() {
let config = config_from_pairs(&[
(
ENV_BILLING_AUTH_SYNC_BASE_URL,
"https://legacy-auth.example",
),
(ENV_BILLING_AUTH_SYNC_API_KEY, "legacy-secret"),
(
ENV_BILLING_AUTH_SYNC_PATH,
"/api/auth/admin/grant-assignment/sync-billing",
),
(
ENV_NATIVE_GRANT_SYNC_BASE_URL,
"https://native-auth.example",
),
(ENV_NATIVE_GRANT_SYNC_API_KEY, "native-secret"),
(
ENV_NATIVE_GRANT_SYNC_PATH,
"/api/auth/admin/grant-assignment/sync-source",
),
(
ENV_NATIVE_RIGHT_SYNC_BASE_URL,
"https://rights-auth.example",
),
(ENV_NATIVE_RIGHT_SYNC_API_KEY, "rights-secret"),
(
ENV_NATIVE_RIGHT_SYNC_PATH,
"/api/auth/admin/rights/sync-source",
),
])
.unwrap()
.unwrap();
assert_eq!(
config.endpoint,
"https://rights-auth.example/api/auth/admin/rights/sync-source"
);
assert_eq!(config.api_key, "rights-secret");
}
}