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