Skip to main content

port_sdk/
config.rs

1use crate::auth::{AuthStrategy, ClientCredentialsOptions};
2use crate::error::PortError;
3use dotenvy::dotenv;
4use std::env;
5use std::str::FromStr;
6use std::time::Duration;
7use url::Url;
8
9const DEFAULT_EU_BASE_URL: &str = "https://api.getport.io";
10const DEFAULT_US_BASE_URL: &str = "https://api.us.getport.io";
11
12/// Supported Port regions for convenience configuration.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PortRegion {
15    Eu,
16    Us,
17}
18
19impl Default for PortRegion {
20    fn default() -> Self {
21        PortRegion::Eu
22    }
23}
24
25impl PortRegion {
26    pub fn base_url(self) -> &'static str {
27        match self {
28            PortRegion::Eu => DEFAULT_EU_BASE_URL,
29            PortRegion::Us => DEFAULT_US_BASE_URL,
30        }
31    }
32}
33
34impl FromStr for PortRegion {
35    type Err = PortError;
36
37    fn from_str(s: &str) -> Result<Self, Self::Err> {
38        match s.to_ascii_lowercase().as_str() {
39            "eu" => Ok(PortRegion::Eu),
40            "us" => Ok(PortRegion::Us),
41            other => {
42                Err(PortError::Configuration(format!("unsupported PORT_REGION value: {other}")))
43            }
44        }
45    }
46}
47
48/// Retry configuration aligned with the `port-js` SDK defaults.
49#[derive(Debug, Clone)]
50pub struct RetryConfig {
51    pub max_attempts: u32,
52    pub max_elapsed_time: Option<Duration>,
53    pub initial_interval: Duration,
54    pub multiplier: f64,
55    pub max_interval: Duration,
56    pub retry_on_statuses: Vec<u16>,
57}
58
59impl Default for RetryConfig {
60    fn default() -> Self {
61        RetryConfig {
62            max_attempts: 4,
63            max_elapsed_time: Some(Duration::from_secs(30)),
64            initial_interval: Duration::from_millis(500),
65            multiplier: 1.5,
66            max_interval: Duration::from_secs(5),
67            retry_on_statuses: vec![429, 500, 502, 503, 504],
68        }
69    }
70}
71
72/// Configuration object describing how to connect to Port.
73#[derive(Debug, Clone, Default)]
74pub struct TelemetryConfig {
75    pub enable_tracing: bool,
76    pub log_level: Option<String>,
77    pub verbose: bool,
78}
79
80#[derive(Debug, Clone)]
81pub struct PortConfig {
82    pub region: PortRegion,
83    pub base_url: Url,
84    pub auth: AuthStrategy,
85    pub timeout: Duration,
86    pub proxy: Option<String>,
87    pub retry: Option<RetryConfig>,
88    pub telemetry: TelemetryConfig,
89}
90
91impl PortConfig {
92    pub fn builder() -> PortConfigBuilder {
93        PortConfigBuilder::default()
94    }
95
96    /// Load configuration from `.env`/environment variables.
97    ///
98    /// ```no_run
99    /// use port_sdk::config::PortConfig;
100    /// std::env::set_var("PORT_ACCESS_TOKEN", "test-token");
101    /// let config = PortConfig::from_env().unwrap();
102    /// assert_eq!(config.base_url.as_str(), "https://api.getport.io/");
103    /// ```
104    pub fn from_env() -> Result<Self, PortError> {
105        dotenv().ok();
106
107        let region = env::var("PORT_REGION")
108            .ok()
109            .map(|value| PortRegion::from_str(&value))
110            .transpose()?
111            .unwrap_or_default();
112
113        let base_url_str =
114            env::var("PORT_BASE_URL").unwrap_or_else(|_| region.base_url().to_string());
115        let base_url = Url::parse(&base_url_str)?;
116
117        let proxy = env::var("PORT_PROXY_URL")
118            .ok()
119            .or_else(|| env::var("HTTPS_PROXY").ok())
120            .or_else(|| env::var("HTTP_PROXY").ok());
121
122        let timeout = env::var("PORT_TIMEOUT")
123            .ok()
124            .and_then(|raw| raw.parse::<u64>().ok())
125            .map(Duration::from_millis)
126            .or_else(|| {
127                env::var("PORT_TIMEOUT_SECONDS")
128                    .ok()
129                    .and_then(|raw| raw.parse::<u64>().ok())
130                    .map(Duration::from_secs)
131            })
132            .unwrap_or_else(|| Duration::from_secs(30));
133
134        let retry = match env::var("PORT_RETRY_DISABLED") {
135            Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") => None,
136            _ => {
137                let mut config = RetryConfig::default();
138                if let Some(attempts) =
139                    env::var("PORT_RETRY_MAX_ATTEMPTS").ok().and_then(|v| v.parse::<u32>().ok())
140                {
141                    config.max_attempts = attempts;
142                }
143                if let Some(interval_ms) = env::var("PORT_RETRY_INITIAL_INTERVAL_MS")
144                    .ok()
145                    .and_then(|v| v.parse::<u64>().ok())
146                {
147                    config.initial_interval = Duration::from_millis(interval_ms.max(1));
148                }
149                if let Some(max_retries) =
150                    env::var("PORT_MAX_RETRIES").ok().and_then(|v| v.parse::<u32>().ok())
151                {
152                    config.max_attempts = max_retries.max(1);
153                }
154                Some(config)
155            }
156        };
157
158        let auth = load_auth_from_env(&base_url)?;
159
160        let telemetry = TelemetryConfig {
161            enable_tracing: env::var("PORT_TRACING_ENABLED")
162                .ok()
163                .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
164                .unwrap_or(false),
165            log_level: env::var("PORT_LOG_LEVEL").ok(),
166            verbose: env::var("PORT_VERBOSE")
167                .ok()
168                .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
169                .unwrap_or(false),
170        };
171
172        Ok(PortConfig { region, base_url, auth, timeout, proxy, retry, telemetry })
173    }
174}
175
176#[derive(Default)]
177pub struct PortConfigBuilder {
178    region: PortRegion,
179    base_url: Option<Url>,
180    auth: Option<AuthStrategy>,
181    timeout: Duration,
182    proxy: Option<String>,
183    retry: Option<RetryConfig>,
184    telemetry: TelemetryConfig,
185}
186
187impl PortConfigBuilder {
188    pub fn region(mut self, region: PortRegion) -> Self {
189        self.region = region;
190        self
191    }
192
193    pub fn base_url(mut self, base_url: Url) -> Self {
194        self.base_url = Some(base_url);
195        self
196    }
197
198    pub fn auth(mut self, strategy: AuthStrategy) -> Self {
199        self.auth = Some(strategy);
200        self
201    }
202
203    pub fn timeout(mut self, timeout: Duration) -> Self {
204        self.timeout = timeout;
205        self
206    }
207
208    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
209        self.proxy = Some(proxy.into());
210        self
211    }
212
213    pub fn retry(mut self, retry: Option<RetryConfig>) -> Self {
214        self.retry = retry;
215        self
216    }
217
218    pub fn telemetry(mut self, telemetry: TelemetryConfig) -> Self {
219        self.telemetry = telemetry;
220        self
221    }
222
223    pub fn build(self) -> Result<PortConfig, PortError> {
224        let base_url = match self.base_url {
225            Some(url) => url,
226            None => Url::parse(self.region.base_url())?,
227        };
228
229        let auth = self.auth.ok_or_else(|| {
230            PortError::Configuration(
231                "authentication strategy missing; set it on PortConfigBuilder::auth".into(),
232            )
233        })?;
234
235        Ok(PortConfig {
236            region: self.region,
237            base_url,
238            auth,
239            timeout: if self.timeout == Duration::from_secs(0) {
240                Duration::from_secs(30)
241            } else {
242                self.timeout
243            },
244            proxy: self.proxy,
245            retry: self.retry,
246            telemetry: self.telemetry,
247        })
248    }
249}
250
251fn load_auth_from_env(base_url: &Url) -> Result<AuthStrategy, PortError> {
252    if let Ok(token) = env::var("PORT_ACCESS_TOKEN") {
253        if token.trim().is_empty() {
254            return Err(PortError::Configuration("PORT_ACCESS_TOKEN is present but empty".into()));
255        }
256        return Ok(AuthStrategy::StaticToken(token));
257    }
258
259    if let Ok(token) = env::var("PORT_API_TOKEN") {
260        if token.trim().is_empty() {
261            return Err(PortError::Configuration("PORT_API_TOKEN is present but empty".into()));
262        }
263        return Ok(AuthStrategy::StaticToken(token));
264    }
265
266    if let (Ok(raw_client_id), Ok(raw_client_secret)) =
267        (env::var("PORT_CLIENT_ID"), env::var("PORT_CLIENT_SECRET"))
268    {
269        let client_id = raw_client_id.trim().to_owned();
270        let client_secret = raw_client_secret.trim().to_owned();
271        if client_id.is_empty() || client_secret.is_empty() {
272            return Err(PortError::Configuration(
273                "PORT_CLIENT_ID and PORT_CLIENT_SECRET cannot be empty".into(),
274            ));
275        }
276
277        let token_url = match env::var("PORT_TOKEN_URL") {
278            Ok(value) => Url::parse(&value).map_err(|err| {
279                PortError::Configuration(format!("invalid PORT_TOKEN_URL: {err}"))
280            })?,
281            Err(_) => infer_token_url(base_url).map_err(|err| {
282                PortError::Configuration(format!("failed to derive token URL: {err}"))
283            })?,
284        };
285
286        let minimum_ttl = env::var("PORT_MIN_TOKEN_TTL_SECONDS")
287            .ok()
288            .and_then(|value| value.parse::<u64>().ok())
289            .map(Duration::from_secs)
290            .unwrap_or_else(|| Duration::from_secs(30));
291
292        let options = ClientCredentialsOptions { client_id, client_secret, token_url, minimum_ttl };
293
294        return Ok(AuthStrategy::ClientCredentials(options));
295    }
296
297    Err(PortError::Configuration(
298        "failed to derive authentication strategy from environment; set PORT_ACCESS_TOKEN or PORT_CLIENT_ID/PORT_CLIENT_SECRET".into(),
299    ))
300}
301
302fn infer_token_url(base_url: &Url) -> Result<Url, url::ParseError> {
303    if base_url.path().ends_with('/') {
304        base_url.join("oauth/token")
305    } else {
306        let mut clone = base_url.clone();
307        let mut path = clone.path().to_owned();
308        path.push('/');
309        clone.set_path(&path);
310        clone.join("oauth/token")
311    }
312}