Skip to main content

static_serve/
lib.rs

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