dragoman 0.5.12

Server for scholarly metadata in commonmeta format stored in a SQLite database.
//! Cache headers for the pid routes, so browsers and CDNs can keep what they
//! serve.
//!
//! A pid URL answers by its Accept header: the page for a browser, a redirect
//! to the landing page, BibTeX for a client asking for BibTeX. So every answer
//! from `/{pid}` and `/api/handles/{pid}` — the redirects included — says
//! `Vary: Accept`, unless `?format=` overrides the header. That is the safety
//! net; the URLs a cache handles best are the ones keyed by URL alone
//! (`?format=`, `/dois/`, `/works/`), and a negotiated answer names its own in
//! `Content-Location`.
//!
//! Lifetimes follow the status. A format response is kept an hour by a browser
//! and a day by a shared cache, which may serve it stale for another day while
//! it revalidates. A temporary redirect — a DOI to its landing page — is kept
//! an hour by a shared cache, so a CDN answers repeat clicks, while a browser
//! revalidates every time. A 404 or 406 is kept five minutes. A 5xx — most often
//! Crossref or DataCite failing — is not kept at all. Entity pages keep the
//! `no-cache` their handler sets, and every 200 carries an ETag of its body, so
//! revalidating costs a 304 instead of the page.

use std::hash::{DefaultHasher, Hasher};

use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};

use crate::negotiate::{self, NegotiateResult};

/// A metadata record in a format: an hour in a browser, a day in a CDN.
pub const CACHE_FORMAT: &str = "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
/// A temporary redirect: an hour in a CDN, revalidated by a browser each time,
/// since where a DOI resolves to is the registrant's to move.
pub const CACHE_REDIRECT: &str = "public, max-age=0, s-maxage=3600";
/// Nothing found under this pid, or not in that format, for now.
pub const CACHE_MISSING: &str = "public, max-age=300";
/// A failure, here or upstream, that the next request may not meet.
pub const CACHE_NEVER: &str = "no-store";

/// Cache headers for a route that answers by its Accept header.
pub async fn negotiated(req: Request, next: Next) -> Response {
    finish(req, next, true).await
}

/// Cache headers for a route keyed by its URL alone.
pub async fn by_url(req: Request, next: Next) -> Response {
    finish(req, next, false).await
}

async fn finish(req: Request, next: Next, varies: bool) -> Response {
    let if_none_match = req.headers().get(header::IF_NONE_MATCH).cloned();
    // `?format=` overrides the Accept header, so the answer no longer varies.
    let varies = varies && !names_format(req.uri().query());
    let location = varies.then(|| format_url(&req)).flatten();
    let (mut parts, body) = next.run(req).await.into_parts();

    if varies {
        parts
            .headers
            .append(header::VARY, HeaderValue::from_static("accept"));
    }
    if !parts.headers.contains_key(header::CACHE_CONTROL)
        && let Some(policy) = lifetime(parts.status)
    {
        parts
            .headers
            .insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
    }
    if parts.status != StatusCode::OK {
        return Response::from_parts(parts, body);
    }
    if let Some(location) = location
        && !is_html(&parts.headers)
        && let Ok(value) = HeaderValue::from_str(&location)
    {
        parts.headers.insert(header::CONTENT_LOCATION, value);
    }

    // Every body on these routes is built whole before it is sent, so reading
    // it back costs a copy, not a stream.
    let bytes = match axum::body::to_bytes(body, usize::MAX).await {
        Ok(bytes) => bytes,
        Err(e) => {
            tracing::error!(error = %e, "reading response body for its ETag");
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
        }
    };
    let tag = etag(&bytes);
    let fresh = if_none_match.is_some_and(|v| matches(&v, &tag));
    parts.headers.insert(
        header::ETAG,
        HeaderValue::from_str(&tag).expect("an ETag is ASCII"),
    );
    if fresh {
        parts.status = StatusCode::NOT_MODIFIED;
        parts.headers.remove(header::CONTENT_TYPE);
        parts.headers.remove(header::CONTENT_LENGTH);
        return Response::from_parts(parts, Body::empty());
    }
    Response::from_parts(parts, Body::from(bytes))
}

/// The lifetime a status gets when its handler set none. A permanent redirect
/// gets none: a browser keeps one anyway.
fn lifetime(status: StatusCode) -> Option<&'static str> {
    match status.as_u16() {
        200 => Some(CACHE_FORMAT),
        302 | 307 => Some(CACHE_REDIRECT),
        404 | 406 => Some(CACHE_MISSING),
        500..=599 => Some(CACHE_NEVER),
        _ => None,
    }
}

fn is_html(headers: &HeaderMap) -> bool {
    headers
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|ct| ct.starts_with("text/html"))
}

/// The URL that serves, by URL alone, what this request's Accept header asked
/// for: the same path and query with `format=` (and a citation's `style=` and
/// `locale=`). `None` when the Accept header asks for no format.
fn format_url(req: &Request) -> Option<String> {
    let query = req.uri().query().unwrap_or("");
    let accept = req.headers().get(header::ACCEPT)?.to_str().ok()?;
    let NegotiateResult::Format(negotiated) = negotiate::negotiate(accept) else {
        return None;
    };
    let mut url = format!("{}?", req.uri().path());
    if !query.is_empty() {
        url.push_str(query);
        url.push('&');
    }
    url.push_str("format=");
    url.push_str(negotiated.format);
    for (key, value) in [("style", &negotiated.style), ("locale", &negotiated.locale)] {
        if let Some(value) = value {
            url.push_str(&format!("&{key}={}", encode(value)));
        }
    }
    Some(url)
}

/// Whether a query names a format, which then overrides the Accept header.
fn names_format(query: Option<&str>) -> bool {
    query.unwrap_or("").split('&').any(|pair| {
        pair.split_once('=')
            .is_some_and(|(k, v)| k == "format" && !v.is_empty())
    })
}

/// Percent-encode everything but the unreserved characters.
fn encode(value: &str) -> String {
    value
        .bytes()
        .map(|b| match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                (b as char).to_string()
            }
            _ => format!("%{b:02X}"),
        })
        .collect()
}

/// A weak ETag of the body: weak, because the compression layer outside this
/// one may send the same body in another encoding.
fn etag(body: &[u8]) -> String {
    let mut hasher = DefaultHasher::new();
    hasher.write(body);
    format!("W/\"{:016x}\"", hasher.finish())
}

/// Whether an If-None-Match header names `tag` (compared weakly, as RFC 9110
/// asks of If-None-Match), or is `*`.
fn matches(if_none_match: &HeaderValue, tag: &str) -> bool {
    let bare = |t: &str| t.trim().trim_start_matches("W/").to_string();
    let tag = bare(tag);
    if_none_match
        .to_str()
        .unwrap_or("")
        .split(',')
        .any(|t| t.trim() == "*" || bare(t) == tag)
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{Router, routing::get};
    use tower::ServiceExt;

    fn app() -> Router {
        Router::new().route(
            "/{*path}",
            get(|req: Request| async move {
                match req.uri().path() {
                    "/missing" => StatusCode::NOT_FOUND.into_response(),
                    "/upstream" => StatusCode::BAD_GATEWAY.into_response(),
                    "/landing" => (
                        StatusCode::FOUND,
                        [(header::LOCATION, "https://example.org/")],
                    )
                        .into_response(),
                    "/page" => (
                        [
                            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
                            (header::CACHE_CONTROL, "no-cache"),
                        ],
                        "<html></html>",
                    )
                        .into_response(),
                    _ => (
                        [(header::CONTENT_TYPE, "application/x-bibtex")],
                        "@article{}",
                    )
                        .into_response(),
                }
            })
            .layer(axum::middleware::from_fn(negotiated)),
        )
    }

    async fn get_with(uri: &str, headers: &[(&'static str, &str)]) -> Response {
        let mut req = Request::builder().uri(uri);
        for (k, v) in headers {
            req = req.header(*k, *v);
        }
        app()
            .oneshot(req.body(Body::empty()).unwrap())
            .await
            .unwrap()
    }

    fn header(response: &Response, name: header::HeaderName) -> Option<&str> {
        response.headers().get(name).and_then(|v| v.to_str().ok())
    }

    #[tokio::test]
    async fn every_answer_varies_by_accept_and_gets_a_lifetime_for_its_status() {
        let cases: &[(&str, StatusCode, Option<&str>)] = &[
            ("/10.5555/1", StatusCode::OK, Some(CACHE_FORMAT)),
            ("/page", StatusCode::OK, Some("no-cache")),
            ("/landing", StatusCode::FOUND, Some(CACHE_REDIRECT)),
            ("/missing", StatusCode::NOT_FOUND, Some(CACHE_MISSING)),
            ("/upstream", StatusCode::BAD_GATEWAY, Some(CACHE_NEVER)),
        ];
        for (uri, status, cache) in cases {
            let response = get_with(uri, &[]).await;
            assert_eq!(response.status(), *status, "{uri}");
            assert_eq!(header(&response, header::VARY), Some("accept"), "{uri}");
            assert_eq!(header(&response, header::CACHE_CONTROL), *cache, "{uri}");
        }
    }

    #[tokio::test]
    async fn a_negotiated_format_names_the_url_that_serves_it() {
        let response = get_with(
            "/10.5555/1?source=crossref",
            &[("accept", "text/x-bibliography; style=apa; locale=fr-FR")],
        )
        .await;
        assert_eq!(
            header(&response, header::CONTENT_LOCATION),
            Some("/10.5555/1?source=crossref&format=citation&style=apa&locale=fr-FR")
        );
        // Already keyed by URL, whatever the Accept header says: nothing to
        // point to, and nothing to vary on.
        let by_url = get_with(
            "/10.5555/1?format=bibtex",
            &[("accept", "application/vnd.citationstyles.csl+json")],
        )
        .await;
        assert_eq!(header(&by_url, header::CONTENT_LOCATION), None);
        assert_eq!(header(&by_url, header::VARY), None);
        assert_eq!(header(&by_url, header::CACHE_CONTROL), Some(CACHE_FORMAT));
        let page = get_with("/page", &[("accept", "application/x-bibtex")]).await;
        assert_eq!(header(&page, header::CONTENT_LOCATION), None);
    }

    #[tokio::test]
    async fn a_matching_etag_is_answered_with_304() {
        let first = get_with("/page", &[]).await;
        let tag = header(&first, header::ETAG).unwrap().to_string();
        assert!(tag.starts_with("W/\""));

        let again = get_with("/page", &[("if-none-match", &tag)]).await;
        assert_eq!(again.status(), StatusCode::NOT_MODIFIED);
        assert_eq!(header(&again, header::ETAG), Some(tag.as_str()));
        assert_eq!(header(&again, header::VARY), Some("accept"));
        let body = axum::body::to_bytes(again.into_body(), usize::MAX)
            .await
            .unwrap();
        assert!(body.is_empty());

        // A strong form of the same tag matches too; another tag does not.
        let strong = tag.trim_start_matches("W/");
        let again = get_with("/page", &[("if-none-match", strong)]).await;
        assert_eq!(again.status(), StatusCode::NOT_MODIFIED);
        let other = get_with("/page", &[("if-none-match", "W/\"0\"")]).await;
        assert_eq!(other.status(), StatusCode::OK);
        // Errors carry no ETag.
        assert_eq!(header(&get_with("/missing", &[]).await, header::ETAG), None);
    }
}