mod http;
mod wire;
use std::sync::Arc;
use std::time::Duration;
use aspero_core::http::path_segment;
use aspero_core::{
Capabilities, Cursor, Doi, Error, FieldSet, Filter, Hit, HttpClient, HttpRequest, HttpResponse,
IdKind, Lookup, Page, Provider, ProviderId, ResiliencePolicy, Response, Result, SearchMode,
SearchQuery, SourceRecord, WorkId,
};
use async_trait::async_trait;
use crate::wire::{Envelope, WireList};
pub const ID: ProviderId = ProviderId::from_static("crossref");
pub const DEFAULT_BASE_URL: &str = "https://api.crossref.org";
const MAX_BATCH: usize = 50;
const MAX_ROWS: u32 = 1000;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CrossrefConfig {
pub mailto: Option<String>,
pub base_url: String,
}
impl Default for CrossrefConfig {
fn default() -> Self {
CrossrefConfig {
mailto: None,
base_url: DEFAULT_BASE_URL.to_owned(),
}
}
}
pub struct Crossref {
config: CrossrefConfig,
http: Arc<dyn HttpClient>,
capabilities: Capabilities,
}
impl Crossref {
pub fn new(config: CrossrefConfig, http: Arc<dyn HttpClient>) -> Self {
let mut capabilities = Capabilities::default();
capabilities.lookup_kinds = vec![IdKind::Doi];
capabilities.max_batch = MAX_BATCH;
capabilities.fields = FieldSet::ALL;
capabilities.search = true;
capabilities.citation_search = true;
capabilities.search_filters = vec![Filter::Years, Filter::WorkType];
Crossref {
config,
http,
capabilities,
}
}
pub fn default_policy() -> ResiliencePolicy {
let mut policy = ResiliencePolicy::default();
policy.min_interval = Duration::from_millis(350);
policy
}
fn url(&self, rest: &str, params: &[(&str, String)]) -> Result<String> {
let base = format!("{}/works{rest}", self.config.base_url.trim_end_matches('/'));
let mut url = url::Url::parse(&base)
.map_err(|e| Error::BadRequest(format!("invalid Crossref base URL: {e}")))?;
{
let mut query = url.query_pairs_mut();
for (k, v) in params {
query.append_pair(k, v);
}
if let Some(mailto) = &self.config.mailto {
query.append_pair("mailto", mailto);
}
}
if url.query() == Some("") {
url.set_query(None);
}
Ok(url.into())
}
async fn get(&self, url: String) -> Result<HttpResponse> {
let req = HttpRequest::get(url).header("accept", "application/json");
let resp = self.http.send(req).await?;
http::check(&resp)?;
Ok(resp)
}
async fn lookup_one(&self, doi: &Doi) -> Result<Option<SourceRecord>> {
let url = self.url(&format!("/{}", path_segment(doi.as_str())), &[])?;
let resp = match self.get(url).await {
Ok(resp) => resp,
Err(Error::NotFound) => return Ok(None),
Err(e) => return Err(e),
};
let envelope: Envelope<serde_json::Value> =
serde_json::from_slice(&resp.body).map_err(Error::decode)?;
wire::to_record(&envelope.message, resp.received_at)
.map(Some)
.map_err(Error::decode)
}
async fn list(&self, params: &[(&str, String)]) -> Result<(Vec<Hit>, WireList)> {
let resp = self.get(self.url("", params)?).await?;
let envelope: Envelope<WireList> =
serde_json::from_slice(&resp.body).map_err(Error::decode)?;
let list = envelope.message;
let hits = list
.items
.iter()
.map(|v| {
let score = v.get("score").and_then(serde_json::Value::as_f64);
wire::to_record(v, resp.received_at).map(|r| Hit::new(r, score))
})
.collect::<serde_json::Result<_>>()
.map_err(Error::decode)?;
Ok((hits, list))
}
}
#[async_trait]
impl Provider for Crossref {
fn id(&self) -> ProviderId {
ID
}
fn capabilities(&self) -> &Capabilities {
&self.capabilities
}
async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
let dois = ids
.iter()
.map(|id| match id {
WorkId::Doi(doi) => Ok(doi),
_ => Err(Error::Unsupported("Crossref lookup of non-DOI identifiers")),
})
.collect::<Result<Vec<_>>>()?;
if let [doi] = dois.as_slice() {
let record = self.lookup_one(doi).await?;
return Ok(Response::new(vec![Lookup {
id: ids[0].clone(),
record,
}]));
}
let (batchable, singles): (Vec<&Doi>, Vec<&Doi>) =
dois.iter().partition(|d| !d.as_str().contains(','));
let mut found = Vec::new();
for chunk in batchable.chunks(MAX_BATCH) {
let filter = chunk
.iter()
.map(|d| format!("doi:{}", d.as_str()))
.collect::<Vec<_>>()
.join(",");
let params = [("filter", filter), ("rows", chunk.len().to_string())];
let (hits, _) = self.list(¶ms).await?;
found.extend(hits.into_iter().map(|h| h.record));
}
for doi in singles {
found.extend(self.lookup_one(doi).await?);
}
let lookups = ids
.iter()
.map(|id| Lookup {
id: id.clone(),
record: found.iter().find(|r| r.ids.contains(id)).cloned(),
})
.collect();
Ok(Response::new(lookups))
}
async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
let mut filters = Vec::new();
if let Some(years) = query.years {
if let Some(from) = years.from {
filters.push(format!("from-pub-date:{from:04}"));
}
if let Some(to) = years.to {
filters.push(format!("until-pub-date:{to:04}"));
}
}
if let Some(t) = &query.work_type {
let crossref = wire::crossref_type(t).ok_or_else(|| {
Error::BadRequest(format!(
"Crossref has no work type matching {:?}",
t.as_csl()
))
})?;
filters.push(format!("type:{crossref}"));
}
let mut params: Vec<(&str, String)> = Vec::new();
for (key, value) in &query.extra {
match key.as_str() {
"filter" => filters.push(value.clone()),
"sort" | "order" => params.push((key.as_str(), value.clone())),
k if k.starts_with("query.") => params.push((k, value.clone())),
other => {
return Err(Error::BadRequest(format!(
"unknown Crossref parameter {other:?} (supported: filter, sort, order, query.*)"
)));
}
}
}
let text = query.text.trim();
if !text.is_empty() {
let field = match query.mode {
SearchMode::Citation => "query.bibliographic",
_ => "query",
};
params.push((field, text.to_owned()));
}
if !filters.is_empty() {
params.push(("filter", filters.join(",")));
}
let has_query = params.iter().any(|(k, _)| k.starts_with("query"));
if has_query && !params.iter().any(|(k, _)| *k == "sort") {
params.push(("sort", "score".into()));
params.push(("order", "desc".into()));
}
params.push(("rows", query.page.limit.clamp(1, MAX_ROWS).to_string()));
let cursor = query.page.cursor.as_ref().map_or("*", |c| c.0.as_str());
params.push(("cursor", cursor.to_owned()));
let (hits, list) = self.list(¶ms).await?;
let next = list.next_cursor.filter(|_| !hits.is_empty()).map(Cursor);
Ok(Response::new(Page::new(hits, list.total_results, next)))
}
}