port-sdk 0.1.0

Rust SDK for Port APIs.
Documentation
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";

/// Supported Port regions for convenience configuration.
#[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}")))
            }
        }
    }
}

/// Retry configuration aligned with the `port-js` SDK defaults.
#[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],
        }
    }
}

/// Configuration object describing how to connect to Port.
#[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()
    }

    /// Load configuration from `.env`/environment variables.
    ///
    /// ```no_run
    /// use port_sdk::config::PortConfig;
    /// std::env::set_var("PORT_ACCESS_TOKEN", "test-token");
    /// let config = PortConfig::from_env().unwrap();
    /// assert_eq!(config.base_url.as_str(), "https://api.getport.io/");
    /// ```
    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")
    }
}