Skip to main content

static_serve/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::convert::Infallible;
4
5use axum::{
6    Router,
7    extract::FromRequestParts,
8    http::{
9        StatusCode,
10        header::{
11            ACCEPT_ENCODING, ACCEPT_RANGES, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, ETAG,
12            HeaderValue, IF_NONE_MATCH, VARY,
13        },
14        request::Parts,
15    },
16    response::IntoResponse,
17    routing::{MethodRouter, get},
18};
19use bytes::Bytes;
20use range_requests::{headers::range::HttpRange, serve_file_with_http_range};
21
22pub use static_serve_macro::{embed_asset, embed_assets};
23
24/// The accept/reject status for gzip and zstd encoding
25#[derive(Debug, Copy, Clone)]
26struct AcceptEncoding {
27    /// Is gzip accepted?
28    pub gzip: bool,
29    /// Is zstd accepted?
30    pub zstd: bool,
31}
32
33impl<S> FromRequestParts<S> for AcceptEncoding
34where
35    S: Send + Sync,
36{
37    type Rejection = Infallible;
38
39    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
40        let accept_encoding = parts.headers.get(ACCEPT_ENCODING);
41        let accept_encoding = accept_encoding
42            .and_then(|accept_encoding| accept_encoding.to_str().ok())
43            .unwrap_or_default();
44
45        Ok(Self {
46            gzip: accept_encoding.contains("gzip"),
47            zstd: accept_encoding.contains("zstd"),
48        })
49    }
50}
51
52/// Check if the  `IfNoneMatch` header is present
53#[derive(Debug)]
54struct IfNoneMatch(Option<HeaderValue>);
55
56impl IfNoneMatch {
57    /// required function for checking if `IfNoneMatch` is present
58    fn matches(&self, etag: &str) -> bool {
59        self.0
60            .as_ref()
61            .is_some_and(|if_none_match| if_none_match.as_bytes() == etag.as_bytes())
62    }
63}
64
65impl<S> FromRequestParts<S> for IfNoneMatch
66where
67    S: Send + Sync,
68{
69    type Rejection = Infallible;
70
71    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
72        let if_none_match = parts.headers.get(IF_NONE_MATCH).cloned();
73        Ok(Self(if_none_match))
74    }
75}
76
77#[doc(hidden)]
78#[expect(clippy::too_many_arguments)]
79/// The router for adding routes for static assets
80pub fn static_route<S>(
81    router: Router<S>,
82    web_path: &'static str,
83    content_type: &'static str,
84    etag: &'static str,
85    body: &'static [u8],
86    body_gz: Option<&'static [u8]>,
87    body_zst: Option<&'static [u8]>,
88    cache_busted: bool,
89) -> Router<S>
90where
91    S: Clone + Send + Sync + 'static,
92{
93    router.route(
94        web_path,
95        get(
96            move |accept_encoding: AcceptEncoding,
97                  if_none_match: IfNoneMatch,
98                  http_range: Option<HttpRange>| async move {
99                static_inner(StaticInnerData {
100                    content_type,
101                    etag,
102                    body,
103                    body_gz,
104                    body_zst,
105                    cache_busted,
106                    accept_encoding,
107                    if_none_match,
108                    http_range,
109                })
110            },
111        ),
112    )
113}
114
115#[doc(hidden)]
116/// Creates a route for a single static asset.
117///
118/// Used by the `embed_asset!` macro, so it needs to be `pub`.
119pub fn static_method_router<S>(
120    content_type: &'static str,
121    etag: &'static str,
122    body: &'static [u8],
123    body_gz: Option<&'static [u8]>,
124    body_zst: Option<&'static [u8]>,
125    cache_busted: bool,
126) -> MethodRouter<S>
127where
128    S: Clone + Send + Sync + 'static,
129{
130    MethodRouter::get(
131        MethodRouter::new(),
132        move |accept_encoding: AcceptEncoding,
133              if_none_match: IfNoneMatch,
134              http_range: Option<HttpRange>| async move {
135            static_inner(StaticInnerData {
136                content_type,
137                etag,
138                body,
139                body_gz,
140                body_zst,
141                cache_busted,
142                accept_encoding,
143                if_none_match,
144                http_range,
145            })
146        },
147    )
148}
149
150/// Struct of parameters for `static_inner` (to avoid `clippy::too_many_arguments`)
151///
152/// This differs from `StaticRouteData` because it
153/// includes the `AcceptEncoding` and `IfNoneMatch` fields
154/// and excludes the `web_path`
155struct StaticInnerData {
156    content_type: &'static str,
157    etag: &'static str,
158    body: &'static [u8],
159    body_gz: Option<&'static [u8]>,
160    body_zst: Option<&'static [u8]>,
161    cache_busted: bool,
162    accept_encoding: AcceptEncoding,
163    if_none_match: IfNoneMatch,
164    http_range: Option<HttpRange>,
165}
166
167fn static_inner(static_inner_data: StaticInnerData) -> impl IntoResponse {
168    let StaticInnerData {
169        content_type,
170        etag,
171        body,
172        body_gz,
173        body_zst,
174        cache_busted,
175        accept_encoding,
176        if_none_match,
177        http_range,
178    } = static_inner_data;
179
180    let optional_cache_control = if cache_busted {
181        Some([(
182            CACHE_CONTROL,
183            HeaderValue::from_static("public, max-age=31536000, immutable"),
184        )])
185    } else {
186        None
187    };
188
189    let resp_base = (
190        [
191            (CONTENT_TYPE, HeaderValue::from_static(content_type)),
192            (ETAG, HeaderValue::from_static(etag)),
193            (VARY, HeaderValue::from_static("Accept-Encoding")),
194        ],
195        optional_cache_control,
196    );
197
198    if if_none_match.matches(etag) {
199        return (resp_base, StatusCode::NOT_MODIFIED).into_response();
200    }
201
202    let resp_base = (
203        [(ACCEPT_RANGES, HeaderValue::from_static("bytes"))],
204        resp_base,
205    );
206    let (selected_body, optional_content_encoding) = match (
207        (accept_encoding.gzip, body_gz),
208        (accept_encoding.zstd, body_zst),
209        &http_range,
210    ) {
211        (_, (true, Some(body_zst)), None) => (
212            Bytes::from_static(body_zst),
213            Some([(CONTENT_ENCODING, HeaderValue::from_static("zstd"))]),
214        ),
215        ((true, Some(body_gz)), _, None) => (
216            Bytes::from_static(body_gz),
217            Some([(CONTENT_ENCODING, HeaderValue::from_static("gzip"))]),
218        ),
219        _ => (Bytes::from_static(body), None),
220    };
221
222    if selected_body.is_empty() {
223        // Empty bodies cannot be range-served; return the full (empty) response.
224        return (resp_base, optional_content_encoding, selected_body).into_response();
225    }
226
227    match serve_file_with_http_range(selected_body, http_range) {
228        Ok(body_range) => (resp_base, optional_content_encoding, body_range).into_response(),
229        Err(unsatisfiable) => (resp_base, unsatisfiable).into_response(),
230    }
231}