use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
use governor::clock::DefaultClock;
use governor::middleware::NoOpMiddleware;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
use url::Url;
use crate::error::{Error, Result};
use crate::validation::validate_contact_email;
const DEFAULT_BASE_URL: &str = "https://openlibrary.org";
const DEFAULT_COVERS_URL: &str = "https://covers.openlibrary.org";
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_RATE_LIMIT_RPS: u32 = 1;
type Limiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock, NoOpMiddleware>;
#[derive(Clone)]
pub struct OpenLibraryClient {
pub(crate) http: reqwest::Client,
pub(crate) base_url: Url,
pub(crate) covers_url: Url,
pub(crate) rate_limiter: Arc<Limiter>,
}
impl OpenLibraryClient {
pub fn builder() -> OpenLibraryClientBuilder {
OpenLibraryClientBuilder::default()
}
pub(crate) async fn wait_for_slot(&self) {
self.rate_limiter.until_ready().await;
}
pub(crate) async fn get_json<T>(&self, url: Url) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
self.wait_for_slot().await;
let response = self
.http
.get(url.clone())
.send()
.await
.map_err(|e| {
if e.is_timeout() {
Error::Timeout
} else {
Error::Http(e)
}
})?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound(url.to_string()));
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
return Err(Error::RateLimited);
}
if !status.is_success() {
return Err(Error::Status {
code: status.as_u16(),
url: url.to_string(),
});
}
let cap = crate::validation::max_response_bytes();
if let Some(len) = response.content_length()
&& len > cap
{
return Err(Error::ResponseTooLarge);
}
let bytes = response.bytes().await.map_err(Error::Http)?;
if bytes.len() as u64 > cap {
return Err(Error::ResponseTooLarge);
}
serde_json::from_slice(&bytes).map_err(|source| Error::Deserialize {
source,
body: String::from_utf8_lossy(&bytes).into_owned(),
})
}
pub(crate) async fn get_covers_json<T>(&self, url: Url) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
self.wait_for_slot().await;
let response = self
.http
.get(url.clone())
.send()
.await
.map_err(|e| {
if e.is_timeout() {
Error::Timeout
} else {
Error::Http(e)
}
})?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound(url.to_string()));
}
if !status.is_success() {
return Err(Error::Status {
code: status.as_u16(),
url: url.to_string(),
});
}
let cap = crate::validation::max_response_bytes();
let bytes = response.bytes().await.map_err(Error::Http)?;
if bytes.len() as u64 > cap {
return Err(Error::ResponseTooLarge);
}
serde_json::from_slice(&bytes).map_err(|source| Error::Deserialize {
source,
body: String::from_utf8_lossy(&bytes).into_owned(),
})
}
}
pub struct OpenLibraryClientBuilder {
base_url: String,
covers_url: String,
connect_timeout: Duration,
request_timeout: Duration,
rate_limit_rps: u32,
contact_email: Option<String>,
}
impl Default for OpenLibraryClientBuilder {
fn default() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
covers_url: DEFAULT_COVERS_URL.to_string(),
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
rate_limit_rps: DEFAULT_RATE_LIMIT_RPS,
contact_email: None,
}
}
}
impl OpenLibraryClientBuilder {
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn covers_url(mut self, url: impl Into<String>) -> Self {
self.covers_url = url.into();
self
}
pub fn connect_timeout(mut self, d: Duration) -> Self {
self.connect_timeout = d;
self
}
pub fn timeout(mut self, d: Duration) -> Self {
self.request_timeout = d;
self
}
pub fn rate_limit(mut self, rps: u32) -> Self {
self.rate_limit_rps = rps;
self
}
pub fn contact_email(mut self, email: impl Into<String>) -> Result<Self> {
let e = email.into();
validate_contact_email(&e)?;
self.contact_email = Some(e);
Ok(self)
}
pub fn build(self) -> Result<OpenLibraryClient> {
let user_agent = match &self.contact_email {
Some(email) => format!("open-library-api-rs/0.1.0 ({email})"),
None => "open-library-api-rs/0.1.0".to_string(),
};
let http = reqwest::Client::builder()
.user_agent(user_agent)
.connect_timeout(self.connect_timeout)
.timeout(self.request_timeout)
.build()
.map_err(Error::Http)?;
let rps = NonZeroU32::new(self.rate_limit_rps.max(1))
.expect("rate limit rps is always ≥ 1");
let limiter = Arc::new(RateLimiter::direct(Quota::per_second(rps)));
Ok(OpenLibraryClient {
http,
base_url: Url::parse(&self.base_url)?,
covers_url: Url::parse(&self.covers_url)?,
rate_limiter: limiter,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_send_sync<T: Send + Sync>(_: &T) {}
#[test]
fn client_is_send_and_sync() {
let client = OpenLibraryClient::builder().build().unwrap();
assert_send_sync(&client);
}
}