use std::fmt::Write as _; use serde::{Deserialize, Serialize}; use crate::error::{PinakesError, Result}; /// `OpenLibrary` API client for book metadata enrichment pub struct OpenLibraryClient { client: reqwest::Client, base_url: String, } impl Default for OpenLibraryClient { fn default() -> Self { Self::new() } } impl OpenLibraryClient { /// Create a new `OpenLibraryClient`. #[must_use] pub fn new() -> Self { let client = reqwest::Client::builder() .user_agent("Pinakes/1.0") .timeout(std::time::Duration::from_secs(10)) .build() .unwrap_or_else(|_| reqwest::Client::new()); Self { client, base_url: "https://openlibrary.org".to_string(), } } /// Fetch book metadata by ISBN /// /// # Errors /// /// Returns an error if the HTTP request fails or the response cannot be /// parsed. pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { let url = format!("{}/isbn/{}.json", self.base_url, isbn); let response = self.client.get(&url).send().await.map_err(|e| { PinakesError::External(format!("OpenLibrary request failed: {e}")) })?; if !response.status().is_success() { return Err(PinakesError::External(format!( "OpenLibrary returned status: {}", response.status() ))); } response.json::().await.map_err(|e| { PinakesError::External(format!( "Failed to parse OpenLibrary response: {e}" )) }) } /// Search for books by title and author /// /// # Errors /// /// Returns an error if the HTTP request fails or the response cannot be /// parsed. pub async fn search( &self, title: &str, author: Option<&str>, ) -> Result> { let mut url = format!( "{}/search.json?title={}", self.base_url, urlencoding::encode(title) ); if let Some(author) = author { let _ = write!(url, "&author={}", urlencoding::encode(author)); } url.push_str("&limit=5"); let response = self.client.get(&url).send().await.map_err(|e| { PinakesError::External(format!("OpenLibrary search failed: {e}")) })?; if !response.status().is_success() { return Err(PinakesError::External(format!( "OpenLibrary search returned status: {}", response.status() ))); } let search_response: OpenLibrarySearchResponse = response.json().await.map_err(|e| { PinakesError::External(format!("Failed to parse search results: {e}")) })?; Ok(search_response.docs) } /// Fetch cover image by cover ID /// /// # Errors /// /// Returns an error if the HTTP request fails or the response cannot be /// read. pub async fn fetch_cover( &self, cover_id: i64, size: CoverSize, ) -> Result> { let size_str = match size { CoverSize::Small => "S", CoverSize::Medium => "M", CoverSize::Large => "L", }; let url = format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg"); let response = self.client.get(&url).send().await.map_err(|e| { PinakesError::External(format!("Cover download failed: {e}")) })?; if !response.status().is_success() { return Err(PinakesError::External(format!( "Cover download returned status: {}", response.status() ))); } response.bytes().await.map(|b| b.to_vec()).map_err(|e| { PinakesError::External(format!("Failed to read cover data: {e}")) }) } /// Fetch cover by ISBN /// /// # Errors /// /// Returns an error if the HTTP request fails or the response cannot be /// read. pub async fn fetch_cover_by_isbn( &self, isbn: &str, size: CoverSize, ) -> Result> { let size_str = match size { CoverSize::Small => "S", CoverSize::Medium => "M", CoverSize::Large => "L", }; let url = format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg"); let response = self.client.get(&url).send().await.map_err(|e| { PinakesError::External(format!("Cover download failed: {e}")) })?; if !response.status().is_success() { return Err(PinakesError::External(format!( "Cover download returned status: {}", response.status() ))); } response.bytes().await.map(|b| b.to_vec()).map_err(|e| { PinakesError::External(format!("Failed to read cover data: {e}")) }) } } #[derive(Debug, Clone, Copy)] pub enum CoverSize { Small, // 256x256 Medium, // 600x800 Large, // Original } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenLibraryBook { #[serde(default)] pub title: Option, #[serde(default)] pub subtitle: Option, #[serde(default)] pub authors: Vec, #[serde(default)] pub publishers: Vec, #[serde(default)] pub publish_date: Option, #[serde(default)] pub number_of_pages: Option, #[serde(default)] pub subjects: Vec, #[serde(default)] pub covers: Vec, #[serde(default)] pub isbn_10: Vec, #[serde(default)] pub isbn_13: Vec, #[serde(default)] pub series: Vec, #[serde(default)] pub description: Option, #[serde(default)] pub languages: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorRef { pub key: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LanguageRef { pub key: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum StringOrObject { String(String), Object { value: String }, } impl StringOrObject { #[must_use] pub fn as_str(&self) -> &str { match self { Self::String(s) => s, Self::Object { value } => value, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenLibrarySearchResponse { #[serde(default)] pub docs: Vec, #[serde(default)] pub num_found: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenLibrarySearchResult { #[serde(default)] pub key: Option, #[serde(default)] pub title: Option, #[serde(default)] pub author_name: Vec, #[serde(default)] pub first_publish_year: Option, #[serde(default)] pub publisher: Vec, #[serde(default)] pub isbn: Vec, #[serde(default)] pub cover_i: Option, #[serde(default)] pub subject: Vec, } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_openlibrary_client_creation() { let client = OpenLibraryClient::new(); assert_eq!(client.base_url, "https://openlibrary.org"); } #[test] fn test_string_or_object_parsing() { let string_desc: StringOrObject = serde_json::from_str(r#""Simple description""#).unwrap(); assert_eq!(string_desc.as_str(), "Simple description"); let object_desc: StringOrObject = serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); assert_eq!(object_desc.as_str(), "Object description"); } }