1use std::sync::Arc;
16use std::time::Duration;
17
18use aspero_core::csl::null_default;
19use aspero_core::http::{escape_lucene, status_error};
20use aspero_core::{
21 Capabilities, Cursor, Error, FieldSet, Filter, Hit, HttpClient, HttpRequest, HttpResponse,
22 IdKind, Isbn, Lookup, Page, PartialDate, Person, Provider, ProviderId, Raw, ResiliencePolicy,
23 Response, Result, SearchMode, SearchQuery, SourceRecord, Work, WorkId, WorkType,
24};
25use async_trait::async_trait;
26use jiff::Timestamp;
27use serde::Deserialize;
28
29pub const ID: ProviderId = ProviderId::from_static("openlibrary");
30
31pub const DEFAULT_BASE_URL: &str = "https://openlibrary.org";
32
33const MAX_BATCH: usize = 50;
35
36const MAX_PAGE: u32 = 100;
39
40const MAX_WORK_ISBNS: usize = 20;
43
44const SEARCH_FIELDS: &str = "key,title,subtitle,author_name,first_publish_year,publish_year,isbn,publisher,number_of_pages_median";
45
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct OpenLibraryConfig {
49 pub base_url: String,
50}
51
52impl Default for OpenLibraryConfig {
53 fn default() -> Self {
54 OpenLibraryConfig {
55 base_url: DEFAULT_BASE_URL.to_owned(),
56 }
57 }
58}
59
60pub struct OpenLibrary {
61 config: OpenLibraryConfig,
62 http: Arc<dyn HttpClient>,
63 capabilities: Capabilities,
64}
65
66impl OpenLibrary {
67 pub fn new(config: OpenLibraryConfig, http: Arc<dyn HttpClient>) -> Self {
68 let mut capabilities = Capabilities::default();
69 capabilities.lookup_kinds = vec![IdKind::Isbn];
70 capabilities.max_batch = MAX_BATCH;
71 capabilities.fields = FieldSet::of(&[
72 aspero_core::Field::Title,
73 aspero_core::Field::Authors,
74 aspero_core::Field::Issued,
75 aspero_core::Field::Type,
76 aspero_core::Field::Publication,
77 aspero_core::Field::Url,
78 ]);
79 capabilities.search = true;
80 capabilities.search_filters = vec![Filter::Years];
81 OpenLibrary {
82 config,
83 http,
84 capabilities,
85 }
86 }
87
88 pub fn default_policy() -> ResiliencePolicy {
90 let mut policy = ResiliencePolicy::default();
91 policy.min_interval = Duration::from_millis(600);
92 policy
93 }
94
95 async fn get(&self, path: &str, params: &[(&str, String)]) -> Result<HttpResponse> {
96 let base = format!("{}{path}", self.config.base_url.trim_end_matches('/'));
97 let url = url::Url::parse_with_params(&base, params)
98 .map_err(|e| Error::BadRequest(format!("invalid Open Library base URL: {e}")))?;
99 let resp = self
100 .http
101 .send(HttpRequest::get(String::from(url)).header("accept", "application/json"))
102 .await?;
103 check(&resp)?;
104 Ok(resp)
105 }
106}
107
108fn check(resp: &HttpResponse) -> Result<()> {
109 if resp.is_success() {
110 return Ok(());
111 }
112 Err(match resp.status {
113 400 | 422 => Error::BadRequest("rejected by Open Library".into()),
114 _ => status_error(resp, None),
115 })
116}
117
118#[derive(Debug, Default, Deserialize)]
120#[serde(default)]
121struct Edition {
122 title: Option<String>,
123 subtitle: Option<String>,
124 #[serde(deserialize_with = "null_default")]
125 authors: Vec<Named>,
126 publish_date: Option<String>,
127 #[serde(deserialize_with = "null_default")]
128 publishers: Vec<Named>,
129 #[serde(deserialize_with = "null_default")]
130 identifiers: Identifiers,
131 url: Option<String>,
132}
133
134#[derive(Debug, Default, Deserialize)]
135#[serde(default)]
136struct Named {
137 name: Option<String>,
138}
139
140#[derive(Debug, Default, Deserialize)]
141#[serde(default)]
142struct Identifiers {
143 #[serde(deserialize_with = "null_default")]
144 isbn_13: Vec<String>,
145 #[serde(deserialize_with = "null_default")]
146 isbn_10: Vec<String>,
147}
148
149#[derive(Debug, Default, Deserialize)]
151#[serde(default)]
152struct SearchDoc {
153 key: Option<String>,
154 title: Option<String>,
155 subtitle: Option<String>,
156 #[serde(deserialize_with = "null_default")]
157 author_name: Vec<String>,
158 first_publish_year: Option<i16>,
159 #[serde(deserialize_with = "null_default")]
161 publish_year: Vec<i16>,
162 #[serde(deserialize_with = "null_default")]
163 isbn: Vec<String>,
164 #[serde(deserialize_with = "null_default")]
165 publisher: Vec<String>,
166}
167
168#[derive(Debug, Default, Deserialize)]
169#[serde(default, rename_all = "camelCase")]
170struct SearchResults {
171 num_found: Option<u64>,
172 #[serde(deserialize_with = "null_default")]
173 docs: Vec<serde_json::Value>,
174}
175
176fn full_title(title: Option<String>, subtitle: Option<String>) -> Option<String> {
179 match (title, subtitle.filter(|s| !s.trim().is_empty())) {
180 (Some(t), Some(s)) => Some(format!("{t}: {s}")),
181 (t, _) => t,
182 }
183}
184
185fn isbns<'a>(raw: impl IntoIterator<Item = &'a String>) -> Vec<WorkId> {
186 let mut ids = Vec::new();
187 for isbn in raw.into_iter().filter_map(|s| s.parse::<Isbn>().ok()) {
188 let id = WorkId::Isbn(isbn);
189 if !ids.contains(&id) {
190 ids.push(id);
191 }
192 }
193 ids
194}
195
196fn book(
197 title: Option<String>,
198 authors: Vec<String>,
199 year: Option<i16>,
200 publisher: Option<String>,
201 url: Option<String>,
202) -> Work {
203 let mut work = Work::default();
204 work.title = title;
205 work.authors = authors.into_iter().map(Person::literal).collect();
206 work.issued = year.map(PartialDate::from_year);
207 work.publisher = publisher;
208 work.work_type = Some(WorkType::Book);
209 work.url = url;
210 work
211}
212
213fn year_in(date: &str) -> Option<i16> {
215 date.as_bytes()
216 .windows(4)
217 .find(|w| w.iter().all(u8::is_ascii_digit))
218 .and_then(|w| std::str::from_utf8(w).ok()?.parse().ok())
219}
220
221fn or_skip(record: serde_json::Result<SourceRecord>) -> Option<SourceRecord> {
224 record
225 .inspect_err(|e| {
226 tracing::warn!(
227 target: "aspero::decode",
228 provider = %ID, error = %e, "skipped a record that doesn't decode"
229 );
230 })
231 .ok()
232}
233
234fn edition_record(
235 requested: &Isbn,
236 value: &serde_json::Value,
237 at: Timestamp,
238) -> serde_json::Result<SourceRecord> {
239 let e = Edition::deserialize(value)?;
240 let mut ids = vec![WorkId::Isbn(requested.clone())];
241 for id in isbns(e.identifiers.isbn_13.iter().chain(&e.identifiers.isbn_10)) {
242 if !ids.contains(&id) {
243 ids.push(id);
244 }
245 }
246 let work = book(
247 full_title(e.title, e.subtitle),
248 e.authors.into_iter().filter_map(|a| a.name).collect(),
249 e.publish_date.as_deref().and_then(year_in),
250 e.publishers.into_iter().find_map(|p| p.name),
251 e.url,
252 );
253 Ok(SourceRecord::new(
254 ID,
255 ids,
256 work,
257 FieldSet::ALL,
258 Raw::json(value),
259 at,
260 ))
261}
262
263fn search_record(
264 base: &str,
265 value: &serde_json::Value,
266 at: Timestamp,
267) -> serde_json::Result<SourceRecord> {
268 let d = SearchDoc::deserialize(value)?;
269 let mut ids = isbns(&d.isbn);
270 ids.truncate(MAX_WORK_ISBNS);
271 let url = d.key.as_ref().map(|k| format!("{base}{k}"));
272 let mut work = book(
273 full_title(d.title, d.subtitle),
274 d.author_name,
275 d.first_publish_year,
276 d.publisher.into_iter().next(),
277 url,
278 );
279 let mut editions: Vec<i16> = d
281 .publish_year
282 .into_iter()
283 .filter(|y| Some(*y) != d.first_publish_year)
284 .collect();
285 editions.sort_unstable();
286 editions.dedup();
287 work.other_years = editions;
288 Ok(SourceRecord::new(
289 ID,
290 ids,
291 work,
292 FieldSet::ALL,
293 Raw::json(value),
294 at,
295 ))
296}
297
298#[async_trait]
299impl Provider for OpenLibrary {
300 fn id(&self) -> ProviderId {
301 ID
302 }
303
304 fn capabilities(&self) -> &Capabilities {
305 &self.capabilities
306 }
307
308 async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
309 let isbns: Vec<&Isbn> = ids
310 .iter()
311 .map(|id| match id {
312 WorkId::Isbn(isbn) => Ok(isbn),
313 _ => Err(Error::Unsupported(
314 "Open Library lookup of non-ISBN identifiers",
315 )),
316 })
317 .collect::<Result<_>>()?;
318 let mut lookups = Vec::with_capacity(ids.len());
319 for (chunk_ids, chunk) in ids.chunks(MAX_BATCH).zip(isbns.chunks(MAX_BATCH)) {
320 let keys: Vec<String> = chunk.iter().map(|i| format!("ISBN:{i}")).collect();
321 let params = [
322 ("bibkeys", keys.join(",")),
323 ("format", "json".to_owned()),
324 ("jscmd", "data".to_owned()),
325 ];
326 let resp = self.get("/api/books", ¶ms).await?;
327 let body: serde_json::Map<String, serde_json::Value> =
328 serde_json::from_slice(&resp.body).map_err(Error::decode)?;
329 for ((id, isbn), key) in chunk_ids.iter().zip(chunk).zip(&keys) {
330 let record = body
331 .get(key)
332 .and_then(|v| or_skip(edition_record(isbn, v, resp.received_at)));
333 lookups.push(Lookup {
334 id: id.clone(),
335 record,
336 });
337 }
338 }
339 Ok(Response::new(lookups))
340 }
341
342 async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
343 if query.mode != SearchMode::Keywords {
344 return Err(Error::Unsupported("Open Library has no citation matcher"));
345 }
346 if let Some((key, _)) = query.extra.iter().next() {
347 return Err(Error::BadRequest(format!(
348 "unknown Open Library parameter {key:?}"
349 )));
350 }
351 let mut clauses = Vec::new();
352 if !query.text.trim().is_empty() {
353 clauses.push(format!("({})", escape_lucene(query.text.trim())));
354 }
355 if let Some(years) = query.years {
356 let end = |y: Option<i16>| y.map_or("*".to_owned(), |y| y.to_string());
357 clauses.push(format!(
358 "first_publish_year:[{} TO {}]",
359 end(years.from),
360 end(years.to)
361 ));
362 }
363 let page_number: u32 = match &query.page.cursor {
364 None => 1,
365 Some(c) => c
366 .0
367 .parse()
368 .map_err(|_| Error::BadRequest(format!("invalid Open Library cursor {:?}", c.0)))?,
369 };
370 let limit = query.page.limit.clamp(1, MAX_PAGE);
371 let params = [
372 ("q", clauses.join(" AND ")),
373 ("fields", SEARCH_FIELDS.to_owned()),
374 ("limit", limit.to_string()),
375 ("page", page_number.to_string()),
376 ];
377 let resp = self.get("/search.json", ¶ms).await?;
378 let results: SearchResults = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
379 let base = self.config.base_url.trim_end_matches('/');
380 let hits: Vec<Hit> = results
381 .docs
382 .iter()
383 .filter_map(|v| or_skip(search_record(base, v, resp.received_at)))
384 .map(|r| Hit::new(r, None))
385 .collect();
386 let seen = u64::from(page_number) * u64::from(limit);
387 let more = !hits.is_empty() && results.num_found.is_some_and(|n| n > seen);
388 let next = page_number
390 .checked_add(1)
391 .filter(|_| more)
392 .map(|n| Cursor(n.to_string()));
393 Ok(Response::new(Page::new(hits, results.num_found, next)))
394 }
395}