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.archives-ouvertes.fr";
const FIELDS: &str = "docid,title_s,authFullName_s,producedDateY_i,doiId_s,uri_s,\
openAccess_bool,fileMain_s,licence_s,docType_s";
#[derive(Clone, Debug)]
pub struct HalSource {
base: Url,
}
impl HalSource {
#[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
.join("/search/")
.map_err(|e| FetchError::SourceSchema {
hint: format!("hal URL construction failed: {e}"),
})?;
url.query_pairs_mut()
.append_pair("q", &format!("doiId_s:\"{}\"", doi.as_str()))
.append_pair("fl", FIELDS)
.append_pair("rows", "1")
.append_pair("wt", "json");
Ok(url)
}
}
impl Default for HalSource {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Source for HalSource {
fn name(&self) -> &str {
"hal"
}
fn can_serve(&self, profile: &CapabilityProfile, ref_: &Ref) -> bool {
profile.metadata.hal && 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: "hal".into(),
});
}
};
if !profile.metadata.hal {
return Err(FetchError::NotEligible {
source_key: "hal".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!("hal returned non-JSON: {e}"),
})?;
let docs = envelope
.get("response")
.and_then(|r| r.get("docs"))
.and_then(|d| d.as_array())
.ok_or_else(|| FetchError::SourceSchema {
hint: format!(
"hal response missing `response.docs` (got: {})",
truncate_for_hint(&body)
),
})?;
let doc = docs.first().ok_or_else(|| FetchError::SourceSchema {
hint: "hal has no deposit for this DOI".to_string(),
})?;
if doc
.get("openAccess_bool")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
return Err(FetchError::SourceSchema {
hint: "hal deposit is not open access (openAccess_bool != true)".to_string(),
});
}
let license = license_of(doc);
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(doc.clone()),
})
}
}
fn license_of(doc: &serde_json::Value) -> String {
first_str(doc, "licence_s")
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string())
}
#[must_use]
pub fn first_str<'a>(doc: &'a serde_json::Value, field: &str) -> Option<&'a str> {
let v = doc.get(field)?;
v.as_str().or_else(|| {
v.as_array()
.and_then(|a| a.first())
.and_then(|f| f.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_HIT: &str = r#"{
"response": {
"numFound": 1,
"docs": [
{
"docid": "1204546",
"openAccess_bool": true,
"title_s": ["Minimally entangled typical thermal states"],
"authFullName_s": ["Moritz Binder", "Thomas Barthel"],
"uri_s": "https://hal.science/hal-01204546v1",
"doiId_s": "10.1103/PhysRevB.92.125119",
"producedDateY_i": 2015
}
]
}
}"#;
const SAMPLE_EMPTY: &str = r#"{"response": {"numFound": 0, "docs": []}}"#;
const SAMPLE_CLOSED: &str = r#"{
"response": {
"numFound": 1,
"docs": [
{
"docid": "999",
"openAccess_bool": false,
"title_s": ["An Embargoed Deposit"],
"doiId_s": "10.1234/closed"
}
]
}
}"#;
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("hal", 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(hal: bool) -> CapabilityProfile {
let mut p = CapabilityProfile::from_env().expect("clean env never errors");
p.metadata = MetadataAccess {
openalex: false,
semantic_scholar: false,
doaj: false,
datacite: false,
hal,
openaire: false,
core: false,
europe_pmc: false,
};
p
}
#[test]
fn request_url_quotes_the_doi_as_a_solr_phrase() {
let src = HalSource::new();
let doi = Doi::parse("10.1103/PhysRevB.92.125119").expect("valid doi");
let url = src.request_url(&doi).expect("url builds");
let q = url
.query_pairs()
.find(|(k, _)| k == "q")
.expect("q param")
.1
.into_owned();
assert_eq!(q, "doiId_s:\"10.1103/PhysRevB.92.125119\"");
assert_eq!(url.path(), "/search/");
}
#[tokio::test]
async fn fetch_returns_the_first_open_deposit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search/"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_HIT))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = HalSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevB.92.125119").expect("doi"));
let got = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect("fetch succeeds");
assert_eq!(got.source, "hal");
assert!(got.pdf_bytes.is_none(), "metadata-only contract");
assert_eq!(got.license, "unknown");
let doc = got.metadata_json.expect("doc");
assert_eq!(
first_str(&doc, "title_s"),
Some("Minimally entangled typical thermal states"),
"multi-valued Solr fields must be unwrapped"
);
}
#[tokio::test]
async fn closed_access_deposits_are_rejected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search/"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_CLOSED))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = HalSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1234/closed").expect("doi"));
let err = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect_err("closed deposits must not be returned");
assert!(
matches!(err, FetchError::SourceSchema { .. }),
"got {err:?}"
);
}
#[test]
fn missing_open_access_flag_is_not_treated_as_open() {
let doc = serde_json::json!({"docid": "1", "title_s": ["x"]});
assert_ne!(
doc.get("openAccess_bool")
.and_then(serde_json::Value::as_bool),
Some(true)
);
}
#[tokio::test]
async fn no_deposit_surfaces_as_source_schema() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search/"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_EMPTY))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = HalSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1234/absent").expect("doi"));
let err = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect_err("must not claim success");
assert!(
matches!(err, FetchError::SourceSchema { .. }),
"got {err:?}"
);
}
#[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 = HalSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevB.92.125119").expect("doi"));
assert!(!src.can_serve(&profile(false), &ref_));
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 = HalSource::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 { .. })
));
}
#[test]
fn first_str_handles_bare_and_array_fields() {
let doc = serde_json::json!({"a": "bare", "b": ["first", "second"], "c": []});
assert_eq!(first_str(&doc, "a"), Some("bare"));
assert_eq!(first_str(&doc, "b"), Some("first"));
assert_eq!(first_str(&doc, "c"), None);
assert_eq!(first_str(&doc, "missing"), None);
}
}