use crate::auth::{AuthStrategy, ClientCredentialsOptions};
use crate::error::PortError;
use dotenvy::dotenv;
use std::env;
use std::str::FromStr;
use std::time::Duration;
use url::Url;
const DEFAULT_EU_BASE_URL: &str = "https://api.getport.io";
const DEFAULT_US_BASE_URL: &str = "https://api.us.getport.io";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortRegion {
Eu,
Us,
}
impl Default for PortRegion {
fn default() -> Self {
PortRegion::Eu
}
}
impl PortRegion {
pub fn base_url(self) -> &'static str {
match self {
PortRegion::Eu => DEFAULT_EU_BASE_URL,
PortRegion::Us => DEFAULT_US_BASE_URL,
}
}
}
impl FromStr for PortRegion {
type Err = PortError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"eu" => Ok(PortRegion::Eu),
"us" => Ok(PortRegion::Us),
other => {
Err(PortError::Configuration(format!("unsupported PORT_REGION value: {other}")))
}
}
}
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_attempts: u32,
pub max_elapsed_time: Option<Duration>,
pub initial_interval: Duration,
pub multiplier: f64,
pub max_interval: Duration,
pub retry_on_statuses: Vec<u16>,
}
impl Default for RetryConfig {
fn default() -> Self {
RetryConfig {
max_attempts: 4,
max_elapsed_time: Some(Duration::from_secs(30)),
initial_interval: Duration::from_millis(500),
multiplier: 1.5,
max_interval: Duration::from_secs(5),
retry_on_statuses: vec![429, 500, 502, 503, 504],
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TelemetryConfig {
pub enable_tracing: bool,
pub log_level: Option<String>,
pub verbose: bool,
}
#[derive(Debug, Clone)]
pub struct PortConfig {
pub region: PortRegion,
pub base_url: Url,
pub auth: AuthStrategy,
pub timeout: Duration,
pub proxy: Option<String>,
pub retry: Option<RetryConfig>,
pub telemetry: TelemetryConfig,
}
impl PortConfig {
pub fn builder() -> PortConfigBuilder {
PortConfigBuilder::default()
}
pub fn from_env() -> Result<Self, PortError> {
dotenv().ok();
let region = env::var("PORT_REGION")
.ok()
.map(|value| PortRegion::from_str(&value))
.transpose()?
.unwrap_or_default();
let base_url_str =
env::var("PORT_BASE_URL").unwrap_or_else(|_| region.base_url().to_string());
let base_url = Url::parse(&base_url_str)?;
let proxy = env::var("PORT_PROXY_URL")
.ok()
.or_else(|| env::var("HTTPS_PROXY").ok())
.or_else(|| env::var("HTTP_PROXY").ok());
let timeout = env::var("PORT_TIMEOUT")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.map(Duration::from_millis)
.or_else(|| {
env::var("PORT_TIMEOUT_SECONDS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.map(Duration::from_secs)
})
.unwrap_or_else(|| Duration::from_secs(30));
let retry = match env::var("PORT_RETRY_DISABLED") {
Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") => None,
_ => {
let mut config = RetryConfig::default();
if let Some(attempts) =
env::var("PORT_RETRY_MAX_ATTEMPTS").ok().and_then(|v| v.parse::<u32>().ok())
{
config.max_attempts = attempts;
}
if let Some(interval_ms) = env::var("PORT_RETRY_INITIAL_INTERVAL_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
config.initial_interval = Duration::from_millis(interval_ms.max(1));
}
if let Some(max_retries) =
env::var("PORT_MAX_RETRIES").ok().and_then(|v| v.parse::<u32>().ok())
{
config.max_attempts = max_retries.max(1);
}
Some(config)
}
};
let auth = load_auth_from_env(&base_url)?;
let telemetry = TelemetryConfig {
enable_tracing: env::var("PORT_TRACING_ENABLED")
.ok()
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false),
log_level: env::var("PORT_LOG_LEVEL").ok(),
verbose: env::var("PORT_VERBOSE")
.ok()
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false),
};
Ok(PortConfig { region, base_url, auth, timeout, proxy, retry, telemetry })
}
}
#[derive(Default)]
pub struct PortConfigBuilder {
region: PortRegion,
base_url: Option<Url>,
auth: Option<AuthStrategy>,
timeout: Duration,
proxy: Option<String>,
retry: Option<RetryConfig>,
telemetry: TelemetryConfig,
}
impl PortConfigBuilder {
pub fn region(mut self, region: PortRegion) -> Self {
self.region = region;
self
}
pub fn base_url(mut self, base_url: Url) -> Self {
self.base_url = Some(base_url);
self
}
pub fn auth(mut self, strategy: AuthStrategy) -> Self {
self.auth = Some(strategy);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
self.proxy = Some(proxy.into());
self
}
pub fn retry(mut self, retry: Option<RetryConfig>) -> Self {
self.retry = retry;
self
}
pub fn telemetry(mut self, telemetry: TelemetryConfig) -> Self {
self.telemetry = telemetry;
self
}
pub fn build(self) -> Result<PortConfig, PortError> {
let base_url = match self.base_url {
Some(url) => url,
None => Url::parse(self.region.base_url())?,
};
let auth = self.auth.ok_or_else(|| {
PortError::Configuration(
"authentication strategy missing; set it on PortConfigBuilder::auth".into(),
)
})?;
Ok(PortConfig {
region: self.region,
base_url,
auth,
timeout: if self.timeout == Duration::from_secs(0) {
Duration::from_secs(30)
} else {
self.timeout
},
proxy: self.proxy,
retry: self.retry,
telemetry: self.telemetry,
})
}
}
fn load_auth_from_env(base_url: &Url) -> Result<AuthStrategy, PortError> {
if let Ok(token) = env::var("PORT_ACCESS_TOKEN") {
if token.trim().is_empty() {
return Err(PortError::Configuration("PORT_ACCESS_TOKEN is present but empty".into()));
}
return Ok(AuthStrategy::StaticToken(token));
}
if let Ok(token) = env::var("PORT_API_TOKEN") {
if token.trim().is_empty() {
return Err(PortError::Configuration("PORT_API_TOKEN is present but empty".into()));
}
return Ok(AuthStrategy::StaticToken(token));
}
if let (Ok(raw_client_id), Ok(raw_client_secret)) =
(env::var("PORT_CLIENT_ID"), env::var("PORT_CLIENT_SECRET"))
{
let client_id = raw_client_id.trim().to_owned();
let client_secret = raw_client_secret.trim().to_owned();
if client_id.is_empty() || client_secret.is_empty() {
return Err(PortError::Configuration(
"PORT_CLIENT_ID and PORT_CLIENT_SECRET cannot be empty".into(),
));
}
let token_url = match env::var("PORT_TOKEN_URL") {
Ok(value) => Url::parse(&value).map_err(|err| {
PortError::Configuration(format!("invalid PORT_TOKEN_URL: {err}"))
})?,
Err(_) => infer_token_url(base_url).map_err(|err| {
PortError::Configuration(format!("failed to derive token URL: {err}"))
})?,
};
let minimum_ttl = env::var("PORT_MIN_TOKEN_TTL_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(30));
let options = ClientCredentialsOptions { client_id, client_secret, token_url, minimum_ttl };
return Ok(AuthStrategy::ClientCredentials(options));
}
Err(PortError::Configuration(
"failed to derive authentication strategy from environment; set PORT_ACCESS_TOKEN or PORT_CLIENT_ID/PORT_CLIENT_SECRET".into(),
))
}
fn infer_token_url(base_url: &Url) -> Result<Url, url::ParseError> {
if base_url.path().ends_with('/') {
base_url.join("oauth/token")
} else {
let mut clone = base_url.clone();
let mut path = clone.path().to_owned();
path.push('/');
clone.set_path(&path);
clone.join("oauth/token")
}
}