1use std::fmt;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use url::Url;
6
7use crate::Error;
8use crate::credentials::{self, Credentials};
9
10pub const VERSION: &str = env!("CARGO_PKG_VERSION");
12
13const ENV_REGION: &str = "ORIGINCLOUD_REGION";
14const DEFAULT_MAX_ATTEMPTS: u32 = 5;
15const DEFAULT_OPERATION_WAIT: Duration = Duration::from_secs(60);
17
18#[derive(Clone)]
22pub struct Client {
23 pub(crate) inner: Arc<Inner>,
24}
25
26pub(crate) struct Inner {
27 pub(crate) http: reqwest::Client,
28 pub(crate) credentials: Arc<dyn Credentials>,
29 region: String,
30 endpoint: Option<Url>,
31 pub(crate) user_agent: String,
32 pub(crate) max_attempts: u32,
33 pub(crate) timeout: Option<Duration>,
35 pub(crate) operation_wait: Duration,
37 token: Mutex<String>,
38}
39
40impl Client {
41 pub fn new() -> Result<Self, Error> {
45 Self::builder().build()
46 }
47
48 pub fn builder() -> ClientBuilder {
50 ClientBuilder::default()
51 }
52
53 pub fn service(&self, shortcode: &str, regional: bool) -> Service {
61 let base = match &self.inner.endpoint {
62 Some(endpoint) => Ok(endpoint.clone()),
63 None if !is_label(shortcode) => Err(format!(
64 "the service shortcode must be [a-z0-9-]+, got {shortcode:?}"
65 )),
66 None => {
67 let region = &self.inner.region;
68 let host = if regional && !region.is_empty() {
69 format!("{shortcode}.{region}.origincloudapis.eu")
70 } else {
71 format!("{shortcode}.origincloudapis.eu")
72 };
73 Url::parse(&format!("https://{host}"))
74 .map_err(|e| format!("the service host {host:?} isn't valid: {e}"))
75 }
76 };
77 Service {
78 client: self.clone(),
79 base,
80 shortcode: shortcode.to_owned(),
81 }
82 }
83
84 pub fn consistency_token(&self) -> String {
89 self.inner.token.lock().expect("the token lock").clone()
90 }
91
92 pub fn set_consistency_token(&self, token: impl Into<String>) {
102 *self.inner.token.lock().expect("the token lock") = token.into();
103 }
104}
105
106impl fmt::Debug for Client {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.debug_struct("Client")
109 .field("credentials", &self.inner.credentials)
110 .field("region", &self.inner.region)
111 .field("endpoint", &self.inner.endpoint.as_ref().map(Url::as_str))
112 .field("max_attempts", &self.inner.max_attempts)
113 .finish_non_exhaustive()
114 }
115}
116
117#[derive(Default)]
119pub struct ClientBuilder {
120 credentials: Option<Arc<dyn Credentials>>,
121 region: Option<String>,
122 endpoint: Option<String>,
123 http: Option<reqwest::Client>,
124 user_agent: Option<String>,
125 max_attempts: Option<u32>,
126 timeout: Option<Duration>,
127 operation_wait: Option<Duration>,
128}
129
130impl ClientBuilder {
131 pub fn credentials(mut self, credentials: impl Credentials + 'static) -> Self {
134 self.credentials = Some(Arc::new(credentials));
135 self
136 }
137
138 pub fn region(mut self, region: impl Into<String>) -> Self {
144 self.region = Some(region.into());
145 self
146 }
147
148 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
152 self.endpoint = Some(endpoint.into());
153 self
154 }
155
156 pub fn http_client(mut self, http: reqwest::Client) -> Self {
159 self.http = Some(http);
160 self
161 }
162
163 pub fn user_agent(mut self, product: impl Into<String>) -> Self {
165 self.user_agent = Some(product.into());
166 self
167 }
168
169 pub fn max_attempts(mut self, attempts: u32) -> Self {
172 self.max_attempts = Some(attempts);
173 self
174 }
175
176 pub fn timeout(mut self, timeout: Duration) -> Self {
184 self.timeout = Some(timeout);
185 self
186 }
187
188 pub fn operation_wait(mut self, wait: Duration) -> Self {
196 self.operation_wait = Some(wait);
197 self
198 }
199
200 pub fn build(self) -> Result<Client, Error> {
203 self.build_with(&|name| std::env::var(name).ok())
204 }
205
206 pub(crate) fn build_with(self, env: &dyn Fn(&str) -> Option<String>) -> Result<Client, Error> {
208 if self.timeout == Some(Duration::ZERO) {
209 return Err(Error::Config(
210 "the timeout must be longer than zero".to_owned(),
211 ));
212 }
213 let max_attempts = self.max_attempts.unwrap_or(DEFAULT_MAX_ATTEMPTS);
214 if max_attempts < 1 {
215 return Err(Error::Config(format!(
216 "the most attempts must be at least 1, got {max_attempts}"
217 )));
218 }
219 let mut user_agent = format!("origincloud-sdk-rust/{VERSION}");
220 if let Some(product) = self.user_agent.filter(|p| !p.is_empty()) {
221 user_agent = format!("{product} {user_agent}");
222 }
223 let region = match self.region {
224 Some(region) => region,
225 None => env(ENV_REGION).unwrap_or_default(),
226 };
227 if !region.is_empty() && !is_label(®ion) {
228 return Err(Error::Config(format!(
229 "the region must be [a-z0-9-]+, such as eu-central1, got {region:?}"
230 )));
231 }
232 let endpoint = match self.endpoint {
233 Some(endpoint) => {
234 let parsed = Url::parse(endpoint.trim_end_matches('/'))
235 .ok()
236 .filter(|u| u.has_host() && !u.cannot_be_a_base());
237 Some(parsed.ok_or_else(|| {
238 Error::Config(format!(
239 "the endpoint must be an absolute URL, got {endpoint:?}"
240 ))
241 })?)
242 }
243 None => None,
244 };
245 let http = match self.http {
246 Some(http) => http,
247 None => reqwest::Client::builder()
248 .build()
249 .map_err(|e| Error::Config(format!("creating the HTTP client: {e}")))?,
250 };
251 let credentials = match self.credentials {
252 Some(credentials) => credentials,
253 None => credentials::from_environment(env)?,
254 };
255 Ok(Client {
256 inner: Arc::new(Inner {
257 http,
258 credentials,
259 region,
260 endpoint,
261 user_agent,
262 max_attempts,
263 timeout: self.timeout,
264 operation_wait: self.operation_wait.unwrap_or(DEFAULT_OPERATION_WAIT),
265 token: Mutex::default(),
266 }),
267 })
268 }
269}
270
271impl fmt::Debug for ClientBuilder {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 f.debug_struct("ClientBuilder")
274 .field("credentials", &self.credentials)
275 .field("region", &self.region)
276 .field("endpoint", &self.endpoint)
277 .field("max_attempts", &self.max_attempts)
278 .field("timeout", &self.timeout)
279 .field("operation_wait", &self.operation_wait)
280 .finish_non_exhaustive()
281 }
282}
283
284fn is_label(s: &str) -> bool {
287 !s.is_empty()
288 && s.bytes()
289 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
290}
291
292#[derive(Clone, Debug)]
294pub struct Service {
295 pub(crate) client: Client,
296 pub(crate) base: Result<Url, String>,
298 pub(crate) shortcode: String,
301}
302
303impl Service {
304 pub fn client(&self) -> &Client {
306 &self.client
307 }
308
309 pub fn base_url(&self) -> Option<&Url> {
312 self.base.as_ref().ok()
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::AccessToken;
320
321 fn no_env(_: &str) -> Option<String> {
322 None
323 }
324
325 fn base(client: &Client, regional: bool) -> String {
326 client
327 .service("pvs", regional)
328 .base_url()
329 .unwrap()
330 .as_str()
331 .trim_end_matches('/')
332 .to_owned()
333 }
334
335 #[test]
336 fn endpoints() {
337 for (region, regional, want) in [
338 ("", true, "https://pvs.origincloudapis.eu"),
339 (
340 "eu-central1",
341 true,
342 "https://pvs.eu-central1.origincloudapis.eu",
343 ),
344 ("eu-central1", false, "https://pvs.origincloudapis.eu"),
345 ] {
346 let client = Client::builder()
347 .credentials(AccessToken::new("t"))
348 .region(region)
349 .build_with(&no_env)
350 .unwrap();
351 assert_eq!(
352 base(&client, regional),
353 want,
354 "region {region:?}, regional {regional}"
355 );
356 }
357
358 let env = |name: &str| (name == ENV_REGION).then(|| "eu-west1".to_owned());
359 let client = Client::builder()
360 .credentials(AccessToken::new("t"))
361 .build_with(&env)
362 .unwrap();
363 assert_eq!(
364 base(&client, true),
365 "https://pvs.eu-west1.origincloudapis.eu"
366 );
367 let client = Client::builder()
368 .credentials(AccessToken::new("t"))
369 .region("")
370 .build_with(&env)
371 .unwrap();
372 assert_eq!(
373 base(&client, true),
374 "https://pvs.origincloudapis.eu",
375 "an empty region overrides the environment"
376 );
377 }
378
379 #[test]
380 fn a_region_that_isnt_a_host_label_is_refused() {
381 for region in [
382 "eu/central1",
383 "x@evil.com/",
384 "eu central1",
385 "eu-central1:443",
386 "EU-CENTRAL1",
387 ] {
388 let built = Client::builder()
389 .credentials(AccessToken::new("t"))
390 .region(region)
391 .build_with(&no_env);
392 let Err(Error::Config(message)) = built else {
393 panic!("region {region:?}: {built:?}");
394 };
395 assert!(message.contains(&format!("{region:?}")), "{message}");
396
397 let env = |name: &str| (name == ENV_REGION).then(|| region.to_owned());
398 let built = Client::builder()
399 .credentials(AccessToken::new("t"))
400 .build_with(&env);
401 assert!(
402 matches!(built, Err(Error::Config(_))),
403 "ORIGINCLOUD_REGION={region:?}"
404 );
405 }
406 }
407
408 #[test]
409 fn a_shortcode_that_isnt_a_host_label_fails_its_calls_instead_of_panicking() {
410 let client = Client::builder()
411 .credentials(AccessToken::new("t"))
412 .build_with(&no_env)
413 .unwrap();
414 assert!(client.service("x@evil.com/", true).base_url().is_none());
415 assert!(client.service("pvs", true).base_url().is_some());
416 }
417
418 #[test]
419 fn invalid_options_are_refused() {
420 let built = Client::builder()
421 .credentials(AccessToken::new("t"))
422 .max_attempts(0)
423 .build_with(&no_env);
424 assert!(matches!(built, Err(Error::Config(_))));
425 for endpoint in [
426 "localhost",
427 "",
428 "http://",
429 "not a url",
430 "http://exa mple.com",
431 "mailto:x@y",
432 ] {
433 let built = Client::builder()
434 .credentials(AccessToken::new("t"))
435 .endpoint(endpoint)
436 .build_with(&no_env);
437 assert!(
438 matches!(built, Err(Error::Config(_))),
439 "endpoint {endpoint:?}"
440 );
441 }
442 }
443
444 #[test]
445 fn no_credentials_at_all() {
446 let env = |name: &str| match name {
447 "XDG_DATA_HOME" => Some("/nonexistent/origincloud-sdk-test".to_owned()),
448 "ORIGINCLOUD_METADATA_DISABLED" => Some("true".to_owned()),
449 _ => None,
450 };
451 assert!(matches!(
452 Client::builder().build_with(&env),
453 Err(Error::NoCredentials)
454 ));
455 }
456}