Skip to main content

origincloud_core/
client.rs

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
10/// This SDK's version, sent in the `User-Agent` header.
11pub const VERSION: &str = env!("CARGO_PKG_VERSION");
12
13const ENV_REGION: &str = "ORIGINCLOUD_REGION";
14const DEFAULT_MAX_ATTEMPTS: u32 = 5;
15/// The longest the API waits for an operation in one call.
16const DEFAULT_OPERATION_WAIT: Duration = Duration::from_secs(60);
17
18/// What the clients of every service share: credentials, endpoints, an
19/// HTTP client and the consistency token. Cloning it is cheap, and the
20/// clones share all of it.
21#[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    /// The timeout of each attempt, when the builder set one.
34    pub(crate) timeout: Option<Duration>,
35    /// How long one call waiting on an operation asks the API to wait.
36    pub(crate) operation_wait: Duration,
37    token: Mutex<String>,
38}
39
40impl Client {
41    /// A client configured by the environment: the credentials
42    /// [`default_credentials`](crate::default_credentials) finds, and the
43    /// region `ORIGINCLOUD_REGION` names, if any.
44    pub fn new() -> Result<Self, Error> {
45        Self::builder().build()
46    }
47
48    /// A builder of a client configured otherwise.
49    pub fn builder() -> ClientBuilder {
50        ClientBuilder::default()
51    }
52
53    /// The endpoint of the service whose host starts with `shortcode`, such
54    /// as `pvs`. A regional service answers at its region's front door when
55    /// the client has a region; a global one always at its regionless host.
56    /// The generated clients call it.
57    ///
58    /// A shortcode that isn't a host label, `[a-z0-9-]+`, makes every call
59    /// of the service fail with [`Error::Config`].
60    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    /// The consistency token the client holds: the one the last answer
84    /// carrying one brought, or empty before any. Hand it to
85    /// [`Client::set_consistency_token`] of a client in another process for
86    /// its reads to show this client's writes.
87    pub fn consistency_token(&self) -> String {
88        self.inner.token.lock().expect("the token lock").clone()
89    }
90
91    /// Replaces the consistency token the client sends with its calls.
92    ///
93    /// The API merges a mutation's position into the token the mutation
94    /// presented, so writes made one after another each answer with a token
95    /// covering all of them. Writes made concurrently each present the same
96    /// earlier token, and the client keeps whichever token is answered last:
97    /// a read then sees that write, not necessarily the others, until it
98    /// presents a token covering them, such as one from waiting on their
99    /// operations.
100    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/// Configures a [`Client`].
117#[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    /// Sets the credentials, instead of taking them from the environment or
131    /// the CLI's session.
132    pub fn credentials(mut self, credentials: impl Credentials + 'static) -> Self {
133        self.credentials = Some(Arc::new(credentials));
134        self
135    }
136
137    /// Sends calls to a region's front door,
138    /// `{service}.{region}.origincloudapis.eu`, for the services that have
139    /// one. It overrides `ORIGINCLOUD_REGION`; an empty region uses the
140    /// regionless endpoint. A region, from either, is `[a-z0-9-]+`, such as
141    /// `eu-central1`: [`ClientBuilder::build`] refuses any other.
142    pub fn region(mut self, region: impl Into<String>) -> Self {
143        self.region = Some(region.into());
144        self
145    }
146
147    /// Sends the calls of every service to one base URL, such as a test
148    /// server's, instead of the service's own host. [`ClientBuilder::build`]
149    /// refuses one that isn't an absolute URL with a host.
150    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
151        self.endpoint = Some(endpoint.into());
152        self
153    }
154
155    /// Sets the HTTP client calls are sent with. Its timeout, if any,
156    /// bounds each attempt.
157    pub fn http_client(mut self, http: reqwest::Client) -> Self {
158        self.http = Some(http);
159        self
160    }
161
162    /// Adds a product, such as `my-tool/1.2`, to the `User-Agent` header.
163    pub fn user_agent(mut self, product: impl Into<String>) -> Self {
164        self.user_agent = Some(product.into());
165        self
166    }
167
168    /// Sets how often a retryable call is sent at most, the first attempt
169    /// included; 1 turns retries off. The default is 5.
170    pub fn max_attempts(mut self, attempts: u32) -> Self {
171        self.max_attempts = Some(attempts);
172        self
173    }
174
175    /// Bounds each attempt of a call by `timeout`; an attempt that takes
176    /// longer fails as a transport error, which a safe call retries. By
177    /// default, only the HTTP client's own timeout, if any, bounds it.
178    ///
179    /// Waiting on an operation asks the API to wait less than this, so that
180    /// a wait call doesn't time out on the way; see
181    /// [`ClientBuilder::operation_wait`].
182    pub fn timeout(mut self, timeout: Duration) -> Self {
183        self.timeout = Some(timeout);
184        self
185    }
186
187    /// Sets how long one call of [`Operation::wait`](crate::Operation::wait)
188    /// asks the API to wait for the operation before answering with it as
189    /// it is, in whole seconds, at least one; the default, and the most the
190    /// API waits, is a minute. With a [`ClientBuilder::timeout`], a call
191    /// asks for a second less than it at most. A wait call that times out
192    /// all the same, such as under the HTTP client's own timeout, counts as
193    /// the operation not being done yet, and the next one is sent.
194    pub fn operation_wait(mut self, wait: Duration) -> Self {
195        self.operation_wait = Some(wait);
196        self
197    }
198
199    /// The client. It fails when no credentials are found or an option is
200    /// invalid.
201    pub fn build(self) -> Result<Client, Error> {
202        self.build_with(&|name| std::env::var(name).ok())
203    }
204
205    /// [`ClientBuilder::build`] with the environment read through `env`.
206    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(&region) {
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
283/// Whether `s` can be one label of a host name, as a service shortcode or
284/// a region is: `[a-z0-9-]+`.
285fn 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/// The endpoint of one service, which its generated clients send calls to.
292#[derive(Clone, Debug)]
293pub struct Service {
294    pub(crate) client: Client,
295    /// The base URL, or why there is none.
296    pub(crate) base: Result<Url, String>,
297}
298
299impl Service {
300    /// The client the service's calls go through.
301    pub fn client(&self) -> &Client {
302        &self.client
303    }
304
305    /// The base URL calls are sent to, such as `https://pvs.origincloudapis.eu`;
306    /// `None` when the service's shortcode can't name a host.
307    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}