1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use url::Url;
6
7use crate::Error;
8use crate::consistency::{ConsistencyTokenCell, ConsistencyTokenStore};
9use crate::credentials::{self, Credentials};
10
11pub const VERSION: &str = env!("CARGO_PKG_VERSION");
13
14const ENV_REGION: &str = "ORIGINCLOUD_REGION";
15const DEFAULT_MAX_ATTEMPTS: u32 = 5;
16const DEFAULT_OPERATION_WAIT: Duration = Duration::from_secs(60);
18
19#[derive(Clone)]
23pub struct Client {
24 pub(crate) inner: Arc<Inner>,
25}
26
27pub(crate) struct Inner {
28 pub(crate) http: reqwest::Client,
29 pub(crate) credentials: Arc<dyn Credentials>,
30 region: String,
31 endpoint: Option<Url>,
32 pub(crate) user_agent: String,
33 pub(crate) max_attempts: u32,
34 pub(crate) timeout: Option<Duration>,
36 pub(crate) operation_wait: Duration,
38 pub(crate) tokens: Arc<dyn ConsistencyTokenStore>,
39}
40
41impl Client {
42 #[cfg_attr(
44 not(target_arch = "wasm32"),
45 doc = "[`default_credentials`](crate::default_credentials) finds, and the"
46 )]
47 #[cfg_attr(
48 target_arch = "wasm32",
49 doc = "`default_credentials` finds natively (in a browser, none), and the"
50 )]
51 pub fn new() -> Result<Self, Error> {
53 Self::builder().build()
54 }
55
56 pub fn builder() -> ClientBuilder {
58 ClientBuilder::default()
59 }
60
61 pub fn service(&self, shortcode: &str, regional: bool) -> Service {
69 let base = match &self.inner.endpoint {
70 Some(endpoint) => Ok(endpoint.clone()),
71 None if !is_label(shortcode) => Err(format!(
72 "the service shortcode must be [a-z0-9-]+, got {shortcode:?}"
73 )),
74 None => {
75 let region = &self.inner.region;
76 let host = if regional && !region.is_empty() {
77 format!("{shortcode}.{region}.origincloudapis.eu")
78 } else {
79 format!("{shortcode}.origincloudapis.eu")
80 };
81 Url::parse(&format!("https://{host}"))
82 .map_err(|e| format!("the service host {host:?} isn't valid: {e}"))
83 }
84 };
85 Service {
86 client: self.clone(),
87 base,
88 shortcode: shortcode.to_owned(),
89 options: CallOptions::default(),
90 }
91 }
92
93 pub fn consistency_token(&self) -> String {
99 self.inner.tokens.get()
100 }
101
102 pub fn set_consistency_token(&self, token: impl Into<String>) {
112 self.inner.tokens.set(token.into());
113 }
114}
115
116impl fmt::Debug for Client {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.debug_struct("Client")
119 .field("credentials", &self.inner.credentials)
120 .field("region", &self.inner.region)
121 .field("endpoint", &self.inner.endpoint.as_ref().map(Url::as_str))
122 .field("max_attempts", &self.inner.max_attempts)
123 .finish_non_exhaustive()
124 }
125}
126
127#[derive(Default)]
129pub struct ClientBuilder {
130 credentials: Option<Arc<dyn Credentials>>,
131 region: Option<String>,
132 endpoint: Option<String>,
133 http: Option<reqwest::Client>,
134 user_agent: Option<String>,
135 max_attempts: Option<u32>,
136 timeout: Option<Duration>,
137 operation_wait: Option<Duration>,
138 tokens: Option<Arc<dyn ConsistencyTokenStore>>,
139}
140
141impl ClientBuilder {
142 pub fn credentials(mut self, credentials: impl Credentials + 'static) -> Self {
145 self.credentials = Some(Arc::new(credentials));
146 self
147 }
148
149 pub fn region(mut self, region: impl Into<String>) -> Self {
155 self.region = Some(region.into());
156 self
157 }
158
159 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
163 self.endpoint = Some(endpoint.into());
164 self
165 }
166
167 pub fn http_client(mut self, http: reqwest::Client) -> Self {
170 self.http = Some(http);
171 self
172 }
173
174 pub fn user_agent(mut self, product: impl Into<String>) -> Self {
176 self.user_agent = Some(product.into());
177 self
178 }
179
180 pub fn max_attempts(mut self, attempts: u32) -> Self {
183 self.max_attempts = Some(attempts);
184 self
185 }
186
187 pub fn timeout(mut self, timeout: Duration) -> Self {
195 self.timeout = Some(timeout);
196 self
197 }
198
199 pub fn operation_wait(mut self, wait: Duration) -> Self {
207 self.operation_wait = Some(wait);
208 self
209 }
210
211 pub fn consistency_token_store(mut self, store: Arc<dyn ConsistencyTokenStore>) -> Self {
219 self.tokens = Some(store);
220 self
221 }
222
223 pub fn build(self) -> Result<Client, Error> {
226 self.build_with(&|name| std::env::var(name).ok())
227 }
228
229 pub(crate) fn build_with(self, env: &dyn Fn(&str) -> Option<String>) -> Result<Client, Error> {
231 if self.timeout == Some(Duration::ZERO) {
232 return Err(Error::Config(
233 "the timeout must be longer than zero".to_owned(),
234 ));
235 }
236 let max_attempts = self.max_attempts.unwrap_or(DEFAULT_MAX_ATTEMPTS);
237 if max_attempts < 1 {
238 return Err(Error::Config(format!(
239 "the most attempts must be at least 1, got {max_attempts}"
240 )));
241 }
242 let mut user_agent = format!("origincloud-sdk-rust/{VERSION}");
243 if let Some(product) = self.user_agent.filter(|p| !p.is_empty()) {
244 user_agent = format!("{product} {user_agent}");
245 }
246 let region = match self.region {
247 Some(region) => region,
248 None => env(ENV_REGION).unwrap_or_default(),
249 };
250 if !region.is_empty() && !is_label(®ion) {
251 return Err(Error::Config(format!(
252 "the region must be [a-z0-9-]+, such as eu-central1, got {region:?}"
253 )));
254 }
255 let endpoint = match self.endpoint {
256 Some(endpoint) => {
257 let parsed = Url::parse(endpoint.trim_end_matches('/'))
258 .ok()
259 .filter(|u| u.has_host() && !u.cannot_be_a_base());
260 Some(parsed.ok_or_else(|| {
261 Error::Config(format!(
262 "the endpoint must be an absolute URL, got {endpoint:?}"
263 ))
264 })?)
265 }
266 None => None,
267 };
268 let http = match self.http {
269 Some(http) => http,
270 None => reqwest::Client::builder()
271 .build()
272 .map_err(|e| Error::Config(format!("creating the HTTP client: {e}")))?,
273 };
274 let credentials = match self.credentials {
275 Some(credentials) => credentials,
276 None => credentials::from_environment(env)?,
277 };
278 Ok(Client {
279 inner: Arc::new(Inner {
280 http,
281 credentials,
282 region,
283 endpoint,
284 user_agent,
285 max_attempts,
286 timeout: self.timeout,
287 operation_wait: self.operation_wait.unwrap_or(DEFAULT_OPERATION_WAIT),
288 tokens: self
289 .tokens
290 .unwrap_or_else(|| Arc::new(ConsistencyTokenCell::new())),
291 }),
292 })
293 }
294}
295
296impl fmt::Debug for ClientBuilder {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 f.debug_struct("ClientBuilder")
299 .field("credentials", &self.credentials)
300 .field("region", &self.region)
301 .field("endpoint", &self.endpoint)
302 .field("max_attempts", &self.max_attempts)
303 .field("timeout", &self.timeout)
304 .field("operation_wait", &self.operation_wait)
305 .field("consistency_token_store", &self.tokens)
306 .finish_non_exhaustive()
307 }
308}
309
310fn is_label(s: &str) -> bool {
313 !s.is_empty()
314 && s.bytes()
315 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
316}
317
318#[derive(Clone, Debug)]
321pub struct Service {
322 pub(crate) client: Client,
323 pub(crate) base: Result<Url, String>,
325 pub(crate) shortcode: String,
328 pub(crate) options: CallOptions,
329}
330
331impl Service {
332 pub fn with_options(&self, options: CallOptions) -> Self {
336 Self {
337 options,
338 ..self.clone()
339 }
340 }
341
342 pub fn options(&self) -> &CallOptions {
344 &self.options
345 }
346
347 pub(crate) fn max_attempts(&self) -> u32 {
350 self.options
351 .max_attempts
352 .unwrap_or(self.client.inner.max_attempts)
353 }
354
355 pub fn client(&self) -> &Client {
357 &self.client
358 }
359
360 pub fn base_url(&self) -> Option<&Url> {
363 self.base.as_ref().ok()
364 }
365}
366
367#[derive(Clone, Debug, Default, PartialEq, Eq)]
378#[non_exhaustive]
379pub struct CallOptions {
380 max_attempts: Option<u32>,
381}
382
383impl CallOptions {
384 pub fn new() -> Self {
386 Self::default()
387 }
388
389 pub fn max_attempts(mut self, attempts: u32) -> Self {
396 self.max_attempts = Some(attempts.max(1));
397 self
398 }
399
400 pub fn attempts(&self) -> Option<u32> {
402 self.max_attempts
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::AccessToken;
410
411 fn no_env(_: &str) -> Option<String> {
412 None
413 }
414
415 fn base(client: &Client, regional: bool) -> String {
416 client
417 .service("pvs", regional)
418 .base_url()
419 .unwrap()
420 .as_str()
421 .trim_end_matches('/')
422 .to_owned()
423 }
424
425 #[test]
426 fn endpoints() {
427 for (region, regional, want) in [
428 ("", true, "https://pvs.origincloudapis.eu"),
429 (
430 "eu-central1",
431 true,
432 "https://pvs.eu-central1.origincloudapis.eu",
433 ),
434 ("eu-central1", false, "https://pvs.origincloudapis.eu"),
435 ] {
436 let client = Client::builder()
437 .credentials(AccessToken::new("t"))
438 .region(region)
439 .build_with(&no_env)
440 .unwrap();
441 assert_eq!(
442 base(&client, regional),
443 want,
444 "region {region:?}, regional {regional}"
445 );
446 }
447
448 let env = |name: &str| (name == ENV_REGION).then(|| "eu-west1".to_owned());
449 let client = Client::builder()
450 .credentials(AccessToken::new("t"))
451 .build_with(&env)
452 .unwrap();
453 assert_eq!(
454 base(&client, true),
455 "https://pvs.eu-west1.origincloudapis.eu"
456 );
457 let client = Client::builder()
458 .credentials(AccessToken::new("t"))
459 .region("")
460 .build_with(&env)
461 .unwrap();
462 assert_eq!(
463 base(&client, true),
464 "https://pvs.origincloudapis.eu",
465 "an empty region overrides the environment"
466 );
467 }
468
469 #[test]
470 fn a_region_that_isnt_a_host_label_is_refused() {
471 for region in [
472 "eu/central1",
473 "x@evil.com/",
474 "eu central1",
475 "eu-central1:443",
476 "EU-CENTRAL1",
477 ] {
478 let built = Client::builder()
479 .credentials(AccessToken::new("t"))
480 .region(region)
481 .build_with(&no_env);
482 let Err(Error::Config(message)) = built else {
483 panic!("region {region:?}: {built:?}");
484 };
485 assert!(message.contains(&format!("{region:?}")), "{message}");
486
487 let env = |name: &str| (name == ENV_REGION).then(|| region.to_owned());
488 let built = Client::builder()
489 .credentials(AccessToken::new("t"))
490 .build_with(&env);
491 assert!(
492 matches!(built, Err(Error::Config(_))),
493 "ORIGINCLOUD_REGION={region:?}"
494 );
495 }
496 }
497
498 #[test]
499 fn a_shortcode_that_isnt_a_host_label_fails_its_calls_instead_of_panicking() {
500 let client = Client::builder()
501 .credentials(AccessToken::new("t"))
502 .build_with(&no_env)
503 .unwrap();
504 assert!(client.service("x@evil.com/", true).base_url().is_none());
505 assert!(client.service("pvs", true).base_url().is_some());
506 }
507
508 #[test]
509 fn invalid_options_are_refused() {
510 let built = Client::builder()
511 .credentials(AccessToken::new("t"))
512 .max_attempts(0)
513 .build_with(&no_env);
514 assert!(matches!(built, Err(Error::Config(_))));
515 for endpoint in [
516 "localhost",
517 "",
518 "http://",
519 "not a url",
520 "http://exa mple.com",
521 "mailto:x@y",
522 ] {
523 let built = Client::builder()
524 .credentials(AccessToken::new("t"))
525 .endpoint(endpoint)
526 .build_with(&no_env);
527 assert!(
528 matches!(built, Err(Error::Config(_))),
529 "endpoint {endpoint:?}"
530 );
531 }
532 }
533
534 #[test]
535 fn no_credentials_at_all() {
536 let env = |name: &str| match name {
537 "XDG_DATA_HOME" => Some("/nonexistent/origincloud-sdk-test".to_owned()),
538 "ORIGINCLOUD_METADATA_DISABLED" => Some("true".to_owned()),
539 _ => None,
540 };
541 assert!(matches!(
542 Client::builder().build_with(&env),
543 Err(Error::NoCredentials)
544 ));
545 }
546}