Skip to main content

aro_web/
cache.rs

1//! `ETag` and conditional response helpers.
2//!
3//! Provides an [`IfNoneMatch`] extractor for the `If-None-Match` request
4//! header, a [`conditional`] helper that returns 304 when the `ETag` matches,
5//! and a [`with_etag`] helper to attach an `ETag` header to a response.
6
7use axum::extract::FromRequestParts;
8use axum::http::request::Parts;
9use axum::http::{HeaderValue, StatusCode, header};
10use axum::response::{IntoResponse, Response};
11
12/// Extractor for the `If-None-Match` request header.
13///
14/// Always succeeds — contains `None` when the header is absent.
15pub struct IfNoneMatch(pub Option<String>);
16
17impl<S> FromRequestParts<S> for IfNoneMatch
18where
19    S: Send + Sync,
20{
21    type Rejection = std::convert::Infallible;
22
23    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
24        let value = parts
25            .headers
26            .get(header::IF_NONE_MATCH)
27            .and_then(|v| v.to_str().ok())
28            .map(ToOwned::to_owned);
29        Ok(Self(value))
30    }
31}
32
33/// Returns `Some(304 Not Modified)` if `etag` matches the client's
34/// `If-None-Match` value. Returns `None` otherwise, signalling the
35/// caller should send the full response.
36#[expect(
37    clippy::expect_used,
38    reason = "ETag value is from trusted internal source"
39)]
40pub fn conditional(etag: &str, if_none_match: &IfNoneMatch) -> Option<Response> {
41    let client_etag = if_none_match.0.as_deref()?;
42    if client_etag == etag {
43        let mut response = StatusCode::NOT_MODIFIED.into_response();
44        response.headers_mut().insert(
45            header::ETAG,
46            HeaderValue::from_str(etag).expect("valid ETag header value"),
47        );
48        Some(response)
49    } else {
50        None
51    }
52}
53
54/// Attaches an `ETag` header to a response.
55#[expect(
56    clippy::expect_used,
57    reason = "ETag value is from trusted internal source"
58)]
59pub fn with_etag<T: IntoResponse>(response: T, etag: &str) -> Response {
60    let mut response = response.into_response();
61    response.headers_mut().insert(
62        header::ETAG,
63        HeaderValue::from_str(etag).expect("valid ETag header value"),
64    );
65    response
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn conditional_returns_304_on_match() {
74        let inm = IfNoneMatch(Some("\"abc123\"".to_owned()));
75        let resp = conditional("\"abc123\"", &inm);
76        assert!(resp.is_some());
77        let resp = resp.unwrap();
78        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
79        assert_eq!(
80            resp.headers().get(header::ETAG).unwrap().to_str().unwrap(),
81            "\"abc123\""
82        );
83    }
84
85    #[test]
86    fn conditional_returns_none_on_mismatch() {
87        let inm = IfNoneMatch(Some("\"old\"".to_owned()));
88        assert!(conditional("\"new\"", &inm).is_none());
89    }
90
91    #[test]
92    fn conditional_returns_none_when_header_absent() {
93        let inm = IfNoneMatch(None);
94        assert!(conditional("\"abc\"", &inm).is_none());
95    }
96
97    #[test]
98    fn with_etag_sets_header() {
99        let resp = with_etag("hello", "\"tag1\"");
100        assert_eq!(
101            resp.headers().get(header::ETAG).unwrap().to_str().unwrap(),
102            "\"tag1\""
103        );
104    }
105}