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.openalex.org";
#[derive(Clone, Debug)]
pub struct OpenalexSource {
base: Url,
contact_email: String,
}
impl OpenalexSource {
#[must_use]
pub fn new(contact_email: String) -> Self {
Self {
#[allow(clippy::expect_used)]
base: Url::parse(DEFAULT_BASE).expect("hard-coded base URL is valid"),
contact_email,
}
}
pub fn with_base(base: Url, contact_email: String) -> Self {
Self {
base,
contact_email,
}
}
fn request_url(&self, doi: &crate::Doi) -> Result<Url, FetchError> {
let path = format!("/works/doi:{}", doi.as_str());
let mut url = self
.base
.join(&path)
.map_err(|e| FetchError::SourceSchema {
hint: format!("openalex URL construction failed: {e}"),
})?;
if !self.contact_email.is_empty() {
url.query_pairs_mut()
.append_pair("mailto", &self.contact_email);
}
Ok(url)
}
}
#[async_trait]
impl Source for OpenalexSource {
fn name(&self) -> &str {
"openalex"
}
fn can_serve(&self, profile: &CapabilityProfile, ref_: &Ref) -> bool {
profile.metadata.openalex && 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: "openalex".into(),
});
}
};
if !profile.metadata.openalex {
return Err(FetchError::NotEligible {
source_key: "openalex".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 work: serde_json::Value =
serde_json::from_slice(&body).map_err(|e| FetchError::SourceSchema {
hint: format!("openalex returned non-JSON: {e}"),
})?;
if work.get("id").is_none() {
return Err(FetchError::SourceSchema {
hint: format!(
"openalex response missing `id` field — likely an error \
payload (got: {})",
truncate_for_hint(&body)
),
});
}
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: None,
store_path: None,
canonical_digest: Some(&canonical),
})?;
Ok(FetchResult {
source: self.name().to_string(),
license: "unknown".into(),
pdf_bytes: None,
final_url: Some(final_url),
metadata_json: Some(work),
})
}
}
#[must_use]
pub fn open_access_pdf_url(record: &serde_json::Value) -> Option<&str> {
record
.get("locations")
.and_then(serde_json::Value::as_array)?
.iter()
.find_map(|loc| {
if loc.get("is_oa").and_then(serde_json::Value::as_bool) != Some(true) {
return None;
}
loc.get("pdf_url")
.and_then(serde_json::Value::as_str)
.filter(|u| !u.is_empty())
})
}
#[must_use]
pub(crate) fn describe_locations(record: &serde_json::Value) -> Option<(usize, String)> {
let locations = record
.get("locations")
.and_then(serde_json::Value::as_array)?;
if locations.is_empty() {
return None;
}
let mut oa = 0usize;
let mut with_pdf = 0usize;
let mut named: Vec<&str> = Vec::new();
for loc in locations {
if loc.get("is_oa").and_then(serde_json::Value::as_bool) == Some(true) {
oa += 1;
}
if loc
.get("pdf_url")
.and_then(serde_json::Value::as_str)
.is_some_and(|u| !u.is_empty())
{
with_pdf += 1;
}
if let Some(host) = loc
.get("source")
.and_then(|s| s.get("display_name"))
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
{
if !named.contains(&host) {
named.push(host);
}
}
}
let hosts = if named.is_empty() {
String::new()
} else {
format!(" ({})", named.join("; "))
};
Some((
oa,
format!(
"openalex named {} location(s){hosts}: {oa} flagged open access, {with_pdf} with a PDF URL. A location without a PDF URL may still be a real deposit whose landing page is not an item page",
locations.len()
),
))
}
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 {
#[test]
fn a_named_but_unusable_location_is_described_rather_than_dropped() {
let record = serde_json::json!({
"locations": [
{
"is_oa": false,
"pdf_url": serde_json::Value::Null,
"landing_page_url": "https://doi.org/10.1109/tsp.2023.3269664",
"source": { "display_name": "IEEE Transactions on Signal Processing" }
},
{
"is_oa": false,
"pdf_url": serde_json::Value::Null,
"landing_page_url": "https://strathprints.strath.ac.uk/view/author/70486.html",
"source": { "display_name": "Strathprints: The University of Strathclyde" }
}
]
});
assert!(
open_access_pdf_url(&record).is_none(),
"premise: the extractor still finds nothing followable"
);
let (oa, d) = describe_locations(&record).expect("locations were named");
assert_eq!(
oa, 0,
"the caller reclassifies to NotOpenAccess only when this is 0, so it is part of the contract, not a detail"
);
assert!(d.contains("2 location"), "says how many: {d}");
assert!(
d.contains("Strathprints"),
"NAMES the repository, which is what the reader can act on: {d}"
);
assert!(
d.contains("0 with a PDF URL"),
"and why none was followed: {d}"
);
}
#[test]
fn a_usable_location_still_resolves_and_needs_no_description() {
let record = serde_json::json!({
"locations": [{
"is_oa": true,
"pdf_url": "https://strathprints.strath.ac.uk/91130/7/Khattak-etal.pdf",
"source": { "display_name": "Strathprints: The University of Strathclyde" }
}]
});
assert_eq!(
open_access_pdf_url(&record),
Some("https://strathprints.strath.ac.uk/91130/7/Khattak-etal.pdf")
);
}
#[test]
fn an_open_location_without_a_pdf_url_is_counted_as_open() {
let record = serde_json::json!({
"locations": [{
"is_oa": true,
"pdf_url": serde_json::Value::Null,
"landing_page_url": "https://repo.example/view/author/1.html",
"source": { "display_name": "Some Repository" }
}]
});
assert!(
open_access_pdf_url(&record).is_none(),
"premise: still nothing followable"
);
let (oa, d) = describe_locations(&record).expect("a location was named");
assert_eq!(
oa, 1,
"OpenAlex said this is open; labelling it `NotOpenAccess` would assert the opposite on the machine-readable token: {d}"
);
}
#[test]
fn no_locations_means_no_description() {
assert!(describe_locations(&serde_json::json!({})).is_none());
assert!(describe_locations(&serde_json::json!({"locations": []})).is_none());
}
use super::*;
use std::sync::Arc;
use camino::Utf8PathBuf;
use tempfile::TempDir;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::http::HttpClient;
use crate::provenance::ProvenanceLog;
use crate::rate_limiter::RateLimiter;
use crate::{ArxivId, CapabilityProfile, Doi, MetadataAccess, RateLimits, Ref};
const SAMPLE_WORK: &str = r#"{
"id": "https://openalex.org/W2741809807",
"doi": "https://doi.org/10.1234/example",
"display_name": "Example Work Title",
"publication_year": 2024,
"referenced_works": [
"https://openalex.org/W2000000001",
"https://openalex.org/W2000000002"
]
}"#;
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 log_path = log_dir.join("test.jsonl");
let http = Arc::new(HttpClient::new_for_tests_allow_http(
"openalex",
wiremock_host,
));
let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
let session_id = "01J0000000000000000000TEST".to_string();
let log = Arc::new(
ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
);
let ctx = FetchContext {
http,
rate_limiter,
log,
session_id,
cache_root: None,
};
(td, ctx)
}
fn profile_with_openalex_enabled() -> CapabilityProfile {
let mut p = CapabilityProfile::for_tests();
p.metadata = MetadataAccess {
openalex: true,
semantic_scholar: false,
doaj: false,
datacite: false,
hal: false,
openaire: false,
core: false,
europe_pmc: false,
};
p
}
#[tokio::test]
async fn fetch_doi_returns_work_metadata() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/works/doi:10.1234/example"))
.and(query_param("mailto", "doiget@localhost"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_WORK))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.uri());
let src = OpenalexSource::with_base(
Url::parse(&server.uri()).expect("wiremock URI parses"),
"doiget@localhost".to_string(),
);
let profile = profile_with_openalex_enabled();
let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("DOI parses"));
let result = src.fetch(&ref_, &profile, &ctx).await.expect("fetch ok");
assert_eq!(result.source, "openalex");
assert!(result.pdf_bytes.is_none(), "metadata-only contract");
let meta = result.metadata_json.expect("metadata_json present");
assert_eq!(meta["display_name"], "Example Work Title");
assert_eq!(
meta["referenced_works"][0],
"https://openalex.org/W2000000001"
);
}
#[tokio::test]
async fn fetch_arxiv_id_is_not_eligible() {
let (_td, ctx) = build_test_context("http://127.0.0.1:1");
let src = OpenalexSource::with_base(
Url::parse("http://127.0.0.1:1").expect("URI parses"),
"doiget@localhost".to_string(),
);
let profile = profile_with_openalex_enabled();
let ref_ = Ref::Arxiv(ArxivId::parse("2401.12345").expect("arXiv id parses"));
let err = src
.fetch(&ref_, &profile, &ctx)
.await
.expect_err("arXiv ref must be rejected");
assert!(matches!(err, FetchError::NotEligible { .. }));
}
#[tokio::test]
async fn fetch_without_capability_flag_is_not_eligible() {
let (_td, ctx) = build_test_context("http://127.0.0.1:1");
let src = OpenalexSource::with_base(
Url::parse("http://127.0.0.1:1").expect("URI parses"),
"doiget@localhost".to_string(),
);
let profile = CapabilityProfile::for_tests();
let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("DOI parses"));
assert!(
!src.can_serve(&profile, &ref_),
"can_serve must be false without DOIGET_ENABLE_OPENALEX"
);
let err = src
.fetch(&ref_, &profile, &ctx)
.await
.expect_err("fetch must reject when capability is denied");
assert!(matches!(err, FetchError::NotEligible { .. }));
}
#[tokio::test]
async fn fetch_malformed_response_returns_source_schema_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/works/doi:10.1234/example"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"error":"not found"}"#))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.uri());
let src = OpenalexSource::with_base(
Url::parse(&server.uri()).expect("wiremock URI parses"),
"doiget@localhost".to_string(),
);
let profile = profile_with_openalex_enabled();
let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("DOI parses"));
let err = src
.fetch(&ref_, &profile, &ctx)
.await
.expect_err("missing `id` must surface as SourceSchema");
assert!(matches!(err, FetchError::SourceSchema { .. }));
}
#[test]
fn open_access_pdf_url_finds_a_repository_copy_past_the_primary() {
let work = serde_json::json!({
"locations": [
{ "is_oa": false, "landing_page_url": "https://ieeexplore.example/doc/1" },
{ "is_oa": true, "pdf_url": "https://repo.example.ac.uk/1/paper.pdf" }
]
});
assert_eq!(
open_access_pdf_url(&work),
Some("https://repo.example.ac.uk/1/paper.pdf")
);
}
#[test]
fn open_access_pdf_url_skips_a_landing_page_only_location() {
let work = serde_json::json!({
"locations": [
{ "is_oa": true, "landing_page_url": "https://repo.example.ac.uk/1" },
{ "is_oa": true, "pdf_url": "https://other.example.ac.uk/2/paper.pdf" }
]
});
assert_eq!(
open_access_pdf_url(&work),
Some("https://other.example.ac.uk/2/paper.pdf")
);
}
#[test]
fn open_access_pdf_url_ignores_a_non_oa_location() {
let work = serde_json::json!({
"locations": [
{ "is_oa": false, "pdf_url": "https://paywall.example/1.pdf" }
]
});
assert_eq!(open_access_pdf_url(&work), None);
}
#[test]
fn open_access_pdf_url_rejects_empty_and_absent() {
let empty = serde_json::json!({
"locations": [{ "is_oa": true, "pdf_url": "" }]
});
assert_eq!(open_access_pdf_url(&empty), None);
assert_eq!(open_access_pdf_url(&serde_json::json!({})), None);
}
}