use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Path, Query, RawQuery, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use clap::{Args, Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tokio::signal::unix::{SignalKind, signal};
use tower_http::compression::CompressionLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use futures::StreamExt;
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_sdk::logs::SdkLoggerProvider as OtelLoggerProvider;
use rustls_acme::AcmeConfig;
use rustls_acme::caches::DirCache;
mod db;
mod error;
mod negotiate;
use error::AppError;
use negotiate::NegotiateResult;
#[derive(Parser)]
#[command(
name = "dragoman",
about = "PID redirection and content negotiation server"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Start(StartArgs),
Stop(StopArgs),
#[command(disable_help_flag = true)]
Import(PassthroughArgs),
#[command(disable_help_flag = true)]
Convert(PassthroughArgs),
#[command(disable_help_flag = true)]
List(PassthroughArgs),
#[command(disable_help_flag = true)]
Stats(PassthroughArgs),
#[command(disable_help_flag = true)]
Settings(PassthroughArgs),
#[command(external_subcommand)]
External(Vec<String>),
}
#[derive(Args)]
struct PassthroughArgs {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
#[derive(Args)]
struct StartArgs {
#[arg(short, long, env = "PORT", default_value_t = 3456)]
port: u16,
#[arg(short, long, env = "COMMONMETA_DB")]
db: Option<PathBuf>,
#[arg(long, env = "DRAGOMAN_CACHE_DB")]
cache_db: Option<PathBuf>,
#[arg(long, env = "DRAGOMAN_PID_FILE")]
pid_file: Option<PathBuf>,
#[arg(long, env = "DRAGOMAN_DOMAIN")]
domain: Option<String>,
#[arg(long, env = "DRAGOMAN_EMAIL")]
email: Option<String>,
#[arg(long, env = "DRAGOMAN_ACME_CACHE")]
acme_cache: Option<PathBuf>,
#[arg(long, env = "DRAGOMAN_ACME_STAGING")]
acme_staging: bool,
#[arg(long, env = "DRAGOMAN_TLS_PORT")]
tls_port: Option<u16>,
#[arg(long, env = "DRAGOMAN_VIKTORIALOGS_URL")]
viktorialogs_url: Option<String>,
#[arg(long, env = "DRAGOMAN_METRICS_ALLOW")]
metrics_allow: Option<String>,
}
#[derive(Args)]
struct StopArgs {
#[arg(long, env = "DRAGOMAN_PID_FILE", default_value = "/tmp/dragoman.pid")]
pid_file: PathBuf,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Command::Start(args) => cmd_start(args).await,
Command::Stop(args) => {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "dragoman=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
cmd_stop(args);
}
Command::Import(args) => cmd_passthrough(vec!["import".to_string()], args.args),
Command::Convert(args) => cmd_passthrough(vec!["convert".to_string()], args.args),
Command::List(args) => cmd_passthrough(vec!["list".to_string()], args.args),
Command::Stats(args) => cmd_passthrough(vec!["stats".to_string()], args.args),
Command::Settings(args) => cmd_passthrough(vec!["settings".to_string()], args.args),
Command::External(args) => cmd_passthrough(args, Vec::new()),
}
}
fn cmd_passthrough(head: Vec<String>, tail: Vec<String>) {
let mut full_args = vec!["commonmeta".to_string()];
full_args.extend(head);
full_args.extend(tail);
if let Err(e) = commonmeta::run_cli(full_args) {
eprintln!("{e}");
std::process::exit(1);
}
}
fn platform_default_db_path() -> PathBuf {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(format!(
"{}/Library/Application Support/commonmeta/commonmeta.sqlite3",
home
))
}
#[cfg(target_os = "linux")]
{
PathBuf::from("/var/lib/commonmeta/commonmeta.sqlite3")
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
PathBuf::from("commonmeta.sqlite3")
}
}
fn platform_default_cache_path() -> PathBuf {
platform_default_db_path().with_file_name("cache.sqlite3")
}
fn platform_default_acme_cache() -> PathBuf {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(format!(
"{}/Library/Application Support/dragoman/acme",
home
))
}
#[cfg(target_os = "linux")]
{
PathBuf::from("/var/lib/dragoman/acme")
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
PathBuf::from("dragoman/acme")
}
}
fn resolve_local_db(
opened: Result<Option<PathBuf>, AppError>,
path: &std::path::Path,
) -> Option<Arc<PathBuf>> {
match opened {
Ok(Some(p)) => {
tracing::info!(path = %p.display(), "using local sqlite database");
Some(Arc::new(p))
}
Ok(None) => None,
Err(e) => {
tracing::error!(path = %path.display(), error = %e,
"failed to open local database, serving from cache and live API only");
None
}
}
}
fn init_viktorialogs(endpoint: &str) -> OtelLoggerProvider {
use opentelemetry_otlp::WithExportConfig;
let exporter = opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_endpoint(endpoint)
.build()
.expect("failed to create VictoriaLogs OTLP log exporter");
OtelLoggerProvider::builder()
.with_batch_exporter(exporter)
.build()
}
fn client_ip(headers: &HeaderMap) -> Option<String> {
headers
.get("x-real-ip")
.or_else(|| headers.get("x-forwarded-for"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
}
fn normalize_route(path: &str) -> &'static str {
if path == "/" {
return "/";
}
if path.starts_with("/api/handles/") {
return "/api/handles/:id";
}
if path == "/api/metrics" {
return "/api/metrics";
}
if path.starts_with("/dois/") {
return "/dois/:doi";
}
if path.starts_with("/works/") {
return "/works/:doi";
}
if path.starts_with("/orcid/") {
return "/orcid/:id";
}
if path.starts_with("/ror/") {
return "/ror/:id";
}
if path == "/api/status" {
return "/api/status";
}
if path == "/api/count" {
return "/api/count";
}
if path == "/api/random" {
return "/api/random";
}
if path == "/api/search" {
return "/api/search";
}
if path == "/api/container/works" {
return "/api/container/works";
}
if path == "/api/bibliography" {
return "/api/bibliography";
}
if path == "/og-image.png" {
return "/og-image.png";
}
if path == "/schema.json" {
return "/schema.json";
}
if path == "/commonmeta_v1.0.json" {
return "/commonmeta_v1.0.json";
}
if path == "/health" {
return "/health";
}
"/{pid}"
}
async fn metrics_middleware(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let method = req.method().to_string();
let route = normalize_route(req.uri().path());
let start = std::time::Instant::now();
let response = next.run(req).await;
let duration = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
metrics::counter!("http_requests_total", "method" => method.clone(), "route" => route, "status" => status).increment(1);
metrics::histogram!("http_request_duration_seconds", "method" => method, "route" => route)
.record(duration);
response
}
async fn cmd_start(args: StartArgs) {
let otel_provider = args.viktorialogs_url.as_deref().map(init_viktorialogs);
let otel_layer = otel_provider
.as_ref()
.map(|p| OpenTelemetryTracingBridge::new(p));
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "dragoman=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.with(otel_layer)
.init();
let db_path = args.db.unwrap_or_else(platform_default_db_path);
let database = {
let path = db_path.clone();
let opened = tokio::task::spawn_blocking(move || db::open(&path))
.await
.expect("spawn_blocking panicked");
resolve_local_db(opened, &db_path)
};
let cache_path = args.cache_db.unwrap_or_else(platform_default_cache_path);
let cache = {
let path = cache_path.clone();
match tokio::task::spawn_blocking(move || db::cache_open(&path))
.await
.expect("spawn_blocking panicked")
{
Ok(p) => {
tracing::info!(path = %p.display(), "using metadata cache database");
Some(Arc::new(p))
}
Err(e) => {
tracing::warn!(path = %cache_path.display(), error = %e, "cache database unavailable, caching disabled");
None
}
}
};
if let Some(ref path) = args.pid_file {
if let Err(e) = std::fs::write(path, std::process::id().to_string()) {
tracing::error!(path = %path.display(), error = %e, "failed to write PID file");
std::process::exit(1);
}
tracing::info!(path = %path.display(), "wrote PID file");
}
let app = build_app(AppState {
db: database,
cache,
})
.layer(axum::middleware::from_fn(metrics_middleware));
let app = if let Some(allow_str) = args.metrics_allow {
let handle = metrics_exporter_prometheus::PrometheusBuilder::new()
.install_recorder()
.expect("failed to install Prometheus metrics recorder");
let allow_list: Vec<String> = allow_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
tracing::info!(allow = ?allow_list, "Prometheus /api/metrics enabled");
app.route(
"/api/metrics",
axum::routing::get(move |headers: HeaderMap| {
let handle = handle.clone();
let allow = allow_list.clone();
async move {
let ip = client_ip(&headers);
if !allow.iter().any(|a| Some(a.as_str()) == ip.as_deref()) {
return StatusCode::FORBIDDEN.into_response();
}
handle.render().into_response()
}
}),
)
} else {
app
};
let pid_file = args.pid_file.clone();
let shutdown = async move {
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "failed to register SIGTERM handler");
std::process::exit(1);
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
if let Some(ref path) = pid_file {
std::fs::remove_file(path).ok();
tracing::info!(path = %path.display(), "removed PID file");
}
};
if let Some(domain) = args.domain {
let tls_port = args.tls_port.unwrap_or_else(|| {
if is_localhost_domain(&domain) {
3443
} else {
443
}
});
serve_tls(
app,
domain,
args.email,
args.acme_cache.unwrap_or_else(platform_default_acme_cache),
args.acme_staging,
args.port,
tls_port,
shutdown,
)
.await;
} else {
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
tracing::error!(port = args.port, error = %e, "failed to bind");
if let Some(ref path) = args.pid_file {
std::fs::remove_file(path).ok();
}
std::process::exit(1);
}
};
tracing::info!("listening on {addr}");
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
{
tracing::error!(error = %e, "server error");
std::process::exit(1);
}
}
if let Some(provider) = otel_provider {
if let Err(e) = provider.shutdown() {
eprintln!("failed to flush VictoriaLogs exporter: {e}");
}
}
}
fn is_localhost_domain(domain: &str) -> bool {
matches!(domain, "localhost" | "127.0.0.1")
}
fn run_mkcert(cert_path: &std::path::Path, key_path: &std::path::Path) -> Result<(), String> {
let output = std::process::Command::new("mkcert")
.args([
"-cert-file",
&cert_path.to_string_lossy(),
"-key-file",
&key_path.to_string_lossy(),
"localhost",
"127.0.0.1",
])
.output()
.map_err(|e| {
format!("mkcert not found ({e}) — install from https://github.com/FiloSottile/mkcert")
})?;
if !output.status.success() {
return Err(format!(
"mkcert failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
async fn serve_tls(
app: Router,
domain: String,
email: Option<String>,
acme_cache: PathBuf,
staging: bool,
http_port: u16,
tls_port: u16,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) {
if let Err(e) = std::fs::create_dir_all(&acme_cache) {
tracing::error!(path = %acme_cache.display(), error = %e, "failed to create cert cache dir");
std::process::exit(1);
}
if is_localhost_domain(&domain) {
let cert_path = acme_cache.join("localhost.pem");
let key_path = acme_cache.join("localhost-key.pem");
if !cert_path.exists() || !key_path.exists() {
tracing::info!("generating localhost certificate with mkcert");
if let Err(e) = run_mkcert(&cert_path, &key_path) {
tracing::error!(error = %e, "mkcert failed");
std::process::exit(1);
}
}
let config = match axum_server::tls_rustls::RustlsConfig::from_pem_file(
&cert_path, &key_path,
)
.await
{
Ok(c) => c,
Err(e) => {
tracing::error!(cert = %cert_path.display(), error = %e, "failed to load mkcert certificate");
std::process::exit(1);
}
};
let https_base = if http_port == 443 {
"https://localhost".to_string()
} else {
format!("https://localhost:{http_port}")
};
let redirect_app = Router::new().fallback(move |req: axum::extract::Request| {
let base = https_base.clone();
async move {
let pq = req
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/");
Redirect::permanent(&format!("{base}{pq}"))
}
});
let redirect_addr = SocketAddr::from(([127, 0, 0, 1], tls_port));
tracing::info!(addr = %redirect_addr, "HTTP → HTTPS redirect");
match tokio::net::TcpListener::bind(redirect_addr).await {
Ok(l) => {
tokio::spawn(async move {
axum::serve(l, redirect_app).await.ok();
});
}
Err(e) => {
tracing::warn!(port = tls_port, error = %e, "HTTP redirect port busy, skipping HTTP→HTTPS redirect");
}
}
let tls_addr = SocketAddr::from(([127, 0, 0, 1], http_port));
tracing::info!(addr = %tls_addr, "HTTPS on localhost (mkcert)");
let handle = axum_server::Handle::new();
let shutdown_handle = handle.clone();
tokio::spawn(async move {
shutdown.await;
shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(30)));
});
if let Err(e) = axum_server::bind_rustls(tls_addr, config)
.handle(handle)
.serve(app.into_make_service())
.await
{
tracing::error!(error = %e, "HTTPS server error");
std::process::exit(1);
}
return;
}
let email = match email {
Some(e) => e,
None => {
tracing::error!(
"--email is required for Let's Encrypt (--domain {})",
domain
);
std::process::exit(1);
}
};
let mut acme_state = AcmeConfig::new([domain.as_str()])
.contact([format!("mailto:{email}").as_str()])
.cache(DirCache::new(acme_cache))
.directory_lets_encrypt(!staging)
.state();
let rustls_config = acme_state.default_rustls_config();
let acceptor = acme_state.axum_acceptor(rustls_config);
tokio::spawn(async move {
loop {
match acme_state.next().await.unwrap() {
Ok(ok) => tracing::info!("ACME: {:?}", ok),
Err(err) => tracing::error!("ACME error: {:?}", err),
}
}
});
let tls_addr = SocketAddr::from(([0, 0, 0, 0], tls_port));
tracing::info!(domain = %domain, addr = %tls_addr, staging, "HTTPS with Let's Encrypt");
let domain_clone = domain.clone();
let redirect_app = Router::new().fallback(move |req: axum::extract::Request| {
let domain = domain_clone.clone();
async move {
let path_and_query = req
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
Redirect::permanent(&format!("https://{domain}{path_and_query}"))
}
});
let http_addr = SocketAddr::from(([0, 0, 0, 0], http_port));
tracing::info!(addr = %http_addr, "HTTP → HTTPS redirect");
let http_listener = match tokio::net::TcpListener::bind(http_addr).await {
Ok(l) => l,
Err(e) => {
tracing::warn!(port = http_port, error = %e, "HTTP redirect port busy, skipping HTTP→HTTPS redirect");
return;
}
};
tokio::spawn(async move {
if let Err(e) = axum::serve(http_listener, redirect_app).await {
tracing::error!(error = %e, "HTTP redirect server error");
}
});
let handle = axum_server::Handle::new();
let shutdown_handle = handle.clone();
tokio::spawn(async move {
shutdown.await;
shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(30)));
});
if let Err(e) = axum_server::bind(tls_addr)
.handle(handle)
.acceptor(acceptor)
.serve(app.into_make_service())
.await
{
tracing::error!(error = %e, "HTTPS server error");
std::process::exit(1);
}
}
fn cmd_stop(args: StopArgs) {
let pid_str = match std::fs::read_to_string(&args.pid_file) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot read PID file '{}': {e}",
args.pid_file.display()
);
std::process::exit(1);
}
};
let pid: u32 = match pid_str.trim().parse() {
Ok(p) => p,
Err(_) => {
eprintln!(
"error: PID file '{}' does not contain a valid PID",
args.pid_file.display()
);
std::process::exit(1);
}
};
let status = std::process::Command::new("kill")
.arg(pid.to_string())
.status();
match status {
Ok(s) if s.success() => {
println!("sent SIGTERM to process {pid}");
std::fs::remove_file(&args.pid_file).ok();
}
Ok(_) => {
eprintln!("error: kill({pid}) failed — process may have already exited");
std::process::exit(1);
}
Err(e) => {
eprintln!("error: failed to run kill: {e}");
std::process::exit(1);
}
}
}
#[derive(Clone)]
struct AppState {
db: Option<Arc<PathBuf>>,
cache: Option<Arc<PathBuf>>,
}
#[derive(Deserialize)]
struct PidQuery {
format: Option<String>,
source: Option<String>,
style: Option<String>,
locale: Option<String>,
local_only: Option<bool>,
noredirect: Option<String>,
}
#[derive(Deserialize)]
struct ContainerWorksQuery {
id: String,
limit: Option<usize>,
offset: Option<usize>,
}
impl ContainerWorksQuery {
fn page(&self) -> WorksPageQuery {
WorksPageQuery {
limit: self.limit,
offset: self.offset,
}
}
}
#[derive(Deserialize, Default, Clone, Copy)]
struct WorksPageQuery {
limit: Option<usize>,
offset: Option<usize>,
}
const WORKS_PAGE_DEFAULT: usize = 50;
const WORKS_PAGE_MAX: usize = 2000;
impl WorksPageQuery {
fn resolve(self) -> (usize, usize) {
let limit = match self.limit {
Some(0) | None => WORKS_PAGE_DEFAULT,
Some(n) => n.min(WORKS_PAGE_MAX),
};
(limit, self.offset.unwrap_or(0))
}
}
fn record_url(bytes: &[u8], named: Option<&str>) -> Option<String> {
let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
let entity = value.as_array().and_then(|a| a.first()).unwrap_or(&value);
let urls = entity.get("urls")?.as_array()?;
urls.iter()
.filter(|u| match named {
Some(name) => u.get("name").and_then(|n| n.as_str()) == Some(name),
None => true,
})
.find_map(|u| u.get("url").and_then(|u| u.as_str()))
.filter(|u| !u.is_empty())
.map(str::to_string)
}
async fn resolve_target(
path: &str,
source: Option<String>,
state: &AppState,
) -> Result<Option<String>, AppError> {
if is_ror_id(path) {
let ror_url = format!("https://ror.org/{path}");
let db = state.db.as_deref().cloned();
let cache = state.cache.as_deref().cloned();
let website = tokio::task::spawn_blocking(move || -> Option<String> {
let bytes = db
.and_then(|p| commonmeta::read_org_commonmeta_sqlite(&ror_url, &p).ok())
.or_else(|| {
cache.and_then(|p| commonmeta::read_org_commonmeta_sqlite(&ror_url, &p).ok())
})?;
record_url(&bytes, Some("website"))
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
return Ok(website);
}
if is_orcid_id(path) {
let orcid_url = format!("https://orcid.org/{path}");
let db = state.db.as_deref().cloned();
let cache = state.cache.as_deref().cloned();
let first_url = tokio::task::spawn_blocking(move || -> Option<String> {
let bytes = db
.and_then(|p| commonmeta::read_person_commonmeta_sqlite(&orcid_url, &p).ok())
.or_else(|| {
cache.and_then(|p| {
commonmeta::read_person_commonmeta_sqlite(&orcid_url, &p).ok()
})
})?;
record_url(&bytes, None)
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
return Ok(first_url);
}
if is_issn(path) || is_openalex_source_id(path) || is_datacite_client_id(path) {
let url = try_container(path, state, false)
.await?
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.and_then(|v| {
v.as_array()
.and_then(|a| a.first().cloned())
.unwrap_or(v)
.get("url")
.and_then(|u| u.as_str())
.map(str::to_string)
})
.filter(|u| !u.is_empty());
return Ok(url);
}
let Some(doi) = commonmeta::doi_utils::validate_doi(path) else {
return Ok(None);
};
let data = fetch_record_data(
doi.clone(),
source,
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
Ok(Some(if data.url.is_empty() {
format!("https://doi.org/{doi}")
} else {
data.url
}))
}
fn redirect_found(target: &str) -> Response {
(StatusCode::FOUND, [(header::LOCATION, target)]).into_response()
}
fn works_page_response(works: &[commonmeta::Work], total: usize) -> Result<Response, AppError> {
let bytes = works_to_prepared_json(works)?;
let mut response = (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response();
response.headers_mut().insert(
header::HeaderName::from_static("x-total-count"),
header::HeaderValue::from(total as u64),
);
Ok(response)
}
#[derive(Deserialize)]
struct HandlesQuery {
noredirect: Option<String>,
format: Option<String>,
source: Option<String>,
style: Option<String>,
locale: Option<String>,
}
const CACHE_HTML: &str = "public, max-age=3600";
const CACHE_ENTITY: &str = "no-cache";
const CACHE_STATIC: &str = "public, max-age=86400";
async fn og_image() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, CACHE_STATIC),
],
include_bytes!("../ui/public/og-image.png").as_ref(),
)
}
async fn index() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, CACHE_HTML),
],
include_str!("../ui/dist/index.html"),
)
}
async fn handle_pid(
State(state): State<AppState>,
Path(path): Path<String>,
Query(query): Query<PidQuery>,
RawQuery(raw_query): RawQuery,
headers: HeaderMap,
) -> Result<Response, AppError> {
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("text/html");
if let Some(canon) = canonical_pid_path(&path) {
let target = match raw_query.as_deref().filter(|q| !q.is_empty()) {
Some(q) => format!("/{canon}?{q}"),
None => format!("/{canon}"),
};
return Ok(Redirect::permanent(&target).into_response());
}
let wants_html =
query.format.is_none() && matches!(negotiate::negotiate(accept), NegotiateResult::Redirect);
if wants_html
&& query.noredirect.is_none()
&& let Some(target) = resolve_target(&path, query.source.clone(), &state).await?
{
return Ok(redirect_found(&target));
}
if is_ror_id(&path) || is_orcid_id(&path) {
if wants_html {
return serve_entity_page(path, None, state).await;
}
let prefix = if is_ror_id(&path) { "ror" } else { "orcid" };
return Ok(Redirect::temporary(&format!("/{prefix}/{path}")).into_response());
}
if is_issn(&path) || is_openalex_source_id(&path) || is_datacite_client_id(&path) {
if wants_html {
return serve_entity_page(path, None, state).await;
}
let bytes = match try_container(&path, &state, true).await? {
Some(b) => Some(b),
None => fetch_container(&path, state.cache.as_deref().cloned()).await?,
};
if let Some(bytes) = bytes {
let ct = negotiate::content_type_for_format("commonmeta")
.unwrap_or("application/vnd.commonmeta+json; charset=utf-8");
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, ct)], bytes).into_response());
}
return Err(AppError::NotFound(path));
}
let Some(doi) = commonmeta::doi_utils::validate_doi(&path) else {
if wants_html {
return Ok(
Redirect::temporary(&format!("/?q={}", url_encode_query(&path))).into_response(),
);
}
return Err(AppError::NotFound(path.clone()));
};
tracing::info!(doi = %doi, "resolving PID");
let (format, content_type, style, locale): (
String,
&'static str,
Option<String>,
Option<String>,
) = if let Some(fmt) = &query.format {
let ct = negotiate::content_type_for_format(fmt)
.ok_or_else(|| AppError::UnsupportedFormat(fmt.clone()))?;
(fmt.clone(), ct, query.style.clone(), query.locale.clone())
} else {
match negotiate::negotiate(accept) {
NegotiateResult::Format(n) => (
n.format.to_string(),
n.content_type,
n.style.or_else(|| query.style.clone()),
n.locale.or_else(|| query.locale.clone()),
),
NegotiateResult::Redirect => {
return serve_entity_page(path, query.source.clone(), state).await;
}
NegotiateResult::NotAcceptable => {
return Err(AppError::UnsupportedFormat(accept.to_string()));
}
}
};
if format == "commonmeta"
&& let Some(bytes) = try_container(&path, &state, true).await?
{
return Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response());
}
let bytes = fetch_and_convert(
&doi,
&format,
query.source.as_deref(),
style.as_deref(),
locale.as_deref(),
state.db.as_deref(),
state.cache.as_deref(),
query.local_only.unwrap_or(false),
)
.await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
async fn try_container(
identifier: &str,
state: &AppState,
with_works: bool,
) -> Result<Option<Vec<u8>>, AppError> {
let id = identifier.to_string();
let db = state.db.as_deref().cloned();
let cache = state.cache.as_deref().cloned();
tokio::task::spawn_blocking(move || -> Result<Option<Vec<u8>>, AppError> {
let read = |path: &PathBuf| {
if with_works {
db::lookup_container(path, &id)
} else {
db::lookup_container_record(path, &id)
}
};
if let Some(ref path) = db
&& let Some(bytes) = read(path)?
{
return Ok(Some(bytes));
}
if let Some(ref path) = cache
&& let Some(bytes) = read(path)?
{
return Ok(Some(bytes));
}
Ok(None)
})
.await
.map_err(|e| AppError::Internal(format!("container lookup task: {e}")))?
}
async fn fetch_container(
identifier: &str,
cache: Option<PathBuf>,
) -> Result<Option<Vec<u8>>, AppError> {
let id = identifier.to_string();
tokio::task::spawn_blocking(move || -> Option<Vec<u8>> {
let container = db::resolve_container_remote(&id)?;
if let Some(ref cpath) = cache {
if let Err(e) = db::cache_upsert_container(cpath, &container) {
tracing::warn!(id = %id, error = %e, "failed to cache container");
}
}
let value = serde_json::to_value(&container).ok()?;
serde_json::to_vec_pretty(&serde_json::Value::Array(vec![value])).ok()
})
.await
.map_err(|e| AppError::Internal(format!("container fetch task: {e}")))
}
async fn serve_entity_page(
path: String,
source: Option<String>,
state: AppState,
) -> Result<Response, AppError> {
if is_ror_id(&path) {
let ror_url = format!("https://ror.org/{path}");
if let Some(ref db_path) = state.db {
if let Ok(bytes) = commonmeta::read_org_commonmeta_sqlite(&path, db_path) {
let has_works = serde_json::from_slice::<serde_json::Value>(&bytes)
.map(|v| v.as_array().map_or(false, |a| a.len() > 1))
.unwrap_or(false);
if has_works {
return entity_page_html(bytes);
}
}
}
if let Some(ref cache_path) = state.cache {
if let Some(bytes) = db::cache_lookup_org_page(cache_path, &ror_url) {
tracing::debug!(id = %ror_url, "ror entity page served from cache");
return entity_page_html(bytes);
}
}
let (org_data, org_types) = fetch_ror_org(
ror_url.clone(),
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
let bytes = ror_to_commonmeta_json(
&org_data,
&org_types,
&[],
state.db.as_deref().map(|p| p.as_path()),
)?;
return entity_page_html(bytes);
}
if is_orcid_id(&path) {
let orcid_url = format!("https://orcid.org/{path}");
if let Some(db_path) = state.db.as_deref().cloned() {
let id = path.clone();
if let Ok(Ok(bytes)) = tokio::task::spawn_blocking(move || {
commonmeta::read_person_commonmeta_sqlite(&id, &db_path)
})
.await
{
return entity_page_html(bytes);
}
}
if let Some(cache_path) = state.cache.as_deref().cloned() {
let url = orcid_url.clone();
if let Ok(Some(bytes)) =
tokio::task::spawn_blocking(move || db::cache_lookup_person_page(&cache_path, &url))
.await
{
tracing::debug!(id = %orcid_url, "orcid entity page served from cache");
return entity_page_html(bytes);
}
}
let person_data =
fetch_orcid_person(orcid_url.clone(), state.cache.as_deref().cloned()).await?;
let bytes = orcid_to_commonmeta_json(&person_data, &[])?;
return entity_page_html(bytes);
}
if let Some(bytes) = try_container(&path, &state, false).await? {
return entity_page_html(bytes);
}
if is_issn(&path) || is_openalex_source_id(&path) || is_datacite_client_id(&path) {
return match fetch_container(&path, state.cache.as_deref().cloned()).await? {
Some(bytes) => entity_page_html(bytes),
None => Err(AppError::NotFound(path)),
};
}
let Some(doi) = commonmeta::doi_utils::validate_doi(&path) else {
return Err(AppError::NotFound(path));
};
let bytes = fetch_and_convert(
&doi,
"commonmeta",
source.as_deref(),
None,
None,
state.db.as_deref(),
state.cache.as_deref(),
false,
)
.await?;
entity_page_html(bytes)
}
async fn handle_handles(
State(state): State<AppState>,
Path(path): Path<String>,
Query(query): Query<HandlesQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if query.noredirect.is_some() {
return Ok(redirect_found(&format!("/{path}?noredirect")));
}
if is_ror_id(&path) {
let ror_url = format!("https://ror.org/{path}");
let fmt = query.format.as_deref().map(str::to_string);
let wants_ror = fmt.as_deref() == Some("ror") || accept_has(accept, "application/json");
let wants_commonmeta = fmt.as_deref() == Some("commonmeta")
|| accept_has(accept, "application/vnd.commonmeta+json");
if wants_ror || wants_commonmeta {
let (org_data, _, _) = fetch_ror_data(
ror_url,
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
if wants_ror {
let bytes = commonmeta::write_ror_json(&org_data)
.map_err(|e| AppError::FetchError(e.to_string()))?;
return Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response());
} else {
let entity = commonmeta::Entity::Organization(org_data);
let bytes = commonmeta::write("commonmeta", &entity)
.map_err(|e| AppError::FetchError(e.to_string()))?;
return Ok((
StatusCode::OK,
[(
header::CONTENT_TYPE,
"application/vnd.commonmeta+json; charset=utf-8",
)],
bytes,
)
.into_response());
}
}
let target = resolve_target(&path, query.source.clone(), &state)
.await?
.unwrap_or(ror_url);
return Ok(redirect_found(&target));
}
if is_orcid_id(&path) {
let orcid_url = format!("https://orcid.org/{path}");
let fmt = query.format.as_deref().map(str::to_string);
let wants_orcid = fmt.as_deref() == Some("orcid")
|| accept_has(accept, "application/vnd.orcid+json")
|| accept_has(accept, "application/json");
let wants_commonmeta = fmt.as_deref() == Some("commonmeta")
|| accept_has(accept, "application/vnd.commonmeta+json");
if wants_orcid || wants_commonmeta {
let (person_data, _) = fetch_orcid_data(
orcid_url,
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
let entity = commonmeta::Entity::Person(person_data);
let bytes = commonmeta::write("commonmeta", &entity)
.map_err(|e| AppError::FetchError(e.to_string()))?;
let ct = if wants_orcid {
"application/json; charset=utf-8"
} else {
"application/vnd.commonmeta+json; charset=utf-8"
};
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, ct)], bytes).into_response());
}
let target = resolve_target(&path, query.source.clone(), &state)
.await?
.unwrap_or(orcid_url);
return Ok(redirect_found(&target));
}
let Some(doi) = commonmeta::doi_utils::validate_doi(&path) else {
return Err(AppError::NotFound(path.clone()));
};
if let NegotiateResult::Format(n) = negotiate::negotiate(accept) {
let fmt = query.format.as_deref().unwrap_or(n.format).to_string();
let ct = negotiate::content_type_for_format(&fmt)
.ok_or_else(|| AppError::UnsupportedFormat(fmt.clone()))?;
let bytes = fetch_and_convert(
&doi,
&fmt,
query.source.as_deref(),
query.style.as_deref(),
query.locale.as_deref(),
state.db.as_deref(),
state.cache.as_deref(),
false,
)
.await?;
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, ct)], bytes).into_response());
}
let target = resolve_target(&path, query.source.clone(), &state)
.await?
.unwrap_or_else(|| format!("https://doi.org/{doi}"));
Ok(redirect_found(&target))
}
fn accept_has(accept: &str, mime: &str) -> bool {
accept.split(',').any(|part| {
let segs: Vec<&str> = part.split(';').collect();
if !segs[0].trim().eq_ignore_ascii_case(mime) {
return false;
}
let q = segs[1..]
.iter()
.find_map(|s| {
let s = s.trim();
if s.len() >= 2 && s[..2].eq_ignore_ascii_case("q=") {
s[2..].parse::<f32>().ok()
} else {
None
}
})
.unwrap_or(1.0);
q > 0.0
})
}
fn entity_into_work(entity: commonmeta::Entity, id: &str) -> Result<commonmeta::Work, AppError> {
let kind = entity.kind();
entity
.into_work()
.ok_or_else(|| AppError::FetchError(format!("'{id}' resolved to a {kind}, not a work")))
}
fn cache_pid(doi: &str) -> String {
commonmeta::doi_utils::normalize_doi(doi)
.trim_start_matches("https://doi.org/")
.trim_start_matches("http://doi.org/")
.trim_start_matches("https://dx.doi.org/")
.trim_start_matches("http://dx.doi.org/")
.to_string()
}
fn cache_raw_work(cache: &Option<PathBuf>, doi: &str, source_id: i64, raw: String) {
if let Some(path) = cache {
let entry = (cache_pid(doi), source_id, raw);
if let Err(e) = db::cache_upsert_raw(path, &[entry]) {
tracing::warn!(doi = %doi, error = %e, "failed to cache fetched metadata");
} else {
tracing::debug!(doi = %doi, "cached fetched metadata");
}
}
}
fn cache_raw_triples(
cache: &Option<PathBuf>,
triples: &[(commonmeta::Work, i64, String)],
id: &str,
kind: &str,
) {
if triples.is_empty() {
return;
}
if let Some(path) = cache {
let entries: Vec<(String, i64, String)> = triples
.iter()
.map(|(w, sid, raw)| (cache_pid(&w.id), *sid, raw.clone()))
.collect();
if let Err(e) = db::cache_upsert_raw(path, &entries) {
tracing::warn!(id = %id, kind, error = %e, "failed to cache raw works");
}
}
}
async fn fetch_and_convert(
doi: &str,
format: &str,
source: Option<&str>,
style: Option<&str>,
locale: Option<&str>,
db: Option<&PathBuf>,
cache: Option<&PathBuf>,
local_only: bool,
) -> Result<Vec<u8>, AppError> {
let doi = doi.to_string();
let format = format.to_string();
let source = source.map(str::to_string);
let style = style.map(str::to_string);
let locale = locale.map(str::to_string);
let db = db.cloned();
let cache = cache.cloned();
tokio::task::spawn_blocking(move || {
if let Some(ref path) = db {
if let Some(data) = db::lookup(path, &doi)? {
tracing::debug!(doi = %doi, "served from local sqlite");
let mut data = enrich_relations(data, &db, &cache, &doi);
commonmeta::utils::fix_data_mojibake(&mut data);
if !local_only {
enrich_pmc_identifiers(&mut data, &doi);
}
let entity = commonmeta::Entity::Work(data);
return commonmeta::write_with_style(
&format,
&entity,
style.as_deref(),
locale.as_deref(),
)
.map_err(|e| AppError::FetchError(e.to_string()));
}
}
if let Some(ref path) = cache {
if let Some(data) = db::cache_lookup(path, &doi)? {
tracing::debug!(doi = %doi, "served from cache sqlite");
let mut data = enrich_relations(data, &db, &cache, &doi);
commonmeta::utils::fix_data_mojibake(&mut data);
if !local_only {
enrich_pmc_identifiers(&mut data, &doi);
}
let entity = commonmeta::Entity::Work(data);
return commonmeta::write_with_style(
&format,
&entity,
style.as_deref(),
locale.as_deref(),
)
.map_err(|e| AppError::FetchError(e.to_string()));
}
}
if local_only {
return Err(AppError::NotFound(doi));
}
let from = resolve_source(&doi, source.as_deref());
let data = match from.as_str() {
"crossref" => {
let (work, raw) = commonmeta::crossref::fetch_with_raw(&doi)
.map_err(|e| AppError::FetchError(e.to_string()))?;
cache_raw_work(&cache, &doi, 1, raw);
work
}
"datacite" => {
let (work, raw) = commonmeta::datacite::fetch_with_raw(&doi)
.map_err(|e| AppError::FetchError(e.to_string()))?;
cache_raw_work(&cache, &doi, 2, raw);
work
}
_ => {
let entity = commonmeta::read(&from, &doi)
.map_err(|e| AppError::FetchError(e.to_string()))?;
if format == "commonmeta"
&& let commonmeta::Entity::Container(container) = &entity
{
let value = serde_json::to_value(container)
.map_err(|e| AppError::Internal(e.to_string()))?;
return serde_json::to_vec_pretty(&serde_json::Value::Array(vec![value]))
.map_err(|e| AppError::Internal(e.to_string()));
}
entity_into_work(entity, &doi)?
}
};
let mut data = enrich_relations(data, &db, &cache, &doi);
commonmeta::utils::fix_data_mojibake(&mut data);
enrich_pmc_identifiers(&mut data, &doi);
let entity = commonmeta::Entity::Work(data);
commonmeta::write_with_style(&format, &entity, style.as_deref(), locale.as_deref())
.map_err(|e| AppError::FetchError(e.to_string()))
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?
}
async fn fetch_record_data(
doi: String,
source: Option<String>,
db: Option<PathBuf>,
cache: Option<PathBuf>,
) -> Result<commonmeta::Work, AppError> {
tokio::task::spawn_blocking(move || {
if let Some(ref path) = db {
if let Some(data) = db::lookup(path, &doi)? {
return Ok(data);
}
}
if let Some(ref path) = cache {
if let Some(data) = db::cache_lookup(path, &doi)? {
return Ok(data);
}
}
let from = resolve_source(&doi, source.as_deref());
let data = match from.as_str() {
"crossref" => {
let (work, raw) = commonmeta::crossref::fetch_with_raw(&doi)
.map_err(|e| AppError::FetchError(e.to_string()))?;
cache_raw_work(&cache, &doi, 1, raw);
work
}
"datacite" => {
let (work, raw) = commonmeta::datacite::fetch_with_raw(&doi)
.map_err(|e| AppError::FetchError(e.to_string()))?;
cache_raw_work(&cache, &doi, 2, raw);
work
}
_ => {
let entity = commonmeta::read(&from, &doi)
.map_err(|e| AppError::FetchError(e.to_string()))?;
entity_into_work(entity, &doi)?
}
};
Ok(data)
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?
}
fn invert_relation_type(type_: &str) -> &str {
match type_ {
"References" => "IsReferencedBy",
"IsReferencedBy" => "References",
"Cites" => "IsCitedBy",
"IsCitedBy" => "Cites",
"IsReviewOf" => "HasReview",
"HasReview" => "IsReviewOf",
"IsVersionOf" => "HasVersion",
"HasVersion" => "IsVersionOf",
"IsNewVersionOf" => "IsPreviousVersionOf",
"IsPreviousVersionOf" => "IsNewVersionOf",
"IsPartOf" => "HasPart",
"HasPart" => "IsPartOf",
"IsPreprintOf" => "HasPreprint",
"HasPreprint" => "IsPreprintOf",
"IsTranslationOf" => "HasTranslation",
"HasTranslation" => "IsTranslationOf",
"IsSupplementTo" => "IsSupplementedBy",
"IsSupplementedBy" => "IsSupplementTo",
"IsVariantFormOf" => "IsOriginalFormOf",
"IsOriginalFormOf" => "IsVariantFormOf",
other => other,
}
}
fn add_sqlite_relations(
data: &mut commonmeta::Work,
existing: &mut std::collections::HashSet<(String, String)>,
path: &PathBuf,
doi: &str,
) {
if let Ok(rels) = db::lookup_source_relations(path, doi) {
for (id, type_) in rels {
if type_ == "References" {
continue;
}
if existing.insert((id.clone(), type_.clone())) {
data.relations.push(commonmeta::metadata::Relation {
id,
type_,
..Default::default()
});
}
}
}
if let Ok(rels) = db::lookup_target_relations(path, doi) {
for (id, type_, year) in rels {
let inverted = invert_relation_type(&type_).to_string();
if existing.insert((id.clone(), inverted.clone())) {
data.relations.push(commonmeta::metadata::Relation {
id,
type_: inverted,
name: if year > 0 {
year.to_string()
} else {
String::new()
},
..Default::default()
});
}
}
}
}
fn enrich_relations(
mut data: commonmeta::Work,
db: &Option<PathBuf>,
cache: &Option<PathBuf>,
doi: &str,
) -> commonmeta::Work {
const ISSN_PORTAL_PREFIX: &str = "https://portal.issn.org/resource/ISSN/";
if data.container.identifier().is_empty() {
if let Some(issn) = data
.relations
.iter()
.find_map(|r| r.id.strip_prefix(ISSN_PORTAL_PREFIX))
.map(str::to_string)
{
data.container.set_identifier(issn, "ISSN");
}
}
data.relations
.retain(|r| !r.id.starts_with(ISSN_PORTAL_PREFIX));
let mut existing: std::collections::HashSet<(String, String)> = data
.relations
.iter()
.map(|r| (r.id.clone(), r.type_.clone()))
.collect();
if let Some(path) = db {
add_sqlite_relations(&mut data, &mut existing, path, doi);
}
if let Some(path) = cache {
add_sqlite_relations(&mut data, &mut existing, path, doi);
}
{
let all_ids: Vec<String> = data
.relations
.iter()
.map(|r| commonmeta::doi_utils::normalize_doi(&r.id))
.filter(|s| !s.is_empty())
.collect();
let mut versioned = std::collections::HashSet::new();
if let Some(path) = db {
versioned.extend(db::filter_versioned_ids(path, &all_ids));
}
if let Some(path) = cache {
versioned.extend(db::filter_versioned_ids(path, &all_ids));
}
if !versioned.is_empty() {
data.relations.retain(|r| {
if !matches!(
r.type_.as_str(),
"IsReferencedBy" | "IsCitedBy" | "IsSupplementTo"
) {
return true;
}
let nid = commonmeta::doi_utils::normalize_doi(&r.id);
!versioned.contains(&nid)
});
}
}
fill_container_title(&mut data.container, db, cache);
data
}
fn fill_container_title(
container: &mut commonmeta::metadata::Container,
db: &Option<PathBuf>,
cache: &Option<PathBuf>,
) {
if !container.title.is_empty() || container.identifiers.is_empty() {
return;
}
for ident in &container.identifiers {
let record = db
.as_ref()
.and_then(|p| db::container_record(p, &ident.identifier))
.or_else(|| {
cache
.as_ref()
.and_then(|p| db::container_record(p, &ident.identifier))
});
if let Some(record) = record
&& !record.title.is_empty()
{
container.title = record.title;
if container.type_.is_empty() && !record.type_.is_empty() {
container.type_ = record.type_;
}
return;
}
}
}
fn enrich_pmc_identifiers(data: &mut commonmeta::Work, doi: &str) {
let has_pmc = data
.identifiers
.iter()
.any(|i| matches!(i.identifier_type.as_str(), "PMID" | "PMCID"));
if has_pmc {
return;
}
if let Ok(epmc) = commonmeta::pubmed::fetch(doi) {
commonmeta::pubmed::merge_pmc_identifiers_into(data, &epmc);
}
}
#[derive(Deserialize)]
struct BibliographyRequest {
items: Vec<BibRequestItem>,
style: Option<String>,
locale: Option<String>,
}
#[derive(Deserialize)]
struct BibRequestItem {
id: String,
data: String, }
#[derive(Serialize)]
struct BibliographyResponse {
items: Vec<BibResponseItem>,
}
#[derive(Serialize)]
struct BibResponseItem {
id: String,
html: String,
}
async fn handle_bibliography(
Json(req): Json<BibliographyRequest>,
) -> Result<Json<BibliographyResponse>, AppError> {
let style = req.style;
let locale = req.locale;
let items = req.items;
let results = tokio::task::spawn_blocking(move || {
items
.into_iter()
.map(|item| {
let first_id = serde_json::from_str::<serde_json::Value>(&item.data)
.ok()
.and_then(|v| v.as_array()?.first()?.get("id")?.as_str().map(String::from))
.unwrap_or_default();
if first_id.starts_with("https://ror.org/")
|| first_id.starts_with("https://orcid.org/")
{
return Ok(BibResponseItem {
id: item.id,
html: String::new(),
});
}
let data = commonmeta::read("commonmeta", &item.data)
.map_err(|e| AppError::FetchError(e.to_string()))?;
let bytes = commonmeta::write_with_style(
"citation",
&data,
style.as_deref(),
locale.as_deref(),
)
.map_err(|e| AppError::FetchError(e.to_string()))?;
Ok(BibResponseItem {
id: item.id,
html: String::from_utf8_lossy(&bytes).into_owned(),
})
})
.collect::<Result<Vec<_>, AppError>>()
})
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
Ok(Json(BibliographyResponse { items: results }))
}
fn format_or_redirect(entity: commonmeta::Entity, fmt: Option<&str>) -> Result<Response, AppError> {
if let Some(format) = fmt {
let ct = negotiate::content_type_for_format(format)
.ok_or_else(|| AppError::UnsupportedFormat(format.to_string()))?;
let bytes = commonmeta::write_with_style(format, &entity, None, None)
.map_err(|e| AppError::FetchError(e.to_string()))?;
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, ct)], bytes).into_response());
}
let id = entity.id();
if let Some(doi) = id.strip_prefix("https://doi.org/") {
return Ok(Redirect::temporary(&format!("/{doi}")).into_response());
}
if let Some(ror_id) = id.strip_prefix("https://ror.org/") {
return Ok(Redirect::temporary(&format!("/{ror_id}")).into_response());
}
if let Some(orcid_id) = id.strip_prefix("https://orcid.org/") {
return Ok(Redirect::temporary(&format!("/{orcid_id}")).into_response());
}
Err(AppError::NotFound(id.to_string()))
}
fn ror_to_commonmeta_json(
data: &commonmeta::Organization,
org_types: &[String],
works: &[commonmeta::Work],
db: Option<&std::path::Path>,
) -> Result<Vec<u8>, AppError> {
use serde_json::{Map, Value};
const ID_TYPE_MAP: &[(&str, &str)] = &[
("crossref funder id", "FundRef"),
("fundref", "FundRef"),
("grid", "GRID"),
("wikidata", "Wikidata"),
("isni", "ISNI"),
];
let normalize_id_type = |raw: &str| -> &'static str {
let lower = raw.to_lowercase();
ID_TYPE_MAP
.iter()
.find(|(k, _)| *k == lower.as_str())
.map(|(_, v)| *v)
.unwrap_or("Other")
};
let mut obj = Map::new();
obj.insert("id".to_string(), Value::String(data.id.clone()));
if !data.name.is_empty() {
obj.insert("name".to_string(), Value::String(data.name.clone()));
}
if !data.acronym.is_empty() {
obj.insert("acronym".to_string(), Value::String(data.acronym.clone()));
}
if !data.additional_names.is_empty() {
obj.insert(
"additional_names".to_string(),
serde_json::json!(data.additional_names),
);
}
if !data.status.is_empty() {
obj.insert("status".to_string(), Value::String(data.status.clone()));
}
if let Some(year) = data.established {
obj.insert("established".to_string(), Value::Number(year.into()));
}
if !data.date_updated.is_empty() {
obj.insert(
"date_updated".to_string(),
Value::String(data.date_updated.clone()),
);
}
if !data.country.is_empty() {
obj.insert("country".to_string(), Value::String(data.country.clone()));
}
if let Some(geo) = data.geo_locations.first() {
if !geo.place.is_empty() || !geo.id.is_empty() {
let mut loc = Map::new();
if !geo.id.is_empty() {
loc.insert("id".to_string(), Value::String(geo.id.clone()));
}
if !geo.place.is_empty() {
loc.insert("place".to_string(), Value::String(geo.place.clone()));
}
if let Some(db_path) = db {
if let Some(geonames_id) = geo
.id
.strip_prefix("https://www.geonames.org/")
.and_then(|s| s.parse::<i64>().ok())
{
if let Ok(gn) = commonmeta::geonames::fetch_geoname_raw(geonames_id, db_path) {
loc.insert("city".to_string(), Value::String(gn.name.clone()));
if !gn.country_code.is_empty() {
loc.insert(
"country_code".to_string(),
Value::String(gn.country_code.clone()),
);
}
if !gn.admin1_code.is_empty() {
let region_code = format!("{}-{}", gn.country_code, gn.admin1_code);
loc.insert("region_code".to_string(), Value::String(region_code));
if let Ok(Some(name)) = commonmeta::geonames::lookup_admin1(
&gn.country_code,
&gn.admin1_code,
db_path,
) {
loc.insert("region_name".to_string(), Value::String(name));
}
}
}
}
}
obj.insert("location".to_string(), Value::Object(loc));
}
}
let urls_arr: Vec<Value> = data
.urls
.iter()
.filter(|u| !u.url.is_empty())
.map(|u| {
let mut m = Map::new();
if !u.name.is_empty() {
m.insert("name".to_string(), Value::String(u.name.clone()));
}
m.insert("url".to_string(), Value::String(u.url.clone()));
Value::Object(m)
})
.collect();
if !urls_arr.is_empty() {
obj.insert("urls".to_string(), Value::Array(urls_arr));
}
let ids: Vec<Value> = data
.identifiers
.iter()
.filter(|id| !id.identifier.is_empty())
.map(|id| {
let mut m = Map::new();
m.insert(
"identifier".to_string(),
Value::String(id.identifier.clone()),
);
m.insert(
"identifier_type".to_string(),
Value::String(normalize_id_type(&id.identifier_type).to_string()),
);
Value::Object(m)
})
.collect();
if !ids.is_empty() {
obj.insert("identifiers".to_string(), Value::Array(ids));
}
let rels: Vec<Value> = data
.relations
.iter()
.filter(|r| !r.id.is_empty())
.map(|r| {
let mut m = Map::new();
m.insert("id".to_string(), Value::String(r.id.clone()));
m.insert("type".to_string(), Value::String(r.type_.clone()));
if !r.name.is_empty() {
m.insert("name".to_string(), Value::String(r.name.clone()));
}
Value::Object(m)
})
.collect();
if !rels.is_empty() {
obj.insert("relations".to_string(), Value::Array(rels));
}
let types: &[String] = if !org_types.is_empty() {
org_types
} else {
&data.types
};
if !types.is_empty() {
obj.insert(
"types".to_string(),
Value::Array(types.iter().map(|t| Value::String(t.clone())).collect()),
);
}
let mut arr = vec![Value::Object(obj)];
for work in works {
let prepared = commonmeta::prepare_commonmeta(work);
let work_val =
serde_json::to_value(&prepared).map_err(|e| AppError::FetchError(e.to_string()))?;
arr.push(work_val);
}
serde_json::to_vec_pretty(&Value::Array(arr)).map_err(|e| AppError::FetchError(e.to_string()))
}
async fn handle_ror(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<PidQuery>,
) -> Result<Response, AppError> {
let ror_url = format!("https://ror.org/{id}");
let fmt = query.format.as_deref().unwrap_or("commonmeta");
if fmt == "commonmeta" {
if let Some(ref db_path) = state.db {
if let Ok(bytes) = commonmeta::read_org_commonmeta_sqlite(&id, db_path) {
return Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response());
}
}
let (org_data, org_types, works) = fetch_ror_data(
ror_url,
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
let bytes = ror_to_commonmeta_json(
&org_data,
&org_types,
&works,
state.db.as_deref().map(|p| p.as_path()),
)?;
return Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response());
}
let (org_data, _, _) = fetch_ror_data(
ror_url,
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
format_or_redirect(commonmeta::Entity::Organization(org_data), Some(fmt))
}
async fn handle_orcid(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<PidQuery>,
) -> Result<Response, AppError> {
let orcid_url = format!("https://orcid.org/{id}");
let fmt = query.format.as_deref().unwrap_or("commonmeta");
if fmt == "commonmeta" {
if let Some(db_path) = state.db.as_deref().cloned() {
let id_clone = id.clone();
if let Ok(Ok(bytes)) = tokio::task::spawn_blocking(move || {
commonmeta::read_person_commonmeta_sqlite(&id_clone, &db_path)
})
.await
{
return Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response());
}
}
let (person_data, works) = fetch_orcid_data(
orcid_url,
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
)
.await?;
let bytes = orcid_to_commonmeta_json(&person_data, &works)?;
return Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response());
}
let fmt = fmt.to_string();
let data = tokio::task::spawn_blocking(move || {
commonmeta::fetch_orcid(&orcid_url).map_err(|e| AppError::FetchError(e.to_string()))
})
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
format_or_redirect(commonmeta::Entity::Person(data), Some(&fmt))
}
async fn handle_dois(
State(state): State<AppState>,
Path(path): Path<String>,
Query(query): Query<PidQuery>,
) -> Result<Response, AppError> {
handle_pid_with_format(state, path, query, "datacite").await
}
async fn handle_works(
State(state): State<AppState>,
Path(path): Path<String>,
Query(query): Query<PidQuery>,
) -> Result<Response, AppError> {
handle_pid_with_format(state, path, query, "crossref").await
}
async fn handle_pid_with_format(
state: AppState,
path: String,
query: PidQuery,
format: &'static str,
) -> Result<Response, AppError> {
let doi = commonmeta::doi_utils::validate_doi(&path)
.ok_or_else(|| AppError::NotFound(path.clone()))?;
let content_type = negotiate::content_type_for_format(format)
.ok_or_else(|| AppError::UnsupportedFormat(format.to_string()))?;
tracing::info!(doi = %doi, format, "resolving PID");
let bytes = fetch_and_convert(
&doi,
format,
query.source.as_deref(),
None,
None,
state.db.as_deref(),
state.cache.as_deref(),
false,
)
.await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
fn build_app(state: AppState) -> Router {
Router::new()
.route("/", get(index))
.route("/og-image.png", get(og_image))
.route("/commonmeta_v1.0.json", get(schema_json))
.route("/api/status", get(handle_status))
.route("/api/count", get(handle_count))
.route("/api/random", get(handle_random))
.route("/api/search", get(handle_search))
.route(
"/api/bibliography",
post(handle_bibliography).layer(DefaultBodyLimit::max(16 * 1024 * 1024)),
)
.route("/api/person/{id}/works", get(handle_person_works))
.route("/api/org/{id}/works", get(handle_org_works))
.route("/api/container/works", get(handle_container_works))
.route("/ror/{id}", get(handle_ror))
.route("/orcid/{id}", get(handle_orcid))
.route("/api/handles/{*path}", get(handle_handles))
.route("/dois/{*path}", get(handle_dois))
.route("/works/{*path}", get(handle_works))
.route("/{*path}", get(handle_pid))
.layer(CompressionLayer::new())
.with_state(state)
}
async fn schema_json() -> impl axum::response::IntoResponse {
(
[
(axum::http::header::CONTENT_TYPE, "application/json"),
(axum::http::header::CACHE_CONTROL, CACHE_STATIC),
],
commonmeta::SCHEMA_JSON,
)
}
async fn handle_status(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "db": state.db.is_some() }))
}
async fn handle_count(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<db::EntityCount>, AppError> {
let Some(db) = state.db else {
return Ok(Json(db::EntityCount {
citations: None,
works: None,
}));
};
let id = params.get("id").cloned().unwrap_or_default();
if id.is_empty() {
return Ok(Json(db::EntityCount {
citations: None,
works: None,
}));
}
let result = tokio::task::spawn_blocking(move || db::count_for_id(&db, &id))
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
Ok(Json(result))
}
async fn handle_random(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
let Some(db) = state.db else {
return Err(AppError::NotFound("no local database".to_string()));
};
let id = tokio::task::spawn_blocking(move || db::random_id(&db))
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
match id {
Some(id) => Ok(Json(serde_json::json!({ "id": id }))),
None => Err(AppError::NotFound("no records found".to_string())),
}
}
async fn handle_search(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<db::SearchResult>>, AppError> {
let Some(db) = state.db else {
return Ok(Json(vec![]));
};
let q = params.get("q").cloned().unwrap_or_default();
if q.trim().is_empty() {
return Ok(Json(vec![]));
}
let kind = db::SearchKind::parse(params.get("kind").map(String::as_str));
let results = tokio::task::spawn_blocking(move || db::search(&db, &q, kind))
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
Ok(Json(results))
}
fn is_ror_id(s: &str) -> bool {
s.len() == 9
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
fn is_orcid_id(s: &str) -> bool {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 4 {
return false;
}
parts[..3]
.iter()
.all(|p| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit()))
&& parts[3].len() == 4
&& {
let b = parts[3].as_bytes();
b[..3].iter().all(|c| c.is_ascii_digit()) && (b[3].is_ascii_digit() || b[3] == b'X')
}
}
pub(crate) fn is_issn(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 9
&& b[4] == b'-'
&& b[..4].iter().all(u8::is_ascii_digit)
&& b[5..8].iter().all(u8::is_ascii_digit)
&& (b[8].is_ascii_digit() || b[8] == b'X' || b[8] == b'x')
}
pub(crate) fn is_openalex_source_id(s: &str) -> bool {
s.len() > 1 && s.starts_with('S') && s[1..].bytes().all(|c| c.is_ascii_digit())
}
pub(crate) fn datacite_client_id(s: &str) -> Option<String> {
let id = s.trim().strip_prefix("datacite:").unwrap_or(s.trim());
let ok = id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.contains('.')
&& !id.ends_with('.')
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.');
ok.then(|| id.to_string())
}
pub(crate) fn is_datacite_client_id(s: &str) -> bool {
datacite_client_id(s).is_some()
}
fn canonical_pid_path(path: &str) -> Option<String> {
let p = path.trim();
if let Some(i) = p.find("openalex.org/") {
let rest = p[i + "openalex.org/".len()..].trim_start_matches("sources/");
if is_openalex_source_id(rest) {
return Some(rest.to_string());
}
}
if let Some(rest) = p.strip_prefix("openalex:")
&& is_openalex_source_id(rest)
{
return Some(rest.to_string());
}
if let Some(rest) = p.strip_prefix("issn:").or_else(|| p.strip_prefix("ISSN:"))
&& is_issn(rest)
{
return Some(rest.to_string());
}
if let Some(rest) = p.strip_prefix("datacite:")
&& is_datacite_client_id(rest)
{
return Some(rest.to_string());
}
if let Some(i) = p.find("commons.datacite.org/repositories/") {
let rest = &p[i + "commons.datacite.org/repositories/".len()..];
if is_datacite_client_id(rest) {
return Some(rest.to_string());
}
}
let looks_like_doi_url = p.starts_with("http://")
|| p.starts_with("https://")
|| p.starts_with("doi:")
|| p.starts_with("DOI:");
if looks_like_doi_url
&& let Some(doi) = commonmeta::doi_utils::validate_doi(p)
&& doi != p
{
return Some(doi);
}
None
}
fn url_encode_query(s: &str) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b' ' => out.push('+'),
b => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn build_og_tags(json_str: &str) -> String {
let v: serde_json::Value = serde_json::from_str(json_str).unwrap_or_default();
let obj = if v.is_array() { v[0].clone() } else { v };
let id = obj["id"].as_str().unwrap_or("");
let title = if let Some(t) = obj["title"].as_str() {
t.to_owned()
} else if obj["given_name"].is_string() || obj["family_name"].is_string() {
let given = obj["given_name"].as_str().unwrap_or("");
let family = obj["family_name"].as_str().unwrap_or("");
format!("{} {}", given, family).trim().to_owned()
} else {
obj["name"].as_str().unwrap_or("").to_owned()
};
let description = obj["description"].as_str().unwrap_or("");
let og_type = if id.starts_with("https://orcid.org") {
"profile"
} else if id.starts_with("https://ror.org") {
"website"
} else {
"article"
};
let author_names: Vec<String> = obj["contributors"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|c| {
c["roles"]
.as_array()
.map(|r| r.iter().any(|v| v.as_str() == Some("Author")))
.unwrap_or(true) })
.filter_map(|c| {
if c["type"].as_str() == Some("Person") {
let given = c["person"]["given_name"].as_str().unwrap_or("");
let family = c["person"]["family_name"].as_str().unwrap_or("");
let name = format!("{} {}", given, family);
let name = name.trim().to_owned();
if name.is_empty() { None } else { Some(name) }
} else {
c["organization"]["name"].as_str().map(|s| s.to_owned())
}
})
.take(5)
.collect()
})
.unwrap_or_default();
let mut tags = format!(
"<meta property=\"og:title\" content=\"{}\">\n\
<meta property=\"og:url\" content=\"{}\">\n\
<meta property=\"og:type\" content=\"{}\">",
html_escape(&title),
html_escape(id),
og_type,
);
if !description.is_empty() {
tags.push_str(&format!(
"\n<meta property=\"og:description\" content=\"{}\">",
html_escape(description)
));
}
for name in &author_names {
tags.push_str(&format!(
"\n<meta property=\"article:author\" content=\"{}\">",
html_escape(name)
));
}
tags.push_str("\n<meta property=\"og:image\" content=\"https://commonmeta.org/og-image.png\">");
tags.push_str("\n<meta name=\"twitter:card\" content=\"summary\">");
if !author_names.is_empty() {
tags.push_str(&format!(
"\n<meta name=\"twitter:label1\" content=\"Authors\">\
\n<meta name=\"twitter:data1\" content=\"{}\">",
html_escape(&author_names.join(", "))
));
}
tags
}
fn entity_page_html(json_bytes: Vec<u8>) -> Result<Response, AppError> {
let html = include_str!("../ui/dist/index.html");
let json_str = String::from_utf8(json_bytes).map_err(|e| AppError::Internal(e.to_string()))?;
let og_tags = build_og_tags(&json_str);
let script = format!("<script>window.__DRAGOMAN_INIT__={};</script>", json_str);
let page = html
.replacen("</head>", &format!("{}\n</head>", og_tags), 1)
.replacen("</head>", &format!("{}\n</head>", script), 1);
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, CACHE_ENTITY),
],
page,
)
.into_response())
}
fn orcid_to_commonmeta_json(
person_data: &commonmeta::Person,
works: &[commonmeta::Work],
) -> Result<Vec<u8>, AppError> {
use serde_json::Value;
let entity = commonmeta::Entity::Person(person_data.clone());
let person_bytes = commonmeta::write("commonmeta", &entity)
.map_err(|e| AppError::FetchError(e.to_string()))?;
let person_val: Value =
serde_json::from_slice(&person_bytes).map_err(|e| AppError::FetchError(e.to_string()))?;
let mut arr = vec![person_val];
for work in works {
let prepared = commonmeta::prepare_commonmeta(work);
let work_val =
serde_json::to_value(&prepared).map_err(|e| AppError::FetchError(e.to_string()))?;
arr.push(work_val);
}
serde_json::to_vec_pretty(&Value::Array(arr)).map_err(|e| AppError::FetchError(e.to_string()))
}
async fn handle_person_works(
State(state): State<AppState>,
Path(id): Path<String>,
Query(page): Query<WorksPageQuery>,
) -> Result<Response, AppError> {
let orcid_url = format!("https://orcid.org/{id}");
let (limit, offset) = page.resolve();
if let Some(ref cache_path) = state.cache {
let (cached, total) = db::orcid_works_page(cache_path, &orcid_url, limit, offset);
if total > 0 {
tracing::debug!(id = %orcid_url, total, "orcid works served from cache");
return works_page_response(&cached, total);
}
}
let (works, total) = fetch_orcid_works_data(
orcid_url.clone(),
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
limit,
offset,
)
.await?;
works_page_response(&works, total)
}
const CONTAINER_WORKS_FETCH_LIMIT: usize = 100;
async fn handle_container_works(
State(state): State<AppState>,
Query(query): Query<ContainerWorksQuery>,
) -> Result<Response, AppError> {
let (limit, offset) = query.page().resolve();
let id = query.id;
let db = state.db.as_deref().cloned();
let cache = state.cache.as_deref().cloned();
let (works, total) = tokio::task::spawn_blocking(move || -> (Vec<commonmeta::Work>, usize) {
let container = db
.as_ref()
.and_then(|path| db::container_record(path, &id))
.or_else(|| {
cache
.as_ref()
.and_then(|path| db::container_record(path, &id))
})
.or_else(|| {
let c = db::resolve_container_remote(&id)?;
if let Some(ref cpath) = cache {
if let Err(e) = db::cache_upsert_container(cpath, &c) {
tracing::warn!(id = %id, error = %e, "failed to cache container");
}
}
Some(c)
});
let (mut works, mut total) = db
.as_ref()
.map(|p| db::container_works_local_page(p, &id, limit, offset))
.filter(|(_, total)| *total > 0)
.or_else(|| {
cache
.as_ref()
.map(|p| db::container_works_local_page(p, &id, limit, offset))
.filter(|(_, total)| *total > 0)
})
.unwrap_or_default();
if total == 0 {
let Some(ref container) = container else {
return (Vec::new(), 0);
};
let triples = commonmeta::fetch_container_works_with_raw(
container,
CONTAINER_WORKS_FETCH_LIMIT,
1,
);
cache_raw_triples(&cache, &triples, &id, "container works");
let mut fetched: Vec<commonmeta::Work> =
triples.into_iter().map(|(w, _, _)| w).collect();
(works, total) = paginate_fetched(&mut fetched, limit, offset);
}
if let Some(container) = container {
for w in &mut works {
if w.container.title.is_empty() && !container.title.is_empty() {
w.container.title = container.title.clone();
}
if w.container.type_.is_empty() && !container.type_.is_empty() {
w.container.type_ = container.type_.clone();
}
}
}
(works, total)
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
works_page_response(&works, total)
}
fn works_to_prepared_json(works: &[commonmeta::Work]) -> Result<Vec<u8>, AppError> {
let arr: Vec<_> = works
.iter()
.filter_map(|w| serde_json::to_value(commonmeta::prepare_commonmeta(w)).ok())
.collect();
serde_json::to_vec_pretty(&arr).map_err(|e| AppError::FetchError(e.to_string()))
}
async fn fetch_orcid_person(
orcid_url: String,
cache: Option<PathBuf>,
) -> Result<commonmeta::Person, AppError> {
tokio::task::spawn_blocking(move || {
let (data, person_xml) = commonmeta::fetch_orcid_with_xml(&orcid_url)
.map_err(|e| AppError::FetchError(e.to_string()))?;
if let Some(ref path) = cache {
if let Err(e) = db::cache_upsert_person(path, &orcid_url, &person_xml) {
tracing::warn!(id = %orcid_url, error = %e, "failed to cache ORCID person");
}
}
Ok(data)
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?
}
async fn fetch_orcid_works_data(
orcid_url: String,
db: Option<PathBuf>,
cache: Option<PathBuf>,
limit: usize,
offset: usize,
) -> Result<(Vec<commonmeta::Work>, usize), AppError> {
let url1 = orcid_url.clone();
let db_clone = db.clone();
let sqlite_task = tokio::task::spawn_blocking(move || -> (Vec<commonmeta::Work>, usize) {
if let Some(ref path) = db_clone {
db::orcid_works_page(path, &url1, limit, offset)
} else {
(Vec::new(), 0)
}
});
let (page, total) = sqlite_task
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
if total > 0 {
return Ok((page, total));
}
let url = orcid_url.clone();
let cache2 = cache.clone();
let mut works = tokio::task::spawn_blocking(move || -> Vec<commonmeta::Work> {
let triples = commonmeta::fetch_orcid_works_with_raw(&url, 1000, 1);
cache_raw_triples(&cache2, &triples, &url, "ORCID works");
triples.into_iter().map(|(w, _, _)| w).collect()
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(paginate_fetched(&mut works, limit, offset))
}
fn paginate_fetched(
works: &mut Vec<commonmeta::Work>,
limit: usize,
offset: usize,
) -> (Vec<commonmeta::Work>, usize) {
let mut seen = std::collections::HashSet::new();
works.retain(|w| seen.insert(w.id.clone()));
works.sort_by(|a, b| b.date_published.cmp(&a.date_published));
works.truncate(WORKS_PAGE_MAX);
let total = works.len();
let page = works
.iter()
.skip(offset)
.take(limit)
.cloned()
.collect::<Vec<_>>();
(page, total)
}
async fn fetch_orcid_data(
orcid_url: String,
db: Option<PathBuf>,
cache: Option<PathBuf>,
) -> Result<(commonmeta::Person, Vec<commonmeta::Work>), AppError> {
let url1 = orcid_url.clone();
let orcid_task = tokio::task::spawn_blocking(move || {
commonmeta::fetch_orcid_with_xml(&url1).map_err(|e| AppError::FetchError(e.to_string()))
});
let url2 = orcid_url.clone();
let db_clone = db.clone();
let sqlite_task = tokio::task::spawn_blocking(move || -> Vec<commonmeta::Work> {
if let Some(ref path) = db_clone {
commonmeta::read_sqlite_by_orcid(&url2, path).unwrap_or_default()
} else {
Vec::new()
}
});
let (orcid_result, sqlite_result) = tokio::join!(orcid_task, sqlite_task);
let (data, person_xml) = orcid_result.map_err(|e| AppError::Internal(e.to_string()))??;
let sqlite_works = sqlite_result.map_err(|e| AppError::Internal(e.to_string()))?;
if let Some(ref path) = cache {
if let Err(e) = db::cache_upsert_person(path, &orcid_url, &person_xml) {
tracing::warn!(id = %orcid_url, error = %e, "failed to cache ORCID person");
} else {
tracing::debug!(id = %orcid_url, "cached ORCID person");
}
}
let mut works = sqlite_works;
if works.is_empty() {
let url_cr = orcid_url.clone();
let url_dc = orcid_url.clone();
let cr_task = tokio::task::spawn_blocking(move || {
commonmeta::fetch_crossref_by_orcid(&url_cr, 1000, 1).unwrap_or_default()
});
let dc_task = tokio::task::spawn_blocking(move || {
commonmeta::fetch_datacite_by_orcid(&url_dc, 1000, 1).unwrap_or_default()
});
let (cr_result, dc_result) = tokio::join!(cr_task, dc_task);
let mut cr = cr_result.map_err(|e| AppError::Internal(e.to_string()))?;
let mut dc = dc_result.map_err(|e| AppError::Internal(e.to_string()))?;
cr.append(&mut dc);
works.append(&mut cr);
}
let mut seen = std::collections::HashSet::new();
works.retain(|w| seen.insert(w.id.clone()));
works.sort_by(|a, b| b.date_published.cmp(&a.date_published));
works.truncate(2000);
Ok((data, works))
}
async fn handle_org_works(
State(state): State<AppState>,
Path(id): Path<String>,
Query(page): Query<WorksPageQuery>,
) -> Result<Response, AppError> {
let ror_url = format!("https://ror.org/{id}");
let (limit, offset) = page.resolve();
if let Some(ref cache_path) = state.cache {
let (cached, total) = db::ror_works_page(cache_path, &ror_url, limit, offset);
if total > 0 {
tracing::debug!(id = %ror_url, total, "ror works served from cache");
return works_page_response(&cached, total);
}
}
let (works, total) = fetch_ror_works_data(
ror_url.clone(),
state.db.as_deref().cloned(),
state.cache.as_deref().cloned(),
limit,
offset,
)
.await?;
works_page_response(&works, total)
}
async fn fetch_ror_org(
ror_url: String,
db: Option<PathBuf>,
cache: Option<PathBuf>,
) -> Result<(commonmeta::Organization, Vec<String>), AppError> {
tokio::task::spawn_blocking(move || {
let data = if let Some(ref path) = db {
if let Some(result) = db::lookup_by_ror(path, &ror_url)? {
tracing::debug!(id = %ror_url, "ror served from local sqlite");
result
} else {
let (d, ror) = commonmeta::fetch_ror_with_raw(&ror_url)
.map_err(|e| AppError::FetchError(e.to_string()))?;
if let Some(ref cpath) = cache {
if let Err(e) = db::cache_upsert_org(cpath, &ror) {
tracing::warn!(id = %ror_url, error = %e, "failed to cache ROR org");
} else {
tracing::debug!(id = %ror_url, "cached ROR org");
}
}
d
}
} else {
let (d, ror) = commonmeta::fetch_ror_with_raw(&ror_url)
.map_err(|e| AppError::FetchError(e.to_string()))?;
if let Some(ref cpath) = cache {
if let Err(e) = db::cache_upsert_org(cpath, &ror) {
tracing::warn!(id = %ror_url, error = %e, "failed to cache ROR org");
}
}
d
};
let org_types = if let Some(ref path) = db {
db::lookup_ror_types(path, &ror_url).unwrap_or_default()
} else {
Vec::new()
};
Ok((data, org_types))
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?
}
async fn fetch_ror_works_data(
ror_url: String,
db: Option<PathBuf>,
cache: Option<PathBuf>,
limit: usize,
offset: usize,
) -> Result<(Vec<commonmeta::Work>, usize), AppError> {
let url1 = ror_url.clone();
let db_clone = db.clone();
let sqlite_task = tokio::task::spawn_blocking(move || -> (Vec<commonmeta::Work>, usize) {
if let Some(ref path) = db_clone {
db::ror_works_page(path, &url1, limit, offset)
} else {
(Vec::new(), 0)
}
});
let (page, total) = sqlite_task
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
if total > 0 {
return Ok((page, total));
}
let url = ror_url.clone();
let cache2 = cache.clone();
let mut works = tokio::task::spawn_blocking(move || -> Vec<commonmeta::Work> {
let triples = commonmeta::fetch_ror_works_with_raw(&url, 1000, 1);
cache_raw_triples(&cache2, &triples, &url, "ROR works");
triples.into_iter().map(|(w, _, _)| w).collect()
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(paginate_fetched(&mut works, limit, offset))
}
async fn fetch_ror_data(
ror_url: String,
db: Option<PathBuf>,
cache: Option<PathBuf>,
) -> Result<(commonmeta::Organization, Vec<String>, Vec<commonmeta::Work>), AppError> {
tokio::task::spawn_blocking(move || {
let data = if let Some(ref path) = db {
if let Some(result) = db::lookup_by_ror(path, &ror_url)? {
tracing::debug!(id = %ror_url, "ror served from local sqlite");
result
} else {
let (d, ror) = commonmeta::fetch_ror_with_raw(&ror_url)
.map_err(|e| AppError::FetchError(e.to_string()))?;
if let Some(ref cpath) = cache {
if let Err(e) = db::cache_upsert_org(cpath, &ror) {
tracing::warn!(id = %ror_url, error = %e, "failed to cache ROR org");
} else {
tracing::debug!(id = %ror_url, "cached ROR org");
}
}
d
}
} else {
let (d, ror) = commonmeta::fetch_ror_with_raw(&ror_url)
.map_err(|e| AppError::FetchError(e.to_string()))?;
if let Some(ref cpath) = cache {
if let Err(e) = db::cache_upsert_org(cpath, &ror) {
tracing::warn!(id = %ror_url, error = %e, "failed to cache ROR org");
}
}
d
};
let org_types = if let Some(ref path) = db {
db::lookup_ror_types(path, &ror_url).unwrap_or_default()
} else {
Vec::new()
};
let sqlite_works = if let Some(ref path) = db {
commonmeta::read_sqlite_by_ror(&ror_url, path).unwrap_or_default()
} else {
Vec::new()
};
let mut works = sqlite_works;
if works.is_empty() {
let mut cr = commonmeta::fetch_crossref_by_ror(&ror_url, 1000, 1).unwrap_or_default();
let mut dc = commonmeta::fetch_datacite_by_ror(&ror_url, 1000, 1).unwrap_or_default();
cr.append(&mut dc);
works.append(&mut cr);
}
let mut seen = std::collections::HashSet::new();
works.retain(|w| seen.insert(w.id.clone()));
works.sort_by(|a, b| b.date_published.cmp(&a.date_published));
works.truncate(2000);
Ok((data, org_types, works))
})
.await
.map_err(|e| AppError::Internal(e.to_string()))?
}
fn resolve_source(doi: &str, source: Option<&str>) -> String {
if let Some(s) = source {
return s.to_lowercase();
}
match commonmeta::doi_utils::get_doi_ra_sync(doi, false)
.as_deref()
.map(str::to_lowercase)
.as_deref()
{
Some("crossref") => "crossref".to_string(),
Some("datacite") => "datacite".to_string(),
_ => "crossref".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request};
use tower::ServiceExt;
fn make_test_db() -> (tempfile::TempDir, Arc<PathBuf>) {
let dir = tempfile::tempdir().unwrap();
let path = db::tests::make_test_db(&dir.path().join("test.sqlite3"));
(dir, Arc::new(path))
}
fn make_test_cache_db() -> (tempfile::TempDir, Arc<PathBuf>) {
let dir = tempfile::tempdir().unwrap();
let path = db::tests::make_test_cache_db(&dir.path().join("cache.sqlite3"));
(dir, Arc::new(path))
}
fn app_no_db() -> Router {
build_app(AppState {
db: None,
cache: None,
})
}
fn app_with_db() -> (tempfile::TempDir, Router) {
let (dir, db) = make_test_db();
let app = build_app(AppState {
db: Some(db),
cache: None,
});
(dir, app)
}
fn app_with_cache_only() -> (tempfile::TempDir, Router) {
let (dir, cache) = make_test_cache_db();
let app = build_app(AppState {
db: None,
cache: Some(cache),
});
(dir, app)
}
#[tokio::test]
async fn index_returns_200() {
let response = app_no_db()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn resolve_local_db_wraps_present_path() {
let out = resolve_local_db(
Ok(Some(PathBuf::from("/db.sqlite3"))),
&PathBuf::from("/db.sqlite3"),
);
assert_eq!(out.as_deref(), Some(&PathBuf::from("/db.sqlite3")));
}
#[test]
fn resolve_local_db_none_when_no_database() {
let out = resolve_local_db(Ok(None), &PathBuf::from("/missing.sqlite3"));
assert!(out.is_none());
}
#[test]
fn resolve_local_db_degrades_on_open_error() {
let out = resolve_local_db(
Err(AppError::Internal("unable to open database file".into())),
&PathBuf::from("/var/lib/commonmeta/commonmeta.sqlite3"),
);
assert!(out.is_none());
}
#[test]
fn open_returns_err_for_unopenable_path() {
let dir = tempfile::tempdir().unwrap();
assert!(db::open(dir.path()).is_err());
}
#[tokio::test]
async fn schema_json_returns_json() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/commonmeta_v1.0.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.contains("application/json"));
}
#[tokio::test]
async fn non_doi_path_redirects_to_search() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/not-a-doi")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(response.headers().get("location").unwrap(), "/?q=not-a-doi");
}
#[tokio::test]
async fn arbitrary_text_path_redirects_to_search() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/hello/world")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
response.headers().get("location").unwrap(),
"/?q=hello%2Fworld"
);
}
#[tokio::test]
async fn non_doi_path_with_json_accept_returns_404() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/not-a-doi")
.header("Accept", "application/json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn handles_non_doi_returns_404() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/api/handles/not-a-doi")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn handles_noredirect_redirects_to_path() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/api/handles/10.5678/cached?noredirect")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response.headers().get("location").unwrap(),
"/10.5678/cached?noredirect"
);
}
#[tokio::test]
async fn handles_noredirect_non_pid_redirects_to_path() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/api/handles/not-a-pid?noredirect")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response.headers().get("location").unwrap(),
"/not-a-pid?noredirect"
);
}
#[tokio::test]
async fn unsupported_accept_returns_406() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/10.1234/test")
.header("Accept", "application/rdf+xml")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn multiple_unsupported_accept_returns_406() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/10.1234/test")
.header("Accept", "application/rdf+xml, image/png")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn invalid_format_query_param_returns_406() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/10.1234/test?format=nonsense")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn html_accept_with_cache_returns_redirect() {
let (_dir, app) = app_with_cache_only();
let response = app
.oneshot(
Request::builder()
.uri("/10.5678/cached")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response
.headers()
.get("location")
.and_then(|v| v.to_str().ok()),
Some("https://example.com/cached-article")
);
}
#[tokio::test]
async fn bibtex_accept_with_cache_returns_200() {
let (_dir, app) = app_with_cache_only();
let response = app
.oneshot(
Request::builder()
.uri("/10.5678/cached")
.header("Accept", "application/x-bibtex")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("application/x-bibtex"),
"unexpected content-type: {ct}"
);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(
text.starts_with('@'),
"bibtex should start with '@': {text}"
);
}
#[tokio::test]
async fn bare_doi_resolves_to_the_landing_page() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response
.headers()
.get("location")
.and_then(|v| v.to_str().ok()),
Some("https://example.com/test-article")
);
}
const EXPECTED_LANDING_PAGE: &str = "https://example.com/test-article";
#[tokio::test]
async fn short_url_serves_the_page_for_an_orcid_with_no_researcher_url() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/0000-0002-1825-0097")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn short_url_serves_the_page_for_a_ror_id_with_no_website() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/01ggx4157")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn record_url_reads_an_organization_website() {
let record = br#"[{"id":"https://ror.org/03y9e9713","urls":[
{"name":"website","url":"https://www.raphaelsklinik.de/rk"}]}]"#;
assert_eq!(
record_url(record, Some("website")).as_deref(),
Some("https://www.raphaelsklinik.de/rk")
);
let bare = br#"{"urls":[{"name":"website","url":"https://example.org"}]}"#;
assert_eq!(
record_url(bare, Some("website")).as_deref(),
Some("https://example.org")
);
assert_eq!(record_url(br#"[{"urls":[]}]"#, Some("website")), None);
assert_eq!(record_url(br#"[{"id":"x"}]"#, Some("website")), None);
assert_eq!(
record_url(
br#"[{"urls":[{"name":"website","url":""}]}]"#,
Some("website")
),
None,
"an empty website is not a target"
);
assert_eq!(
record_url(
br#"[{"urls":[{"name":"wikipedia","url":"https://w"}]}]"#,
Some("website")
),
None,
"only the website url is a resolve target"
);
assert_eq!(record_url(b"not json", Some("website")), None);
}
#[test]
fn record_url_reads_a_person_first_researcher_url() {
let record = br#"[{"id":"https://orcid.org/0000-0003-1419-2405","urls":[
{"name":"Blog","url":"https://blog.front-matter.de"},
{"name":"Mastodon","url":"https://hachyderm.io/@mfenner"}]}]"#;
assert_eq!(
record_url(record, None).as_deref(),
Some("https://blog.front-matter.de")
);
assert_eq!(record_url(br#"[{"urls":[]}]"#, None), None);
assert_eq!(record_url(br#"[{"id":"x"}]"#, None), None);
}
#[tokio::test]
async fn short_url_resolves_a_container_by_issn() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/2749-9952")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert!(
response.headers().contains_key("location"),
"a resolve must name a target"
);
}
#[tokio::test]
async fn canonicalising_a_prefixed_pid_keeps_the_query() {
for (uri, expected) in [
(
"/https://doi.org/10.1234/test?noredirect",
"/10.1234/test?noredirect",
),
(
"/https://doi.org/10.1234/test?format=bibtex",
"/10.1234/test?format=bibtex",
),
(
"/https://doi.org/10.1234/test?format=csl&style=apa",
"/10.1234/test?format=csl&style=apa",
),
("/https://doi.org/10.1234/test", "/10.1234/test"),
] {
let response = app_no_db()
.oneshot(
Request::builder()
.uri(uri)
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT, "{uri}");
assert_eq!(
response
.headers()
.get("location")
.and_then(|v| v.to_str().ok()),
Some(expected),
"{uri}"
);
}
}
#[tokio::test]
async fn bare_doi_with_noredirect_renders_the_entity_page() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test?noredirect")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn html_accept_with_db_returns_redirect() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response
.headers()
.get("location")
.and_then(|v| v.to_str().ok()),
Some(EXPECTED_LANDING_PAGE)
);
}
#[tokio::test]
async fn bibtex_accept_with_db_returns_200() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test")
.header("Accept", "application/x-bibtex")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("application/x-bibtex"),
"unexpected content-type: {ct}"
);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(
text.starts_with('@'),
"bibtex should start with '@': {text}"
);
}
#[tokio::test]
async fn format_query_param_with_db_returns_bibtex() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test?format=bibtex")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("application/x-bibtex"),
"unexpected content-type: {ct}"
);
}
#[tokio::test]
async fn dois_route_returns_datacite_json() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/dois/10.1234/test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("datacite.datacite+json"),
"unexpected content-type: {ct}"
);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
v.get("id").is_some() || v.get("doi").is_some(),
"no id/doi field in datacite json"
);
}
#[tokio::test]
async fn works_route_returns_crossref_json() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/works/10.1234/test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("vnd.crossref+json"),
"unexpected content-type: {ct}"
);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
v["message"].get("DOI").is_some(),
"no message.DOI field in crossref json: {v}"
);
}
#[tokio::test]
async fn csl_json_accept_with_db_returns_200() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test")
.header("Accept", "application/vnd.citationstyles.csl+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("citationstyles.csl+json"),
"unexpected content-type: {ct}"
);
}
#[test]
fn html_escape_replaces_special_chars() {
assert_eq!(html_escape("a & b"), "a & b");
assert_eq!(html_escape("say \"hi\""), "say "hi"");
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("no change"), "no change");
}
#[test]
fn is_issn_matches_bare_form_only() {
assert!(is_issn("2749-9952"));
assert!(is_issn("2049-363X"));
assert!(is_issn("2049-363x"));
assert!(!is_issn("27499952")); assert!(!is_issn("2749-995")); assert!(!is_issn("issn:2749-9952")); }
#[test]
fn is_openalex_source_id_matches_s_prefixed_digits() {
assert!(is_openalex_source_id("S7407056926"));
assert!(!is_openalex_source_id("W7407056926")); assert!(!is_openalex_source_id("S")); assert!(!is_openalex_source_id("S740A56926")); }
#[test]
fn is_datacite_client_id_matches_repository_symbols() {
assert_eq!(
datacite_client_id("cern.zenodo").as_deref(),
Some("cern.zenodo")
);
assert_eq!(
datacite_client_id("datacite:cern.zenodo").as_deref(),
Some("cern.zenodo")
);
assert!(is_datacite_client_id("figshare.figshare"));
assert!(!is_datacite_client_id("2749-9952")); assert!(!is_datacite_client_id("S7407056926")); assert!(!is_datacite_client_id("10.53731/front_matter")); assert!(!is_datacite_client_id("cern")); assert!(!is_datacite_client_id("cern.")); }
#[test]
fn canonical_pid_path_normalises_prefixed_forms() {
assert_eq!(
canonical_pid_path("issn:2749-9952").as_deref(),
Some("2749-9952")
);
assert_eq!(
canonical_pid_path("ISSN:2749-9952").as_deref(),
Some("2749-9952")
);
assert_eq!(
canonical_pid_path("https://openalex.org/S7407056926").as_deref(),
Some("S7407056926")
);
assert_eq!(
canonical_pid_path("openalex:S7407056926").as_deref(),
Some("S7407056926")
);
assert_eq!(
canonical_pid_path("https://doi.org/10.53731/front_matter").as_deref(),
Some("10.53731/front_matter")
);
assert_eq!(
canonical_pid_path("doi:10.53731/front_matter").as_deref(),
Some("10.53731/front_matter")
);
}
#[test]
fn canonical_pid_path_leaves_bare_forms_unchanged() {
assert!(canonical_pid_path("2749-9952").is_none());
assert!(canonical_pid_path("S7407056926").is_none());
assert!(canonical_pid_path("10.53731/front_matter").is_none());
}
#[test]
fn build_og_tags_work_json() {
let json = r#"{"id":"https://doi.org/10.1234/test","title":"A Title","type":"JournalArticle","description":"Abstract text"}"#;
let tags = build_og_tags(json);
assert!(
tags.contains(r#"property="og:title" content="A Title""#),
"missing title: {tags}"
);
assert!(
tags.contains(r#"property="og:url" content="https://doi.org/10.1234/test""#),
"missing url: {tags}"
);
assert!(
tags.contains(r#"property="og:type" content="article""#),
"missing type: {tags}"
);
assert!(
tags.contains(r#"property="og:description" content="Abstract text""#),
"missing description: {tags}"
);
assert!(
tags.contains(r#"name="twitter:card""#),
"missing twitter:card: {tags}"
);
}
#[test]
fn build_og_tags_no_description_omits_tag() {
let json =
r#"{"id":"https://doi.org/10.1234/test","title":"A Title","type":"JournalArticle"}"#;
let tags = build_og_tags(json);
assert!(
!tags.contains("og:description"),
"should not emit og:description when empty: {tags}"
);
}
#[test]
fn build_og_tags_escapes_html_in_title() {
let json = r#"{"id":"https://doi.org/10.1234/x","title":"Rock & Roll: \"Loud\"","type":"JournalArticle"}"#;
let tags = build_og_tags(json);
assert!(
tags.contains("Rock & Roll: "Loud""),
"title not escaped: {tags}"
);
}
#[test]
fn build_og_tags_orcid_person_array() {
let json = r#"[{"id":"https://orcid.org/0000-0001-2345-6789","given_name":"Jane","family_name":"Doe","type":"Person"},{"id":"https://doi.org/10.1/w","title":"A Work"}]"#;
let tags = build_og_tags(json);
assert!(
tags.contains(r#"content="Jane Doe""#),
"missing person name: {tags}"
);
assert!(
tags.contains(r#"property="og:type" content="profile""#),
"wrong og:type for ORCID: {tags}"
);
assert!(
tags.contains(r#"content="https://orcid.org/0000-0001-2345-6789""#),
"missing ORCID url: {tags}"
);
}
#[test]
fn build_og_tags_contributors() {
let json = r#"{"id":"https://doi.org/10.1234/x","title":"A Work","type":"JournalArticle","contributors":[{"type":"Person","roles":["Author"],"person":{"given_name":"Jane","family_name":"Doe"}},{"type":"Person","roles":["Author"],"person":{"given_name":"John","family_name":"Smith"}},{"type":"Person","roles":["Editor"],"person":{"given_name":"Ed","family_name":"Itor"}}]}"#;
let tags = build_og_tags(json);
assert!(
tags.contains(r#"article:author" content="Jane Doe""#),
"missing first author: {tags}"
);
assert!(
tags.contains(r#"article:author" content="John Smith""#),
"missing second author: {tags}"
);
assert!(
!tags.contains("Ed Itor"),
"editor should be excluded: {tags}"
);
assert!(
tags.contains(r#"twitter:label1" content="Authors""#),
"missing twitter label: {tags}"
);
assert!(
tags.contains(r#"twitter:data1" content="Jane Doe, John Smith""#),
"missing twitter data: {tags}"
);
}
#[test]
fn build_og_tags_no_contributors_omits_author_tags() {
let json = r#"{"id":"https://doi.org/10.1234/x","title":"A Work","type":"JournalArticle"}"#;
let tags = build_og_tags(json);
assert!(
!tags.contains("article:author"),
"should not emit article:author when no contributors: {tags}"
);
assert!(
!tags.contains("twitter:label1"),
"should not emit twitter:label1 when no contributors: {tags}"
);
}
#[test]
fn build_og_tags_ror_org_array() {
let json = r#"[{"id":"https://ror.org/01234567","name":"State University","type":"Organization"}]"#;
let tags = build_og_tags(json);
assert!(
tags.contains(r#"content="State University""#),
"missing org name: {tags}"
);
assert!(
tags.contains(r#"property="og:type" content="website""#),
"wrong og:type for ROR: {tags}"
);
}
#[tokio::test]
async fn html_response_includes_og_tags() {
let (_dir, app) = app_with_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.1234/test?noredirect")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 4 * 1024 * 1024)
.await
.unwrap();
let html = std::str::from_utf8(&body).unwrap();
assert!(
html.contains(r#"property="og:title""#),
"missing og:title tag in HTML"
);
assert!(
html.contains(r#"property="og:url""#),
"missing og:url tag in HTML"
);
assert!(
html.contains(r#"property="og:type""#),
"missing og:type tag in HTML"
);
assert!(
html.contains("Test Article on Content Negotiation"),
"missing title value in HTML"
);
assert!(
html.contains(r#"name="twitter:card""#),
"missing twitter:card in HTML"
);
}
#[tokio::test]
async fn cached_html_response_includes_og_tags() {
let (_dir, app) = app_with_cache_only();
let response = app
.oneshot(
Request::builder()
.uri("/10.5678/cached?noredirect")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 4 * 1024 * 1024)
.await
.unwrap();
let html = std::str::from_utf8(&body).unwrap();
assert!(
html.contains(r#"property="og:title""#),
"missing og:title tag in cached HTML"
);
assert!(
html.contains("Cached Article"),
"missing cached title value in HTML"
);
}
fn parse_start(args: &[&str]) -> Result<StartArgs, clap::Error> {
use clap::Parser;
Cli::try_parse_from(
std::iter::once("dragoman")
.chain(std::iter::once("start"))
.chain(args.iter().copied()),
)
.map(|cli| match cli.command {
Command::Start(a) => a,
_ => unreachable!(),
})
}
#[test]
fn tls_flags_default_to_none() {
let args = parse_start(&[]).unwrap();
assert!(args.domain.is_none());
assert!(args.email.is_none());
assert!(!args.acme_staging);
}
#[test]
fn works_page_query_defaults_and_clamps() {
let q = |limit, offset| WorksPageQuery { limit, offset }.resolve();
assert_eq!(q(None, None), (WORKS_PAGE_DEFAULT, 0));
assert_eq!(q(Some(0), None), (WORKS_PAGE_DEFAULT, 0));
assert_eq!(q(Some(10), Some(200)), (10, 200));
assert_eq!(q(Some(100_000), None), (WORKS_PAGE_MAX, 0));
assert_eq!(q(Some(WORKS_PAGE_MAX), None), (WORKS_PAGE_MAX, 0));
}
#[test]
fn paginate_fetched_dedupes_orders_and_slices() {
let work = |id: &str, year: &str| {
let mut w = commonmeta::Work::default();
w.id = id.to_string();
w.date_published = year.to_string();
w
};
let mut works = vec![
work("b", "2020"),
work("a", "2024"),
work("b", "2020"),
work("c", "2022"),
];
let (page, total) = paginate_fetched(&mut works, 2, 0);
assert_eq!(total, 3, "the duplicate must not inflate the total");
let ids: Vec<&str> = page.iter().map(|w| w.id.as_str()).collect();
assert_eq!(ids, vec!["a", "c"], "newest first");
let (page2, total2) = paginate_fetched(&mut works, 2, 2);
assert_eq!(total2, 3);
assert_eq!(
page2.iter().map(|w| w.id.as_str()).collect::<Vec<_>>(),
vec!["b"]
);
let (beyond, _) = paginate_fetched(&mut works, 2, 99);
assert!(beyond.is_empty());
}
#[test]
fn acme_flags_parsed() {
let args =
parse_start(&["--domain", "example.org", "--email", "admin@example.org"]).unwrap();
assert_eq!(args.domain.as_deref(), Some("example.org"));
assert_eq!(args.email.as_deref(), Some("admin@example.org"));
assert_eq!(args.tls_port, None);
assert!(!args.acme_staging);
}
#[test]
fn acme_staging_flag() {
let args = parse_start(&[
"--domain",
"example.org",
"--email",
"admin@example.org",
"--acme-staging",
])
.unwrap();
assert!(args.acme_staging);
}
#[test]
fn domain_localhost_no_email_required() {
let args = parse_start(&["--domain", "localhost"]).unwrap();
assert_eq!(args.domain.as_deref(), Some("localhost"));
assert!(
args.email.is_none(),
"email should not be required for localhost"
);
}
#[test]
fn tls_port_override() {
let args = parse_start(&[
"--domain",
"example.org",
"--email",
"admin@example.org",
"--tls-port",
"8443",
])
.unwrap();
assert_eq!(args.tls_port, Some(8443));
}
#[tokio::test]
async fn https_server_starts_with_pem_cert() {
use axum_server::tls_rustls::RustlsConfig;
use rcgen::generate_simple_self_signed;
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
let cert = generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
std::fs::write(&cert_path, cert.cert.pem()).unwrap();
std::fs::write(&key_path, cert.signing_key.serialize_pem()).unwrap();
let config = RustlsConfig::from_pem_file(&cert_path, &key_path)
.await
.expect("RustlsConfig must load from PEM files produced by mkcert");
let tokio_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = tokio_listener.local_addr().unwrap();
let std_listener = tokio_listener.into_std().unwrap();
let handle = axum_server::Handle::new();
let shutdown = handle.clone();
tokio::spawn(async move {
axum_server::from_tcp_rustls(std_listener, config)
.unwrap()
.handle(handle)
.serve(app_no_db().into_make_service())
.await
.unwrap();
});
let mut connected = false;
for _ in 0..40 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if tokio::net::TcpStream::connect(addr).await.is_ok() {
connected = true;
break;
}
}
assert!(
connected,
"HTTPS server must accept TCP connections on the bound port"
);
shutdown.shutdown();
}
fn make_references_db() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = db::tests::make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
(dir, path)
}
fn app_with_references_db() -> (tempfile::TempDir, Router) {
let dir = tempfile::tempdir().unwrap();
let path = db::tests::make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let app = build_app(AppState {
db: Some(Arc::new(path)),
cache: None,
});
(dir, app)
}
#[tokio::test]
async fn count_doi_returns_citation_count() {
let (_dir, app) = app_with_references_db();
let response = app
.oneshot(
Request::builder()
.uri("/api/count?id=https%3A%2F%2Fdoi.org%2F10.5678%2Fcited-z")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["citations"], 3);
assert!(json.get("works").is_none());
}
#[tokio::test]
async fn count_orcid_returns_works_count() {
let (_dir, app) = app_with_references_db();
let response = app
.oneshot(
Request::builder()
.uri("/api/count?id=https%3A%2F%2Forcid.org%2F0000-0002-1825-0097")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["works"], 2);
assert!(json.get("citations").is_none());
}
#[tokio::test]
async fn count_doi_no_db_returns_empty_json() {
let response = app_no_db()
.oneshot(
Request::builder()
.uri("/api/count?id=https%3A%2F%2Fdoi.org%2F10.5678%2Fcited-z")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("citations").is_none());
assert!(json.get("works").is_none());
}
#[tokio::test]
async fn count_missing_id_param_returns_empty_json() {
let (_dir, app) = app_with_references_db();
let response = app
.oneshot(
Request::builder()
.uri("/api/count")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("citations").is_none());
assert!(json.get("works").is_none());
}
fn make_relations_db() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = db::tests::make_test_relations_db(&dir.path().join("r.sqlite3"));
(dir, path)
}
#[test]
fn enrich_relations_no_db_leaves_data_unchanged() {
let data = commonmeta::Work {
id: "https://doi.org/10.5281/zenodo.1000000".to_string(),
..Default::default()
};
let result = enrich_relations(data, &None, &None, "https://doi.org/10.5281/zenodo.1000000");
assert!(result.relations.is_empty());
}
#[test]
fn enrich_relations_adds_source_relations_from_db() {
let (_dir, path) = make_relations_db();
let data = commonmeta::Work {
id: "https://doi.org/10.5281/zenodo.1000000".to_string(),
..Default::default()
};
let result = enrich_relations(
data,
&Some(path),
&None,
"https://doi.org/10.5281/zenodo.1000000",
);
let version_ids: std::collections::HashSet<_> = result
.relations
.iter()
.filter(|r| r.type_ == "HasVersion")
.map(|r| r.id.as_str())
.collect();
assert!(version_ids.contains("https://doi.org/10.5281/zenodo.1000001"));
assert!(version_ids.contains("https://doi.org/10.5281/zenodo.1000002"));
assert!(version_ids.contains("https://doi.org/10.5281/zenodo.9999999"));
}
#[test]
fn enrich_relations_adds_citations_from_db() {
let (_dir, path) = make_references_db();
let data = commonmeta::Work {
id: "https://doi.org/10.5678/cited-z".to_string(),
..Default::default()
};
let result = enrich_relations(data, &Some(path), &None, "10.5678/cited-z");
let mut cite_rels: Vec<_> = result
.relations
.iter()
.filter(|r| r.type_ == "IsReferencedBy")
.map(|r| (r.id.as_str(), r.name.as_str()))
.collect();
cite_rels.sort_by_key(|(id, _)| *id);
assert_eq!(
cite_rels,
vec![
("https://doi.org/10.1234/citing-a", "2023"),
("https://doi.org/10.1234/citing-b", "2024"),
]
);
}
#[test]
fn enrich_relations_deduplicates_existing_relations() {
let (_dir, path) = make_relations_db();
let data = commonmeta::Work {
id: "https://doi.org/10.5281/zenodo.1000000".to_string(),
relations: vec![commonmeta::metadata::Relation {
id: "https://doi.org/10.5281/zenodo.1000001".to_string(),
type_: "HasVersion".to_string(),
..Default::default()
}],
..Default::default()
};
let result = enrich_relations(
data,
&Some(path),
&None,
"https://doi.org/10.5281/zenodo.1000000",
);
let has_versions: Vec<_> = result
.relations
.iter()
.filter(|r| r.type_ == "HasVersion")
.collect();
assert_eq!(
has_versions.len(),
3,
"pre-existing deduplicated; source + target inversions added"
);
let ids: std::collections::HashSet<_> =
has_versions.iter().map(|r| r.id.as_str()).collect();
assert!(ids.contains("https://doi.org/10.5281/zenodo.1000001"));
assert!(ids.contains("https://doi.org/10.5281/zenodo.1000002"));
assert!(ids.contains("https://doi.org/10.5281/zenodo.9999999"));
}
#[test]
fn enrich_relations_table_absent_leaves_data_unchanged() {
let (_dir, db_path) = make_test_db();
let data = commonmeta::Work {
id: "https://doi.org/10.5281/zenodo.1000000".to_string(),
..Default::default()
};
let result = enrich_relations(
data,
&Some((*db_path).clone()),
&None,
"https://doi.org/10.5281/zenodo.1000000",
);
assert!(result.relations.is_empty());
}
#[test]
fn enrich_relations_preserves_existing_non_db_relations() {
let (_dir, path) = make_relations_db();
let data = commonmeta::Work {
id: "https://doi.org/10.5281/zenodo.1000000".to_string(),
relations: vec![commonmeta::metadata::Relation {
id: "https://doi.org/10.5281/zenodo.9999999".to_string(),
type_: "IsVersionOf".to_string(),
..Default::default()
}],
..Default::default()
};
let result = enrich_relations(
data,
&Some(path),
&None,
"https://doi.org/10.5281/zenodo.1000000",
);
assert!(
result.relations.iter().any(|r| r.type_ == "IsVersionOf"),
"existing IsVersionOf must be preserved"
);
assert_eq!(
result
.relations
.iter()
.filter(|r| r.type_ == "HasVersion")
.count(),
3
);
}
fn app_with_containers_db() -> (tempfile::TempDir, Router) {
let dir = tempfile::tempdir().unwrap();
let path = db::tests::make_test_containers_db(&dir.path().join("c.sqlite3"));
let app = build_app(AppState {
db: Some(Arc::new(path)),
cache: None,
});
(dir, app)
}
async fn body_string(response: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8_lossy(&bytes).into_owned()
}
#[tokio::test]
async fn container_issn_html_serves_entity_page() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/2749-9952?noredirect")
.header(header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_string(response).await;
assert!(
body.contains("__DRAGOMAN_INIT__"),
"entity page must embed init data"
);
assert!(
body.contains("Front Matter"),
"container title must be present"
);
}
#[tokio::test]
async fn container_openalex_source_html_serves_entity_page() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/S7407056926?noredirect")
.header(header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(body_string(response).await.contains("Front Matter"));
}
#[tokio::test]
async fn container_doi_resolves_like_any_other_doi() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.53731/front_matter")
.header(header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
assert!(
response.headers().contains_key("location"),
"a resolve must name a target"
);
}
#[tokio::test]
async fn container_works_accepts_limit_and_offset() {
let (_dir, app) = app_with_containers_db();
for query in [
"id=2749-9952",
"id=2749-9952&limit=5",
"id=2749-9952&offset=0",
"id=2749-9952&limit=5&offset=0",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/container/works?{query}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "?{query}");
assert!(
response.headers().contains_key("x-total-count"),
"?{query} should report the total"
);
}
}
#[tokio::test]
async fn container_entity_page_omits_works_but_json_keeps_them() {
let (_dir, app) = app_with_containers_db();
let page = app
.oneshot(
Request::builder()
.uri("/2749-9952?noredirect")
.header(header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(page.status(), StatusCode::OK);
let body = body_string(page).await;
let init = body
.split("__DRAGOMAN_INIT__=")
.nth(1)
.and_then(|s| s.split(";</script>").next())
.expect("entity page embeds its record");
let embedded: serde_json::Value = serde_json::from_str(init).expect("init is json");
assert_eq!(
embedded.as_array().map(|a| a.len()),
Some(1),
"the page should embed the container alone"
);
let (_dir, app) = app_with_containers_db();
let json = app
.oneshot(
Request::builder()
.uri("/2749-9952")
.header(header::ACCEPT, "application/vnd.commonmeta+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(json.status(), StatusCode::OK);
let value: serde_json::Value =
serde_json::from_str(&body_string(json).await).expect("json");
assert!(
value.as_array().map(|a| a.len()).unwrap_or(0) > 1,
"the commonmeta representation still lists the container's works"
);
}
#[tokio::test]
async fn container_doi_with_noredirect_serves_entity_page() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/10.53731/front_matter?noredirect")
.header(header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(body_string(response).await.contains("Front Matter"));
}
#[tokio::test]
async fn container_issn_commonmeta_returns_json() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/2749-9952")
.header(header::ACCEPT, "application/vnd.commonmeta+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"application/vnd.commonmeta+json; charset=utf-8"
);
let body = body_string(response).await;
assert!(body.contains("Front Matter"));
assert!(
body.contains("\"Blog\""),
"Front Matter type must be overridden to Blog"
);
}
#[tokio::test]
async fn container_prefixed_forms_redirect_to_bare_path() {
let (_dir, app) = app_with_containers_db();
for (input, target) in &[
("/issn:2749-9952", "/2749-9952"),
("/openalex:S7407056926", "/S7407056926"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(*input)
.header(header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_redirection(),
"'{input}' should redirect"
);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
*target,
"'{input}' should redirect to '{target}'"
);
}
}
#[tokio::test]
async fn container_works_endpoint_returns_local_works() {
let (_dir, app) = app_with_containers_db();
let response = app
.oneshot(
Request::builder()
.uri("/api/container/works?id=2749-9952")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_string(response).await;
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(arr.as_array().unwrap().len(), 1);
assert_eq!(arr[0]["id"], "https://doi.org/10.53731/article-1");
}
}