Skip to main content

open_library_api_rs/
client.rs

1// v0.0.1
2use std::num::NonZeroU32;
3use std::sync::Arc;
4use std::time::Duration;
5
6use governor::clock::DefaultClock;
7use governor::middleware::NoOpMiddleware;
8use governor::state::{InMemoryState, NotKeyed};
9use governor::{Quota, RateLimiter};
10use url::Url;
11
12use crate::error::{Error, Result};
13use crate::validation::validate_contact_email;
14
15const DEFAULT_BASE_URL: &str = "https://openlibrary.org";
16const DEFAULT_COVERS_URL: &str = "https://covers.openlibrary.org";
17const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
18const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
19const DEFAULT_RATE_LIMIT_RPS: u32 = 1;
20
21type Limiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock, NoOpMiddleware>;
22
23/// Async client for the Open Library API.
24///
25/// Construct via [`OpenLibraryClient::builder()`]. The client is `Clone`, `Send`, and `Sync`
26/// — clone it cheaply to share across tasks.
27#[derive(Clone)]
28pub struct OpenLibraryClient {
29    pub(crate) http: reqwest::Client,
30    pub(crate) base_url: Url,
31    pub(crate) covers_url: Url,
32    pub(crate) rate_limiter: Arc<Limiter>,
33}
34
35impl OpenLibraryClient {
36    /// Start building a client with default settings.
37    pub fn builder() -> OpenLibraryClientBuilder {
38        OpenLibraryClientBuilder::default()
39    }
40
41    /// Wait for a rate-limit token, then check for and return the rate-limit error if the
42    /// last call returned 429 — otherwise proceed. Called internally before every request.
43    pub(crate) async fn wait_for_slot(&self) {
44        self.rate_limiter.until_ready().await;
45    }
46
47    /// Perform a GET request and deserialize the JSON body into `T`.
48    /// Enforces the 10 MB body cap and maps common HTTP error codes.
49    pub(crate) async fn get_json<T>(&self, url: Url) -> Result<T>
50    where
51        T: serde::de::DeserializeOwned,
52    {
53        self.wait_for_slot().await;
54
55        let response = self
56            .http
57            .get(url.clone())
58            .send()
59            .await
60            .map_err(|e| {
61                if e.is_timeout() {
62                    Error::Timeout
63                } else {
64                    Error::Http(e)
65                }
66            })?;
67
68        let status = response.status();
69
70        if status == reqwest::StatusCode::NOT_FOUND {
71            return Err(Error::NotFound(url.to_string()));
72        }
73        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
74            return Err(Error::RateLimited);
75        }
76        if !status.is_success() {
77            return Err(Error::Status {
78                code: status.as_u16(),
79                url: url.to_string(),
80            });
81        }
82
83        // Enforce body-size cap before deserializing.
84        let cap = crate::validation::max_response_bytes();
85        if let Some(len) = response.content_length()
86            && len > cap
87        {
88            return Err(Error::ResponseTooLarge);
89        }
90
91        let bytes = response.bytes().await.map_err(Error::Http)?;
92        if bytes.len() as u64 > cap {
93            return Err(Error::ResponseTooLarge);
94        }
95
96        serde_json::from_slice(&bytes).map_err(|source| Error::Deserialize {
97            source,
98            body: String::from_utf8_lossy(&bytes).into_owned(),
99        })
100    }
101
102    /// Perform a GET request on the covers subdomain and deserialize the JSON body.
103    pub(crate) async fn get_covers_json<T>(&self, url: Url) -> Result<T>
104    where
105        T: serde::de::DeserializeOwned,
106    {
107        self.wait_for_slot().await;
108
109        let response = self
110            .http
111            .get(url.clone())
112            .send()
113            .await
114            .map_err(|e| {
115                if e.is_timeout() {
116                    Error::Timeout
117                } else {
118                    Error::Http(e)
119                }
120            })?;
121
122        let status = response.status();
123        if status == reqwest::StatusCode::NOT_FOUND {
124            return Err(Error::NotFound(url.to_string()));
125        }
126        if !status.is_success() {
127            return Err(Error::Status {
128                code: status.as_u16(),
129                url: url.to_string(),
130            });
131        }
132
133        let cap = crate::validation::max_response_bytes();
134        let bytes = response.bytes().await.map_err(Error::Http)?;
135        if bytes.len() as u64 > cap {
136            return Err(Error::ResponseTooLarge);
137        }
138
139        serde_json::from_slice(&bytes).map_err(|source| Error::Deserialize {
140            source,
141            body: String::from_utf8_lossy(&bytes).into_owned(),
142        })
143    }
144}
145
146// ── Builder ───────────────────────────────────────────────────────────────────
147
148/// Builder for [`OpenLibraryClient`].
149pub struct OpenLibraryClientBuilder {
150    base_url: String,
151    covers_url: String,
152    connect_timeout: Duration,
153    request_timeout: Duration,
154    rate_limit_rps: u32,
155    contact_email: Option<String>,
156}
157
158impl Default for OpenLibraryClientBuilder {
159    fn default() -> Self {
160        Self {
161            base_url: DEFAULT_BASE_URL.to_string(),
162            covers_url: DEFAULT_COVERS_URL.to_string(),
163            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
164            request_timeout: DEFAULT_REQUEST_TIMEOUT,
165            rate_limit_rps: DEFAULT_RATE_LIMIT_RPS,
166            contact_email: None,
167        }
168    }
169}
170
171impl OpenLibraryClientBuilder {
172    /// Override the base URL (useful for testing with a local mock server).
173    pub fn base_url(mut self, url: impl Into<String>) -> Self {
174        self.base_url = url.into();
175        self
176    }
177
178    /// Override the covers base URL.
179    pub fn covers_url(mut self, url: impl Into<String>) -> Self {
180        self.covers_url = url.into();
181        self
182    }
183
184    /// TCP connection timeout (default 10 s).
185    pub fn connect_timeout(mut self, d: Duration) -> Self {
186        self.connect_timeout = d;
187        self
188    }
189
190    /// Total request timeout (default 30 s).
191    pub fn timeout(mut self, d: Duration) -> Self {
192        self.request_timeout = d;
193        self
194    }
195
196    /// Requests per second for the built-in rate limiter (default 1 req/s).
197    /// Set to 3 when you also call `contact_email` to unlock the identified-tier limit.
198    pub fn rate_limit(mut self, rps: u32) -> Self {
199        self.rate_limit_rps = rps;
200        self
201    }
202
203    /// Provide a contact email address. This is appended to the User-Agent header so the
204    /// Open Library API can identify your application and grant the 3 req/s tier.
205    /// Call `rate_limit(3)` together with this to actually use that tier.
206    pub fn contact_email(mut self, email: impl Into<String>) -> Result<Self> {
207        let e = email.into();
208        validate_contact_email(&e)?;
209        self.contact_email = Some(e);
210        Ok(self)
211    }
212
213    /// Build the [`OpenLibraryClient`].
214    pub fn build(self) -> Result<OpenLibraryClient> {
215        let user_agent = match &self.contact_email {
216            Some(email) => format!("open-library-api-rs/0.1.0 ({email})"),
217            None => "open-library-api-rs/0.1.0".to_string(),
218        };
219
220        let http = reqwest::Client::builder()
221            .user_agent(user_agent)
222            .connect_timeout(self.connect_timeout)
223            .timeout(self.request_timeout)
224            .build()
225            .map_err(Error::Http)?;
226
227        let rps = NonZeroU32::new(self.rate_limit_rps.max(1))
228            .expect("rate limit rps is always ≥ 1");
229        let limiter = Arc::new(RateLimiter::direct(Quota::per_second(rps)));
230
231        Ok(OpenLibraryClient {
232            http,
233            base_url: Url::parse(&self.base_url)?,
234            covers_url: Url::parse(&self.covers_url)?,
235            rate_limiter: limiter,
236        })
237    }
238}
239
240// ── Compile-time Send + Sync assertion ───────────────────────────────────────
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn assert_send_sync<T: Send + Sync>(_: &T) {}
247
248    #[test]
249    fn client_is_send_and_sync() {
250        let client = OpenLibraryClient::builder().build().unwrap();
251        assert_send_sync(&client);
252    }
253}