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}