open_library_api_rs/
client.rs1use 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#[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 pub fn builder() -> OpenLibraryClientBuilder {
38 OpenLibraryClientBuilder::default()
39 }
40
41 pub(crate) async fn wait_for_slot(&self) {
44 self.rate_limiter.until_ready().await;
45 }
46
47 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 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 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
146pub 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 pub fn base_url(mut self, url: impl Into<String>) -> Self {
174 self.base_url = url.into();
175 self
176 }
177
178 pub fn covers_url(mut self, url: impl Into<String>) -> Self {
180 self.covers_url = url.into();
181 self
182 }
183
184 pub fn connect_timeout(mut self, d: Duration) -> Self {
186 self.connect_timeout = d;
187 self
188 }
189
190 pub fn timeout(mut self, d: Duration) -> Self {
192 self.request_timeout = d;
193 self
194 }
195
196 pub fn rate_limit(mut self, rps: u32) -> Self {
199 self.rate_limit_rps = rps;
200 self
201 }
202
203 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 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#[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}