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#[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#[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#[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 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}