fw_client/
lib.rs

1//! A client for interacting with the FW API.
2//! See the [`FWClientBuilder`] for more information on how to create a client.
3//!
4//! See the [`FWClient`] documentation for more information on how to use the client.
5use hyper::header::HeaderValue;
6use reqwest::{header, Client, Method};
7use reqwest_middleware::{
8    ClientBuilder, ClientWithMiddleware, Middleware, RequestBuilder, RequestInitialiser,
9};
10use reqwest_retry::{
11    policies::ExponentialBackoff, RetryPolicy, RetryTransientMiddleware, RetryableStrategy,
12};
13use reqwest_tracing::TracingMiddleware;
14use std::{str::FromStr, sync::Arc, time::Duration};
15use thiserror::Error;
16
17pub use crate::middleware::FWOptions;
18use crate::{
19    headers::{get_user_agent, ApiKey},
20    middleware::FWRetryTransientMiddleware,
21};
22
23pub mod headers;
24pub mod middleware;
25#[cfg(test)]
26mod test;
27
28#[derive(Debug, Error)]
29pub enum FWClientError {
30    #[error(
31        "Invalid API key format, expected format: [<scheme>://]<host>[:<port>]:<key>, found: {0}"
32    )]
33    InvalidApiKey(String),
34    #[error("Missing component: {0}")]
35    MissingComponent(String),
36    #[error("Could not construct HTTP client")]
37    HttpClientError(#[from] reqwest::Error),
38    #[error("Failed to parse header value")]
39    HeaderValueError(#[from] reqwest::header::InvalidHeaderValue),
40}
41
42enum InnerKey {
43    ApiKey(ApiKey),
44    String(String),
45}
46
47/// Builder for the `FWClient`.
48///
49/// This builder allows you to configure the client with an API key, client name,
50/// client version.
51///
52/// For further control you can also add custom middleware and request initializers
53/// using the [`FWClientBuilder::with`] and [`FWClientBuilder::with_init`] methods.
54/// For more information on how to write a Middleware or Request Initialiser, see the
55/// [`reqwest_middleware::Middleware`] and [`reqwest_middleware::RequestInitialiser`]
56/// traits.
57///
58/// Example:
59///
60/// ```rust
61/// use fw_client::{FWClientBuilder, FWClient};
62/// let client = FWClientBuilder::from("scitran-user fw_instance:test_api_key")
63///    .client_name("MyClient".to_string())
64///    .client_version("1.0.0".to_string())
65///    .build();
66/// ```
67pub struct FWClientBuilder {
68    api_key: InnerKey,
69    client_name: Option<String>,
70    client_version: Option<String>,
71    middleware_stack: Vec<Arc<dyn Middleware>>,
72    initialiser_stack: Vec<Arc<dyn RequestInitialiser>>,
73    retry: Arc<dyn Middleware>,
74    tracing: Option<Arc<dyn Middleware>>,
75}
76
77impl Default for FWClientBuilder {
78    /// Create a new `FWClientBuilder` with default values.
79    ///
80    /// This also includes a default retry policy with an exponential backoff strategy
81    /// and a tracing middleware.
82    ///
83    /// The default values are:
84    /// - `api_key`: Empty string
85    /// - `client_name`: None
86    /// - `client_version`: None
87    /// - `timeout`: 60 seconds
88    /// - `connect_timeout`: 10 seconds
89    /// - `read_timeout`: 60 seconds
90    fn default() -> Self {
91        Self {
92            api_key: InnerKey::String(String::new()),
93            client_name: None,
94            client_version: None,
95            middleware_stack: vec![],
96            initialiser_stack: vec![],
97            tracing: Some(Arc::new(TracingMiddleware::default())),
98            retry: Arc::new(FWRetryTransientMiddleware::new(Some(
99                RetryTransientMiddleware::new_with_policy(
100                    ExponentialBackoff::builder().build_with_max_retries(3),
101                ),
102            ))),
103        }
104    }
105}
106
107impl<T: AsRef<str>> From<T> for FWClientBuilder {
108    fn from(api_key: T) -> Self {
109        Self {
110            api_key: InnerKey::String(api_key.as_ref().to_string()),
111            ..Default::default()
112        }
113    }
114}
115
116#[allow(dead_code)]
117impl FWClientBuilder {
118    /// Create a new `FWClientBuilder` with the provided API key.
119    ///
120    /// See the [`ApiKey`] documentation for the expected format.
121    ///
122    /// You can also use the [`From`] trait to create a builder from a string:
123    ///
124    /// ```rust
125    /// use fw_client::FWClientBuilder;
126    /// let builder = FWClientBuilder::from("scitran-user fw_instance:test_api_key");
127    /// // or
128    /// let builder = FWClientBuilder::new("scitran-user fw_instance:test_api_key".parse().unwrap());;
129    /// ```
130    pub fn new(api_key: ApiKey) -> Self {
131        Self {
132            api_key: InnerKey::ApiKey(api_key),
133            ..Default::default()
134        }
135    }
136
137    /// Set the client name. Used in "User-Agent" header.
138    pub fn client_name(mut self, name: String) -> Self {
139        self.client_name = Some(name);
140        self
141    }
142
143    /// Set client version. Used in "User-Agent" header.
144    pub fn client_version(mut self, version: String) -> Self {
145        self.client_version = Some(version);
146        self
147    }
148
149    /// Sets whether the client should use tracing middleware, defaults to `true`.
150    pub fn tracing(mut self, tracing: bool) -> Self {
151        if tracing {
152            self
153        } else {
154            self.tracing = None;
155            self
156        }
157    }
158
159    /// Set the retry policy and strategy for the client,
160    ///
161    /// By default an exponential backoff policy with 3 retries is used.
162    ///
163    /// For example you can create a simple retry policy that retries 5 times with
164    /// 1 second delay between retries:
165    ///
166    /// ```rust
167    /// use reqwest_retry::{RetryPolicy, RetryTransientMiddleware, RetryDecision, RetryableStrategy};
168    /// use fw_client::{FWClientBuilder, FWClient};
169    /// use std::time::{Duration, SystemTime};
170    /// struct SimpleRetryPolicy(u32);
171    ///
172    /// impl RetryPolicy for SimpleRetryPolicy {
173    ///    fn should_retry(&self, _: SystemTime, attempt: u32) -> RetryDecision {
174    ///        if attempt >= self.0 {
175    ///            RetryDecision::DoNotRetry
176    ///        } else {
177    ///            RetryDecision::Retry{
178    ///                execute_after: SystemTime::now() + Duration::from_secs(1)
179    ///            }
180    ///        }
181    ///    }
182    /// }
183    /// let client = FWClientBuilder::from("fw_instance:test_api_key")
184    ///    .with_retry(Some(RetryTransientMiddleware::new_with_policy(
185    ///        SimpleRetryPolicy(3)
186    ///     )))
187    ///    .build()
188    ///    .expect("Failed to create FWClient");
189    /// ```
190    pub fn with_retry<
191        T: RetryPolicy + Send + Sync + 'static,
192        R: RetryableStrategy + Send + Sync + 'static,
193    >(
194        mut self,
195        retry: Option<RetryTransientMiddleware<T, R>>,
196    ) -> Self {
197        self.retry = Arc::new(FWRetryTransientMiddleware::new(retry));
198        self
199    }
200
201    /// Convenience method to attach middleware.
202    ///
203    /// If you need to keep a reference to the middleware after attaching, use [`with_arc`].
204    ///
205    /// [`with_arc`]: Self::with_arc
206    pub fn with<M>(self, middleware: M) -> Self
207    where
208        M: Middleware,
209    {
210        self.with_arc(Arc::new(middleware))
211    }
212
213    /// Add middleware to the chain. [`with`] is more ergonomic if you don't need the `Arc`.
214    ///
215    /// [`with`]: Self::with
216    pub fn with_arc(mut self, middleware: Arc<dyn Middleware>) -> Self {
217        self.middleware_stack.push(middleware);
218        self
219    }
220
221    /// Convenience method to attach a request initialiser.
222    ///
223    /// If you need to keep a reference to the initialiser after attaching, use [`with_arc_init`].
224    ///
225    /// [`with_arc_init`]: Self::with_arc_init
226    pub fn with_init<I>(self, initialiser: I) -> Self
227    where
228        I: RequestInitialiser,
229    {
230        self.with_arc_init(Arc::new(initialiser))
231    }
232
233    /// Add a request initialiser to the chain. [`with_init`] is more ergonomic if you don't need the `Arc`.
234    ///
235    /// [`with_init`]: Self::with_init
236    pub fn with_arc_init(mut self, initialiser: Arc<dyn RequestInitialiser>) -> Self {
237        self.initialiser_stack.push(initialiser);
238        self
239    }
240
241    /// Build the `FWClient` with the provided configuration
242    ///
243    /// Uses default configuration for the underlying `reqwest` client.
244    /// * Timeouts: 60 seconds for read and write, 10 seconds for connect
245    /// * Redirect policy: limited to 10 redirects
246    ///
247    /// If you need to customise the `reqwest::Client`, use [`FWClientBuilder::build_with_client`].
248    pub fn build(self) -> Result<FWClient, FWClientError> {
249        let client_builder = Client::builder()
250            .timeout(Duration::from_secs(60))
251            .read_timeout(Duration::from_secs(60))
252            .connect_timeout(Duration::from_secs(10))
253            .redirect(reqwest::redirect::Policy::limited(10));
254
255        let client: Client = client_builder.build()?;
256        self.build_with_client(client)
257    }
258
259    /// Build the `FWClient` with the provided `reqwest::Client`.
260    ///
261    /// ```rust,ignore
262    /// use reqwest::Client;
263    /// use fw_client::FWClientBuilder;
264    /// use reqwest::Certificate;
265    /// let client = reqwest::Client::builder()
266    ///     // Override default timeouts
267    ///    .timeout(std::time::Duration::from_secs(120))
268    ///    .connect_timeout(std::time::Duration::from_secs(30))
269    ///     // Add custom root certificate
270    ///    .add_root_certificate(Certificate::from_pem(include_bytes!("path/to/cert.pem")).unwrap())
271    ///    .build()
272    ///    .unwrap();
273    ///
274    /// let fw_client = FWClientBuilder::new("scitran-user fw_instance:test_api_key".parse().unwrap())
275    ///    .build_with_client(client)
276    ///    .unwrap();
277    /// ```
278    pub fn build_with_client(self, client: reqwest::Client) -> Result<FWClient, FWClientError> {
279        let api_key = match self.api_key {
280            InnerKey::ApiKey(api_key) => api_key,
281            InnerKey::String(api_key) => ApiKey::from_str(api_key.as_ref())?,
282        };
283        let mut new_client = ClientBuilder::new(client.clone());
284        // Add tracing and retry middleware if they are set
285        if let Some(tracing) = self.tracing {
286            new_client = new_client.with_arc(tracing);
287        }
288        new_client = new_client.with_arc(self.retry);
289        // Add the other middlewares
290        for middleware in self.middleware_stack {
291            new_client = new_client.with_arc(middleware);
292        }
293        for initialiser in self.initialiser_stack {
294            new_client = new_client.with_arc_init(initialiser);
295        }
296
297        Ok(FWClient {
298            scheme: api_key.scheme.clone().unwrap_or("https://".to_string()),
299            client: new_client.build(),
300            base_url: match &api_key.port {
301                Some(port) => format!("{}{}", &api_key.host, port),
302                None => api_key.host.clone(),
303            },
304            user_agent: get_user_agent(self.client_name, self.client_version).parse()?,
305            api_key: api_key.to_string().parse()?,
306        })
307    }
308}
309
310/// A client for interacting with the FW API.
311///
312/// Wraps the `reqwest` crates `Client` and `RequestBuilder`, but includes middleware for retrying all requests
313/// are authenticated with the provided API key and set the user agent.
314///
315/// ```rust,no_run
316/// use serde_json::Value;
317/// use fw_client::{FWClientBuilder, FWClient};
318/// #[tokio::main]
319/// pub async fn main() {
320/// // Initiate client
321/// let client = FWClientBuilder::from("scitran-user httpbin.org:test_api_key")
322///     .build()
323///     .expect("Failed to create FWClient");
324///
325/// // Send a POST request with headers, query parameters, and a body
326/// let resp = client.post("/post")
327///     .header("Content-Type", "application/json")
328///     .query(&[("param", "value1")])
329///     .body(r#"{"key": "value2"}"#)
330///     .send()
331///     .await
332///     .unwrap()
333///     .json::<Value>()
334///     .await
335///     .unwrap();
336///
337/// assert_eq!(resp["json"]["key"], "value2");
338/// assert_eq!(resp["args"]["param"], "value1");
339/// assert_eq!(resp["headers"]["Authorization"], "scitran-user httpbin.org:test_api_key");
340/// }
341/// ```
342///
343/// If you need to request a full URL instead of an endpoint, you can use the
344/// [`FWClient::request`] method.
345/// ```rust,no_run
346/// use reqwest::Method;
347/// use serde_json::Value;
348/// use fw_client::{FWClientBuilder, FWClient};
349/// #[tokio::main]
350/// pub async fn main() {
351/// // Initiate client
352/// let client = FWClientBuilder::new("scitran-user fw_instance:test_api_key".parse().unwrap())
353///     .build()
354///     .expect("Failed to create FWClient");
355///
356/// // Send a PUT request with an absolute URL and no authentication
357/// let resp = client.request(Method::PUT, "https://httpbin.org/put")
358///     .header("Content-Type", "application/json")
359///     .body(r#"{"key": "value"}"#)
360///     .send()
361///     .await
362///     .unwrap()
363///     .json::<Value>()
364///     .await;
365/// }
366/// ```
367///
368/// If you need to skip authentication or retry logic for a specific request, you can use the
369/// [`FWOptions`] extension. This is useful for requests that can't have authorization, or for
370/// requests that can't be retried (e.g. POST requests that create resources).
371///
372/// ```rust,no_run
373/// use reqwest::Method;
374/// use serde_json::Value;
375/// use fw_client::{FWClientBuilder, FWClient, FWOptions};
376/// # #[tokio::main]
377/// # pub async fn main() {
378/// // Initiate client
379/// let client = FWClientBuilder::new("scitran-user httpbin.org:test_api_key".parse().unwrap())
380///     .build()
381///     .expect("Failed to create FWClient");
382///
383/// // Send a PUT request with an absolute URL and no authentication
384/// let resp = client.post("/post")
385///     .header("Content-Type", "application/json")
386///     .body(r#"{"key": "value"}"#)
387///     .with_extension(fw_client::FWOptions {
388///         skip_retry: true, // Skip retry logic for this request
389///         no_auth: true, // Skip authentication for this request
390///     })
391///     .send()
392///     .await
393///     .unwrap()
394///     .json::<Value>()
395///     .await
396///     .unwrap();
397/// assert!(resp["headers"]["Authorization"].is_null());
398/// # }
399/// ```
400#[derive(Debug, Clone)]
401pub struct FWClient {
402    scheme: String,
403    base_url: String,
404    client: ClientWithMiddleware,
405    user_agent: HeaderValue,
406    api_key: HeaderValue,
407}
408
409#[allow(dead_code)]
410impl FWClient {
411    fn url(&self, endpoint: &str) -> String {
412        format!("{}{}{}", self.scheme, self.base_url, endpoint)
413    }
414
415    /// Create a new get request with the given endpoint
416    pub fn get(&self, endpoint: &str) -> RequestBuilder {
417        self.client
418            .get(self.url(endpoint))
419            .header(header::USER_AGENT, self.user_agent.clone())
420            .header(header::AUTHORIZATION, self.api_key.clone())
421    }
422
423    /// Create a new post request with the given endpoint
424    pub fn post(&self, endpoint: &str) -> RequestBuilder {
425        self.client
426            .post(self.url(endpoint))
427            .header(header::USER_AGENT, self.user_agent.clone())
428            .header(header::AUTHORIZATION, self.api_key.clone())
429    }
430
431    /// Create a new put request with the given endpoint
432    pub fn put(&self, endpoint: &str) -> RequestBuilder {
433        self.client
434            .put(self.url(endpoint))
435            .header(header::USER_AGENT, self.user_agent.clone())
436            .header(header::AUTHORIZATION, self.api_key.clone())
437    }
438
439    /// Create a new delete request with the given endpoint
440    pub fn delete(&self, endpoint: &str) -> RequestBuilder {
441        self.client
442            .delete(self.url(endpoint))
443            .header(header::USER_AGENT, self.user_agent.clone())
444            .header(header::AUTHORIZATION, self.api_key.clone())
445    }
446
447    /// Create a new head request with the given endpoint
448    pub fn head(&self, endpoint: &str) -> RequestBuilder {
449        self.client
450            .head(self.url(endpoint))
451            .header(header::USER_AGENT, self.user_agent.clone())
452            .header(header::AUTHORIZATION, self.api_key.clone())
453    }
454
455    /// Create a new patch request with the given endpoint
456    pub fn patch(&self, endpoint: &str) -> RequestBuilder {
457        self.client
458            .patch(self.url(endpoint))
459            .header(header::USER_AGENT, self.user_agent.clone())
460            .header(header::AUTHORIZATION, self.api_key.clone())
461    }
462
463    /// Create a new request with a given method, and absolute URL
464    pub fn request(&self, method: Method, url: &str) -> RequestBuilder {
465        self.client
466            .request(method, url)
467            .header(header::USER_AGENT, self.user_agent.clone())
468            .header(header::AUTHORIZATION, self.api_key.clone())
469    }
470}