Skip to main content

tower_serve_embedded/
service.rs

1use std::convert::Infallible;
2use std::future::{Ready, ready};
3use std::task::{Context, Poll};
4
5use bytes::Bytes;
6use http::header::{ALLOW, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
7use http::{HeaderValue, Method, Request, Response, StatusCode};
8use http_body_util::Full;
9use tower_service::Service;
10
11use crate::Assets;
12
13/// A [`tower::Service`](tower_service::Service) that serves a set of [`Assets`].
14///
15/// Construct one with [`Assets::service`]. Each embedded file is served at its full generated URL
16/// (mirroring the file's location under your crate root): a cache-busted path for ordinary assets,
17/// or the stable, non-hashed path for `immutable_dir` assets. Mount it as a fallback rather than
18/// nesting it under a prefix:
19///
20/// ```ignore
21/// Router::new().fallback_service(ASSETS.service())
22/// ```
23///
24/// It handles `GET`/`HEAD` (other methods get `405`), conditional requests via `If-None-Match`
25/// (`304 Not Modified`), and sets `Content-Type` and a strong `ETag`. Generated routes get a
26/// one-year `immutable` `Cache-Control`. It is intentionally lean: no precompression, directory
27/// index, or SPA fallback — compose those with other `tower` layers if you need them.
28#[derive(Clone, Copy, Debug)]
29pub struct ServeEmbedded {
30    assets: &'static Assets,
31}
32
33impl ServeEmbedded {
34    pub(crate) fn new(assets: &'static Assets) -> Self {
35        Self { assets }
36    }
37
38    fn respond(
39        &self,
40        method: &Method,
41        path: &str,
42        if_none_match: Option<&HeaderValue>,
43    ) -> Response<Full<Bytes>> {
44        if method != Method::GET && method != Method::HEAD {
45            return Response::builder()
46                .status(StatusCode::METHOD_NOT_ALLOWED)
47                .header(ALLOW, HeaderValue::from_static("GET, HEAD"))
48                .body(empty())
49                .unwrap();
50        }
51
52        let Some(resolved) = self.assets.resolve(path) else {
53            return Response::builder()
54                .status(StatusCode::NOT_FOUND)
55                .body(empty())
56                .unwrap();
57        };
58        let file = resolved.file;
59
60        if if_none_match.is_some_and(|inm| etag_matches(inm.as_bytes(), file.etag)) {
61            let mut builder = Response::builder()
62                .status(StatusCode::NOT_MODIFIED)
63                .header(ETAG, file.etag);
64            if let Some(cache_control) = resolved.cache_control {
65                builder = builder.header(CACHE_CONTROL, cache_control);
66            }
67            return builder.body(empty()).unwrap();
68        }
69
70        let body = if method == Method::HEAD {
71            empty()
72        } else {
73            Full::new(Bytes::from_static(file.bytes))
74        };
75
76        let mut builder = Response::builder()
77            .status(StatusCode::OK)
78            .header(CONTENT_TYPE, file.content_type)
79            .header(ETAG, file.etag);
80        if let Some(cache_control) = resolved.cache_control {
81            builder = builder.header(CACHE_CONTROL, cache_control);
82        }
83        builder.body(body).unwrap()
84    }
85}
86
87impl<B> Service<Request<B>> for ServeEmbedded {
88    type Response = Response<Full<Bytes>>;
89    type Error = Infallible;
90    type Future = Ready<Result<Self::Response, Self::Error>>;
91
92    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
93        Poll::Ready(Ok(()))
94    }
95
96    fn call(&mut self, req: Request<B>) -> Self::Future {
97        let resp = self.respond(
98            req.method(),
99            req.uri().path(),
100            req.headers().get(IF_NONE_MATCH),
101        );
102        ready(Ok(resp))
103    }
104}
105
106fn empty() -> Full<Bytes> {
107    Full::new(Bytes::new())
108}
109
110/// Does an `If-None-Match` header value match `etag`?
111///
112/// Handles `*`, comma-separated lists, and weak (`W/"…"`) comparators. Our ETags are strong, so
113/// a weak/strong tag with the same opaque value still counts as a match (weak comparison is the
114/// correct semantics for `If-None-Match`).
115fn etag_matches(if_none_match: &[u8], etag: &str) -> bool {
116    let Ok(header) = std::str::from_utf8(if_none_match) else {
117        return false;
118    };
119    header.split(',').any(|candidate| {
120        let c = candidate.trim();
121        c == "*" || c == etag || c.strip_prefix("W/").is_some_and(|weak| weak == etag)
122    })
123}
124
125#[cfg(test)]
126mod tests {
127    use super::etag_matches;
128
129    #[test]
130    fn matches_exact_and_wildcard_and_weak() {
131        assert!(etag_matches(b"\"abc\"", "\"abc\""));
132        assert!(etag_matches(b"*", "\"abc\""));
133        assert!(etag_matches(b"W/\"abc\"", "\"abc\""));
134        assert!(etag_matches(b"\"x\", \"abc\", \"y\"", "\"abc\""));
135        assert!(!etag_matches(b"\"nope\"", "\"abc\""));
136        assert!(!etag_matches(b"", "\"abc\""));
137    }
138}