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};
pub const CACHE_FORMAT: &str = "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
pub const CACHE_REDIRECT: &str = "public, max-age=0, s-maxage=3600";
pub const CACHE_MISSING: &str = "public, max-age=300";
pub const CACHE_NEVER: &str = "no-store";
pub async fn negotiated(req: Request, next: Next) -> Response {
finish(req, next, true).await
}
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();
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);
}
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))
}
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"))
}
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)
}
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())
})
}
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()
}
fn etag(body: &[u8]) -> String {
let mut hasher = DefaultHasher::new();
hasher.write(body);
format!("W/\"{:016x}\"", hasher.finish())
}
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")
);
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());
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);
assert_eq!(header(&get_with("/missing", &[]).await, header::ETAG), None);
}
}