cronback_client/
client.rs

1use async_trait::async_trait;
2use http::header::{self, USER_AGENT};
3use reqwest::{IntoUrl, RequestBuilder};
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6use url::Url;
7
8use crate::constants::{BASE_URL_ENV, DEFAULT_BASE_URL};
9use crate::{Error, Response, Result};
10
11/// An asynchronous client for a cronback API service.
12///
13/// The client has various configuration options, but has reasonable defaults
14/// that should suit most use-cases. To configure a client, use
15/// [`Client::builder()`] or [`ClientBuilder::new()`]
16///
17/// a `Client` manages an internal connection pool, it's designed to be created
18/// once and reused (via `Client::clone()`). You do **not** need to wrap
19/// `Client` in [`Rc`] or [`Arc`] to reuse it.
20///
21/// [`Rc`]: std::rc::Rc
22#[derive(Clone)]
23pub struct Client {
24    http_client: reqwest::Client,
25    config: ClientConfig,
26}
27
28#[async_trait]
29pub trait RequestRunner: Sync + Send {
30    fn prepare_request(
31        &self,
32        method: http::Method,
33        path: Url,
34    ) -> Result<RequestBuilder>;
35
36    fn make_url(&self, path: &str) -> Result<Url>;
37
38    fn prepare_request_with_body<B>(
39        &self,
40        method: http::Method,
41        path: Url,
42        body: B,
43    ) -> Result<RequestBuilder>
44    where
45        B: Serialize + std::fmt::Debug,
46    {
47        Ok(self.prepare_request(method, path)?.json(&body))
48    }
49
50    async fn process_response<T>(
51        &self,
52        response: reqwest::Response,
53    ) -> Result<Response<T>>
54    where
55        T: DeserializeOwned + Send,
56    {
57        Response::from_raw_response(response).await
58    }
59
60    async fn run<T>(
61        &self,
62        method: http::Method,
63        path: Url,
64    ) -> Result<Response<T>>
65    where
66        T: DeserializeOwned + Send,
67    {
68        let request = self.prepare_request(method, path)?;
69        let resp = request.send().await?;
70        self.process_response(resp).await
71    }
72
73    async fn run_with_body<T, B>(
74        &self,
75        method: http::Method,
76        path: Url,
77        body: B,
78    ) -> Result<Response<T>>
79    where
80        T: DeserializeOwned + Send,
81        B: Serialize + std::fmt::Debug + Send,
82    {
83        let request = self.prepare_request_with_body(method, path, body)?;
84        let resp = request.send().await?;
85        self.process_response(resp).await
86    }
87}
88
89/// A `ClientBuilder` is what should be used to construct a `Client` with custom
90/// configuration.
91///
92/// We default to the production cronback service `https://api.cronback.me/` unless `CRONBACK_BASE_URL`
93/// enviornment variable is defined. Alternatively, the `base_url` can be used
94/// to override the server url for this particular client instance.
95#[must_use]
96#[derive(Default, Clone)]
97pub struct ClientBuilder {
98    config: Config,
99}
100
101impl ClientBuilder {
102    /// Construct a new client builder with reasonable defaults. Use
103    /// [`ClientBuilder::build`] to construct a client.
104    pub fn new() -> Self {
105        Self {
106            config: Config::default(),
107        }
108    }
109
110    pub fn base_url<T: IntoUrl>(mut self, base_url: T) -> Result<Self> {
111        let mut base_url = base_url.into_url()?;
112        // We want to make sure that the query string is empty.
113        base_url.set_query(None);
114        self.config.base_url = Some(base_url);
115        Ok(self)
116    }
117
118    pub fn secret_token(mut self, secret_token: String) -> Self {
119        self.config.secret_token = Some(secret_token);
120        self
121    }
122
123    #[cfg(feature = "admin")]
124    /// If the secret_token is an admin key, the client will act on behalf of
125    /// the project passed here.
126    /// This method is for cronback admin use only. For normal users, the
127    /// project id is infered from the secret token and this value is just
128    /// ignored.
129    pub fn on_behalf_of(mut self, project_id: String) -> Self {
130        self.config.on_behalf_of = Some(project_id);
131        self
132    }
133
134    /// Construct cronback client.
135    pub fn build(self) -> Result<Client> {
136        let user_agent = format!(
137            "rust-{}-{}-{}",
138            env!("CARGO_PKG_VERSION"),
139            std::env::consts::OS,
140            std::env::consts::ARCH,
141        );
142
143        let mut headers = header::HeaderMap::new();
144        headers.insert(
145            USER_AGENT,
146            header::HeaderValue::from_str(&user_agent).expect("User-Agent"),
147        );
148
149        if let Some(prj) = &self.config.on_behalf_of {
150            headers.insert(
151                "X-On-Behalf-Of",
152                header::HeaderValue::from_str(prj).expect("X-On-Behalf-Of"),
153            );
154        }
155
156        let http_client = match self.config.reqwest_client {
157            | Some(c) => c,
158            | None => {
159                reqwest::ClientBuilder::new()
160                    .redirect(reqwest::redirect::Policy::none())
161                    .default_headers(headers)
162                    .build()?
163            }
164        };
165
166        let base_url = match self.config.base_url {
167            | Some(c) => c,
168            | None => {
169                // Attempt to read from enviornment variable before fallback to
170                // default.
171                std::env::var(BASE_URL_ENV)
172                    .ok()
173                    .map(|base_url| Url::parse(&base_url))
174                    .unwrap_or(Ok(DEFAULT_BASE_URL.clone()))
175                    .expect("Config::default()")
176            }
177        };
178        Ok(Client {
179            http_client,
180            config: ClientConfig {
181                base_url,
182                secret_token: self
183                    .config
184                    .secret_token
185                    .ok_or(Error::SecretTokenRequired)?,
186            },
187        })
188    }
189
190    /// Use a pre-configured [`request::Client`] instance instead of creating
191    /// our own. This allows customising TLS, timeout, and other low-level http
192    /// client configuration options.
193    pub fn reqwest_client(mut self, c: reqwest::Client) -> Self {
194        self.config.reqwest_client = Some(c);
195        self
196    }
197}
198
199impl Client {
200    /// Constructs a new client with the default configuration. This is **not**
201    /// the recommended way to construct a client. We recommend using
202    /// `Client::builder().build()` instead.
203    ///
204    /// # Panics
205    ///
206    /// This method panics if TLS backend cannot be initialised, or the
207    /// underlying resolver cannot load the system configuration. Use
208    /// [`Client::builder()`] if you wish to handle the failure as an
209    /// [`crate::Error`] instead of panicking.
210    pub fn new() -> Self {
211        Self::builder().build().expect("Client::new()")
212    }
213
214    /// Creates a `ClientBuilder` to configure a `Client`.
215    ///
216    /// This is the same as `ClientBuilder::new()`.
217    pub fn builder() -> ClientBuilder {
218        ClientBuilder::new()
219    }
220}
221
222impl Default for Client {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228#[derive(Default, Clone)]
229struct Config {
230    base_url: Option<Url>,
231    secret_token: Option<String>,
232    on_behalf_of: Option<String>,
233    reqwest_client: Option<reqwest::Client>,
234}
235
236#[derive(Clone)]
237pub(crate) struct ClientConfig {
238    pub base_url: Url,
239    secret_token: String,
240}
241
242// Ensure that Client is Send + Sync. Compiler will fail if it's not.
243const _: () = {
244    fn assert_send<T: Send + Sync>() {}
245    let _ = assert_send::<Client>;
246};
247
248#[async_trait]
249impl RequestRunner for Client {
250    fn make_url(&self, path: &str) -> Result<Url> {
251        Ok(self.config.base_url.join(path)?)
252    }
253
254    fn prepare_request(
255        &self,
256        method: http::Method,
257        url: Url,
258    ) -> Result<RequestBuilder> {
259        let request = self
260            .http_client
261            .request(method, url)
262            .bearer_auth(&self.config.secret_token);
263
264        Ok(request)
265    }
266}
267
268#[cfg(test)]
269mod tests {}