open-library-api-rs 0.1.0

Async Rust client for the Open Library API
Documentation
// v0.0.1
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>;

/// Async client for the Open Library API.
///
/// Construct via [`OpenLibraryClient::builder()`]. The client is `Clone`, `Send`, and `Sync`
/// — clone it cheaply to share across tasks.
#[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 {
    /// Start building a client with default settings.
    pub fn builder() -> OpenLibraryClientBuilder {
        OpenLibraryClientBuilder::default()
    }

    /// Wait for a rate-limit token, then check for and return the rate-limit error if the
    /// last call returned 429 — otherwise proceed. Called internally before every request.
    pub(crate) async fn wait_for_slot(&self) {
        self.rate_limiter.until_ready().await;
    }

    /// Perform a GET request and deserialize the JSON body into `T`.
    /// Enforces the 10 MB body cap and maps common HTTP error codes.
    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(),
            });
        }

        // Enforce body-size cap before deserializing.
        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(),
        })
    }

    /// Perform a GET request on the covers subdomain and deserialize the JSON body.
    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(),
        })
    }
}

// ── Builder ───────────────────────────────────────────────────────────────────

/// Builder for [`OpenLibraryClient`].
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 {
    /// Override the base URL (useful for testing with a local mock server).
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into();
        self
    }

    /// Override the covers base URL.
    pub fn covers_url(mut self, url: impl Into<String>) -> Self {
        self.covers_url = url.into();
        self
    }

    /// TCP connection timeout (default 10 s).
    pub fn connect_timeout(mut self, d: Duration) -> Self {
        self.connect_timeout = d;
        self
    }

    /// Total request timeout (default 30 s).
    pub fn timeout(mut self, d: Duration) -> Self {
        self.request_timeout = d;
        self
    }

    /// Requests per second for the built-in rate limiter (default 1 req/s).
    /// Set to 3 when you also call `contact_email` to unlock the identified-tier limit.
    pub fn rate_limit(mut self, rps: u32) -> Self {
        self.rate_limit_rps = rps;
        self
    }

    /// Provide a contact email address. This is appended to the User-Agent header so the
    /// Open Library API can identify your application and grant the 3 req/s tier.
    /// Call `rate_limit(3)` together with this to actually use that tier.
    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)
    }

    /// Build the [`OpenLibraryClient`].
    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,
        })
    }
}

// ── Compile-time Send + Sync assertion ───────────────────────────────────────

#[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);
    }
}