Skip to main content

origincloud_core/
client.rs

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
11/// This SDK's version, sent in the `User-Agent` header.
12pub const VERSION: &str = env!("CARGO_PKG_VERSION");
13
14const ENV_REGION: &str = "ORIGINCLOUD_REGION";
15const DEFAULT_MAX_ATTEMPTS: u32 = 5;
16/// The longest the API waits for an operation in one call.
17const DEFAULT_OPERATION_WAIT: Duration = Duration::from_secs(60);
18
19/// What the clients of every service share: credentials, endpoints, an
20/// HTTP client and the consistency token's store. Cloning it is cheap, and
21/// the clones share all of it.
22#[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    /// The timeout of each attempt, when the builder set one.
35    pub(crate) timeout: Option<Duration>,
36    /// How long one call waiting on an operation asks the API to wait.
37    pub(crate) operation_wait: Duration,
38    pub(crate) tokens: Arc<dyn ConsistencyTokenStore>,
39}
40
41impl Client {
42    /// A client configured by the environment: the credentials
43    #[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    /// region `ORIGINCLOUD_REGION` names, if any.
52    pub fn new() -> Result<Self, Error> {
53        Self::builder().build()
54    }
55
56    /// A builder of a client configured otherwise.
57    pub fn builder() -> ClientBuilder {
58        ClientBuilder::default()
59    }
60
61    /// The endpoint of the service whose host starts with `shortcode`, such
62    /// as `pvs`. A regional service answers at its region's front door when
63    /// the client has a region; a global one always at its regionless host.
64    /// The generated clients call it.
65    ///
66    /// A shortcode that isn't a host label, `[a-z0-9-]+`, makes every call
67    /// of the service fail with [`Error::Config`].
68    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    /// The consistency token the client holds: the one the last answer
94    /// carrying one brought, or empty before any. Hand it to
95    /// [`Client::set_consistency_token`] of a client in another process for
96    /// its reads to show this client's writes. It is read from the client's
97    /// store ([`ClientBuilder::consistency_token_store`]).
98    pub fn consistency_token(&self) -> String {
99        self.inner.tokens.get()
100    }
101
102    /// Replaces the consistency token the client sends with its calls.
103    ///
104    /// The API merges a mutation's position into the token the mutation
105    /// presented, so writes made one after another each answer with a token
106    /// covering all of them. Writes made concurrently each present the same
107    /// earlier token, and the client keeps whichever token is answered last:
108    /// a read then sees that write, not necessarily the others, until it
109    /// presents a token covering them, such as one from waiting on their
110    /// operations.
111    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/// Configures a [`Client`].
128#[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    /// Sets the credentials, instead of taking them from the environment or
143    /// the CLI's session.
144    pub fn credentials(mut self, credentials: impl Credentials + 'static) -> Self {
145        self.credentials = Some(Arc::new(credentials));
146        self
147    }
148
149    /// Sends calls to a region's front door,
150    /// `{service}.{region}.origincloudapis.eu`, for the services that have
151    /// one. It overrides `ORIGINCLOUD_REGION`; an empty region uses the
152    /// regionless endpoint. A region, from either, is `[a-z0-9-]+`, such as
153    /// `eu-central1`: [`ClientBuilder::build`] refuses any other.
154    pub fn region(mut self, region: impl Into<String>) -> Self {
155        self.region = Some(region.into());
156        self
157    }
158
159    /// Sends the calls of every service to one base URL, such as a test
160    /// server's, instead of the service's own host. [`ClientBuilder::build`]
161    /// refuses one that isn't an absolute URL with a host.
162    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
163        self.endpoint = Some(endpoint.into());
164        self
165    }
166
167    /// Sets the HTTP client calls are sent with. Its timeout, if any,
168    /// bounds each attempt.
169    pub fn http_client(mut self, http: reqwest::Client) -> Self {
170        self.http = Some(http);
171        self
172    }
173
174    /// Adds a product, such as `my-tool/1.2`, to the `User-Agent` header.
175    pub fn user_agent(mut self, product: impl Into<String>) -> Self {
176        self.user_agent = Some(product.into());
177        self
178    }
179
180    /// Sets how often a retryable call is sent at most, the first attempt
181    /// included; 1 turns retries off. The default is 5.
182    pub fn max_attempts(mut self, attempts: u32) -> Self {
183        self.max_attempts = Some(attempts);
184        self
185    }
186
187    /// Bounds each attempt of a call by `timeout`; an attempt that takes
188    /// longer fails as a transport error, which a safe call retries. By
189    /// default, only the HTTP client's own timeout, if any, bounds it.
190    ///
191    /// Waiting on an operation asks the API to wait less than this, so that
192    /// a wait call doesn't time out on the way; see
193    /// [`ClientBuilder::operation_wait`].
194    pub fn timeout(mut self, timeout: Duration) -> Self {
195        self.timeout = Some(timeout);
196        self
197    }
198
199    /// Sets how long one call of [`Operation::wait`](crate::Operation::wait)
200    /// asks the API to wait for the operation before answering with it as
201    /// it is, in whole seconds, at least one; the default, and the most the
202    /// API waits, is a minute. With a [`ClientBuilder::timeout`], a call
203    /// asks for a second less than it at most. A wait call that times out
204    /// all the same, such as under the HTTP client's own timeout, counts as
205    /// the operation not being done yet, and the next one is sent.
206    pub fn operation_wait(mut self, wait: Duration) -> Self {
207        self.operation_wait = Some(wait);
208        self
209    }
210
211    /// Keeps the consistency token in `store` instead of in a
212    /// [`ConsistencyTokenCell`] of the client's own. Clients given the same
213    /// store share one token: each presents the token the store holds, and
214    /// each answer carrying a token replaces it there, so a read through
215    /// any of them shows the writes made through all of them, as through
216    /// one client (see [`Client::set_consistency_token`] for concurrent
217    /// writes).
218    pub fn consistency_token_store(mut self, store: Arc<dyn ConsistencyTokenStore>) -> Self {
219        self.tokens = Some(store);
220        self
221    }
222
223    /// The client. It fails when no credentials are found or an option is
224    /// invalid.
225    pub fn build(self) -> Result<Client, Error> {
226        self.build_with(&|name| std::env::var(name).ok())
227    }
228
229    /// [`ClientBuilder::build`] with the environment read through `env`.
230    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(&region) {
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
310/// Whether `s` can be one label of a host name, as a service shortcode or
311/// a region is: `[a-z0-9-]+`.
312fn 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/// The endpoint of one service, which its generated clients send calls to,
319/// and how they send them.
320#[derive(Clone, Debug)]
321pub struct Service {
322    pub(crate) client: Client,
323    /// The base URL, or why there is none.
324    pub(crate) base: Result<Url, String>,
325    /// The service's shortcode, such as `pvs`: its host's first label, and
326    /// the service a full resource name in a path field must name.
327    pub(crate) shortcode: String,
328    pub(crate) options: CallOptions,
329}
330
331impl Service {
332    /// The same endpoint, whose calls are sent as `options` says instead of
333    /// as the client's settings do. The generated clients'
334    /// `with_options` call it.
335    pub fn with_options(&self, options: CallOptions) -> Self {
336        Self {
337            options,
338            ..self.clone()
339        }
340    }
341
342    /// How the calls are sent where they differ from the client's settings.
343    pub fn options(&self) -> &CallOptions {
344        &self.options
345    }
346
347    /// The most attempts of a retryable call: the options', else the
348    /// client's.
349    pub(crate) fn max_attempts(&self) -> u32 {
350        self.options
351            .max_attempts
352            .unwrap_or(self.client.inner.max_attempts)
353    }
354
355    /// The client the service's calls go through.
356    pub fn client(&self) -> &Client {
357        &self.client
358    }
359
360    /// The base URL calls are sent to, such as `https://pvs.origincloudapis.eu`;
361    /// `None` when the service's shortcode can't name a host.
362    pub fn base_url(&self) -> Option<&Url> {
363        self.base.as_ref().ok()
364    }
365}
366
367/// How the calls of one service client are sent, where that differs from
368/// the [`Client`]'s settings: give it to a generated client's
369/// `with_options`, such as `ProjectClient::with_options`, for the calls
370/// made through the client it returns. Waiting on an operation such a call
371/// answers with follows the client's settings again.
372///
373/// ```
374/// let once = origincloud_core::CallOptions::new().max_attempts(1);
375/// assert_eq!(once.attempts(), Some(1));
376/// ```
377#[derive(Clone, Debug, Default, PartialEq, Eq)]
378#[non_exhaustive]
379pub struct CallOptions {
380    max_attempts: Option<u32>,
381}
382
383impl CallOptions {
384    /// The client's settings, unchanged.
385    pub fn new() -> Self {
386        Self::default()
387    }
388
389    /// Sends a retryable call `attempts` times at most, the first included,
390    /// instead of [`ClientBuilder::max_attempts`]; 1, or 0, turns retries
391    /// off, for a mutation that must not be sent twice even with its
392    /// request id. A call the API refused for its consistency token alone
393    /// is sent again all the same, as the API refused it before doing
394    /// anything.
395    pub fn max_attempts(mut self, attempts: u32) -> Self {
396        self.max_attempts = Some(attempts.max(1));
397        self
398    }
399
400    /// The most attempts set, if any.
401    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}