use async_trait::async_trait;
use url::Url;
use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
use crate::source::{FetchContext, FetchError, FetchResult, Source};
use crate::{CapabilityProfile, Ref};
const DEFAULT_BASE: &str = "https://api.datacite.org";
#[derive(Clone, Debug)]
pub struct DataCiteSource {
base: Url,
}
impl DataCiteSource {
#[must_use]
pub fn new() -> Self {
Self {
#[allow(clippy::expect_used)]
base: Url::parse(DEFAULT_BASE).expect("hard-coded base URL is valid"),
}
}
pub fn with_base(base: Url) -> Self {
Self { base }
}
fn request_url(&self, doi: &crate::Doi) -> Result<Url, FetchError> {
let mut url = self.base.clone();
{
let mut segs = url
.path_segments_mut()
.map_err(|()| FetchError::SourceSchema {
hint: "datacite base URL cannot be a base".to_string(),
})?;
segs.clear();
segs.push("dois");
for part in doi.as_str().split('/') {
segs.push(part);
}
}
Ok(url)
}
}
impl Default for DataCiteSource {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Source for DataCiteSource {
fn name(&self) -> &str {
"datacite"
}
fn can_serve(&self, profile: &CapabilityProfile, ref_: &Ref) -> bool {
profile.metadata.datacite && matches!(ref_, Ref::Doi(_))
}
async fn fetch(
&self,
ref_: &Ref,
profile: &CapabilityProfile,
ctx: &FetchContext,
) -> Result<FetchResult, FetchError> {
let doi = match ref_ {
Ref::Doi(d) => d,
Ref::Arxiv(_) => {
return Err(FetchError::NotEligible {
source_key: "datacite".into(),
});
}
};
if !profile.metadata.datacite {
return Err(FetchError::NotEligible {
source_key: "datacite".into(),
});
}
let _permit = ctx.rate_limiter.acquire(self.name()).await;
let url = self.request_url(doi)?;
let (body, final_url) = ctx.http.fetch_bytes(self.name(), url).await?;
let envelope: serde_json::Value =
serde_json::from_slice(&body).map_err(|e| FetchError::SourceSchema {
hint: format!("datacite returned non-JSON: {e}"),
})?;
let attributes = envelope
.get("data")
.and_then(|d| d.get("attributes"))
.ok_or_else(|| FetchError::SourceSchema {
hint: format!(
"datacite response missing `data.attributes` (got: {})",
truncate_for_hint(&body)
),
})?;
let license = license_of(attributes);
let canonical = ref_.promote(self.name(), None).digest_hex();
ctx.log.append(RowInput {
event: LogEvent::Fetch,
result: LogResult::Ok,
capability: Capability::Metadata,
ref_: Some(doi.as_str()),
source: Some(self.name()),
error_code: None,
size_bytes: Some(body.len() as u64),
license: Some(license.as_str()),
store_path: None,
canonical_digest: Some(&canonical),
})?;
Ok(FetchResult {
source: self.name().to_string(),
license,
pdf_bytes: None,
final_url: Some(final_url),
metadata_json: Some(attributes.clone()),
})
}
}
fn license_of(attributes: &serde_json::Value) -> String {
attributes
.get("rightsList")
.and_then(|r| r.as_array())
.and_then(|list| {
list.iter()
.find_map(|r| r.get("rightsIdentifier").and_then(|v| v.as_str()))
})
.unwrap_or("unknown")
.to_string()
}
#[must_use]
pub fn resource_type_general(attributes: &serde_json::Value) -> Option<&str> {
attributes
.get("types")
.and_then(|t| t.get("resourceTypeGeneral"))
.and_then(|v| v.as_str())
}
fn truncate_for_hint(body: &[u8]) -> String {
const MAX: usize = 200;
let s = String::from_utf8_lossy(body);
if s.len() <= MAX {
s.into_owned()
} else {
format!("{}…", &s[..MAX])
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use std::sync::Arc;
use camino::Utf8PathBuf;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::http::HttpClient;
use crate::provenance::ProvenanceLog;
use crate::rate_limiter::RateLimiter;
use crate::{CapabilityProfile, Doi, MetadataAccess, RateLimits, Ref};
const SAMPLE_RECORD: &str = r#"{
"data": {
"id": "10.5281/zenodo.22053902",
"type": "dois",
"attributes": {
"doi": "10.5281/zenodo.22053902",
"titles": [{"title": "An Example Deposit"}],
"creators": [
{"name": "Researcher, Alice"},
{"givenName": "Bob", "familyName": "Coauthor"}
],
"publicationYear": 2024,
"publisher": "Zenodo",
"types": {"resourceTypeGeneral": "JournalArticle"},
"rightsList": [
{"rights": "Creative Commons Attribution 4.0",
"rightsIdentifier": "cc-by-4.0"}
],
"url": "https://zenodo.org/doi/10.5281/zenodo.22053902"
}
}
}"#;
fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
let td = TempDir::new().expect("tempdir");
let log_dir =
Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
let http = Arc::new(HttpClient::new_for_tests_allow_http(
"datacite",
wiremock_host,
));
let session_id = "01J0000000000000000000TEST".to_string();
let log = Arc::new(
ProvenanceLog::open(log_dir.join("test.jsonl"), session_id.clone())
.expect("provenance log opens"),
);
let ctx = FetchContext {
http,
rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
log,
session_id,
cache_root: None,
};
(td, ctx)
}
fn profile(datacite: bool) -> CapabilityProfile {
let mut p = CapabilityProfile::from_env().expect("clean env never errors");
p.metadata = MetadataAccess {
openalex: false,
semantic_scholar: false,
doaj: false,
datacite,
hal: false,
openaire: false,
core: false,
europe_pmc: false,
};
p
}
#[test]
fn request_url_keeps_the_doi_slash_structural() {
let src = DataCiteSource::new();
let doi = Doi::parse("10.5281/zenodo.22053902").expect("valid doi");
let url = src.request_url(&doi).expect("url builds");
assert_eq!(
url.as_str(),
"https://api.datacite.org/dois/10.5281/zenodo.22053902"
);
}
#[test]
fn doi_newtype_rejects_characters_that_could_escape_the_path() {
for bad in ["10.5281/a?b", "10.5281/a#b", "10.5281/a b"] {
assert!(
Doi::parse(bad).is_err(),
"{bad} must not parse as a DOI, or request_url could see it"
);
}
}
#[test]
fn request_url_handles_a_multi_segment_suffix() {
let src = DataCiteSource::new();
let doi = Doi::parse("10.17605/osf.io/ab3cd").expect("valid doi");
let url = src.request_url(&doi).expect("url builds");
assert_eq!(
url.as_str(),
"https://api.datacite.org/dois/10.17605/osf.io/ab3cd"
);
assert!(url.query().is_none());
}
#[tokio::test]
async fn fetch_returns_attributes_and_license() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dois/10.5281/zenodo.22053902"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_RECORD))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = DataCiteSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.5281/zenodo.22053902").expect("doi"));
let got = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect("fetch succeeds");
assert_eq!(got.source, "datacite");
assert_eq!(got.license, "cc-by-4.0");
assert!(got.pdf_bytes.is_none(), "metadata-only contract");
let attrs = got.metadata_json.expect("attributes");
assert_eq!(resource_type_general(&attrs), Some("JournalArticle"));
}
#[tokio::test]
async fn is_inert_when_the_runtime_flag_is_unset() {
let server = MockServer::start().await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = DataCiteSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.5281/zenodo.22053902").expect("doi"));
assert!(
!src.can_serve(&profile(false), &ref_),
"can_serve must be false with DOIGET_ENABLE_DATACITE unset"
);
let err = src
.fetch(&ref_, &profile(false), &ctx)
.await
.expect_err("must refuse");
assert!(matches!(err, FetchError::NotEligible { .. }), "got {err:?}");
assert!(
server
.received_requests()
.await
.expect("recorded")
.is_empty(),
"an inert source must make NO request"
);
}
#[tokio::test]
async fn arxiv_refs_are_not_eligible() {
let server = MockServer::start().await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = DataCiteSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Arxiv(crate::ArxivId::parse("2401.12345").expect("arxiv id"));
assert!(!src.can_serve(&profile(true), &ref_));
assert!(matches!(
src.fetch(&ref_, &profile(true), &ctx).await,
Err(FetchError::NotEligible { .. })
));
}
#[tokio::test]
async fn missing_record_surfaces_as_source_schema() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dois/10.1234/not-datacite"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"errors":[{"status":"404"}]}"#),
)
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = DataCiteSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1234/not-datacite").expect("doi"));
let err = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect_err("must not claim success");
assert!(
matches!(err, FetchError::SourceSchema { .. }),
"got {err:?}"
);
}
#[test]
fn license_falls_back_to_unknown_rather_than_guessing() {
let attrs = serde_json::json!({"rightsList": [{"rights": "Open Access"}]});
assert_eq!(license_of(&attrs), "unknown");
assert_eq!(license_of(&serde_json::json!({})), "unknown");
}
}