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            shortcode: shortcode.to_owned(),
81        }
82    }
83
84    /// The consistency token the client holds: the one the last answer
85    /// carrying one brought, or empty before any. Hand it to
86    /// [`Client::set_consistency_token`] of a client in another process for
87    /// its reads to show this client's writes.
88    pub fn consistency_token(&self) -> String {
89        self.inner.token.lock().expect("the token lock").clone()
90    }
91
92    /// Replaces the consistency token the client sends with its calls.
93    ///
94    /// The API merges a mutation's position into the token the mutation
95    /// presented, so writes made one after another each answer with a token
96    /// covering all of them. Writes made concurrently each present the same
97    /// earlier token, and the client keeps whichever token is answered last:
98    /// a read then sees that write, not necessarily the others, until it
99    /// presents a token covering them, such as one from waiting on their
100    /// operations.
101    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/// Configures a [`Client`].
118#[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    /// Sets the credentials, instead of taking them from the environment or
132    /// the CLI's session.
133    pub fn credentials(mut self, credentials: impl Credentials + 'static) -> Self {
134        self.credentials = Some(Arc::new(credentials));
135        self
136    }
137
138    /// Sends calls to a region's front door,
139    /// `{service}.{region}.origincloudapis.eu`, for the services that have
140    /// one. It overrides `ORIGINCLOUD_REGION`; an empty region uses the
141    /// regionless endpoint. A region, from either, is `[a-z0-9-]+`, such as
142    /// `eu-central1`: [`ClientBuilder::build`] refuses any other.
143    pub fn region(mut self, region: impl Into<String>) -> Self {
144        self.region = Some(region.into());
145        self
146    }
147
148    /// Sends the calls of every service to one base URL, such as a test
149    /// server's, instead of the service's own host. [`ClientBuilder::build`]
150    /// refuses one that isn't an absolute URL with a host.
151    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
152        self.endpoint = Some(endpoint.into());
153        self
154    }
155
156    /// Sets the HTTP client calls are sent with. Its timeout, if any,
157    /// bounds each attempt.
158    pub fn http_client(mut self, http: reqwest::Client) -> Self {
159        self.http = Some(http);
160        self
161    }
162
163    /// Adds a product, such as `my-tool/1.2`, to the `User-Agent` header.
164    pub fn user_agent(mut self, product: impl Into<String>) -> Self {
165        self.user_agent = Some(product.into());
166        self
167    }
168
169    /// Sets how often a retryable call is sent at most, the first attempt
170    /// included; 1 turns retries off. The default is 5.
171    pub fn max_attempts(mut self, attempts: u32) -> Self {
172        self.max_attempts = Some(attempts);
173        self
174    }
175
176    /// Bounds each attempt of a call by `timeout`; an attempt that takes
177    /// longer fails as a transport error, which a safe call retries. By
178    /// default, only the HTTP client's own timeout, if any, bounds it.
179    ///
180    /// Waiting on an operation asks the API to wait less than this, so that
181    /// a wait call doesn't time out on the way; see
182    /// [`ClientBuilder::operation_wait`].
183    pub fn timeout(mut self, timeout: Duration) -> Self {
184        self.timeout = Some(timeout);
185        self
186    }
187
188    /// Sets how long one call of [`Operation::wait`](crate::Operation::wait)
189    /// asks the API to wait for the operation before answering with it as
190    /// it is, in whole seconds, at least one; the default, and the most the
191    /// API waits, is a minute. With a [`ClientBuilder::timeout`], a call
192    /// asks for a second less than it at most. A wait call that times out
193    /// all the same, such as under the HTTP client's own timeout, counts as
194    /// the operation not being done yet, and the next one is sent.
195    pub fn operation_wait(mut self, wait: Duration) -> Self {
196        self.operation_wait = Some(wait);
197        self
198    }
199
200    /// The client. It fails when no credentials are found or an option is
201    /// invalid.
202    pub fn build(self) -> Result<Client, Error> {
203        self.build_with(&|name| std::env::var(name).ok())
204    }
205
206    /// [`ClientBuilder::build`] with the environment read through `env`.
207    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(&region) {
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
284/// Whether `s` can be one label of a host name, as a service shortcode or
285/// a region is: `[a-z0-9-]+`.
286fn 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/// The endpoint of one service, which its generated clients send calls to.
293#[derive(Clone, Debug)]
294pub struct Service {
295    pub(crate) client: Client,
296    /// The base URL, or why there is none.
297    pub(crate) base: Result<Url, String>,
298    /// The service's shortcode, such as `pvs`: its host's first label, and
299    /// the service a full resource name in a path field must name.
300    pub(crate) shortcode: String,
301}
302
303impl Service {
304    /// The client the service's calls go through.
305    pub fn client(&self) -> &Client {
306        &self.client
307    }
308
309    /// The base URL calls are sent to, such as `https://pvs.origincloudapis.eu`;
310    /// `None` when the service's shortcode can't name a host.
311    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}