static_serve/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::convert::Infallible;
4
5use axum::{
6    extract::FromRequestParts,
7    http::{
8        header::{
9            HeaderValue, ACCEPT_ENCODING, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, ETAG,
10            IF_NONE_MATCH, VARY,
11        },
12        request::Parts,
13        StatusCode,
14    },
15    response::IntoResponse,
16    routing::{get, MethodRouter},
17    Router,
18};
19use bytes::Bytes;
20
21pub use static_serve_macro::{embed_asset, embed_assets};
22
23/// The accept/reject status for gzip and zstd encoding
24#[derive(Debug, Copy, Clone)]
25struct AcceptEncoding {
26    /// Is gzip accepted?
27    pub gzip: bool,
28    /// Is zstd accepted?
29    pub zstd: bool,
30}
31
32impl<S> FromRequestParts<S> for AcceptEncoding
33where
34    S: Send + Sync,
35{
36    type Rejection = Infallible;
37
38    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
39        let accept_encoding = parts.headers.get(ACCEPT_ENCODING);
40        let accept_encoding = accept_encoding
41            .and_then(|accept_encoding| accept_encoding.to_str().ok())
42            .unwrap_or_default();
43
44        Ok(Self {
45            gzip: accept_encoding.contains("gzip"),
46            zstd: accept_encoding.contains("zstd"),
47        })
48    }
49}
50
51/// Check if the  `IfNoneMatch` header is present
52#[derive(Debug)]
53struct IfNoneMatch(Option<HeaderValue>);
54
55impl IfNoneMatch {
56    /// required function for checking if `IfNoneMatch` is present
57    fn matches(&self, etag: &str) -> bool {
58        self.0
59            .as_ref()
60            .is_some_and(|if_none_match| if_none_match.as_bytes() == etag.as_bytes())
61    }
62}
63
64impl<S> FromRequestParts<S> for IfNoneMatch
65where
66    S: Send + Sync,
67{
68    type Rejection = Infallible;
69
70    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
71        let if_none_match = parts.headers.get(IF_NONE_MATCH).cloned();
72        Ok(Self(if_none_match))
73    }
74}
75
76#[doc(hidden)]
77/// The router for adding routes for static assets
78pub fn static_route<S>(
79    router: Router<S>,
80    web_path: &'static str,
81    content_type: &'static str,
82    etag: &'static str,
83    body: &'static [u8],
84    body_gz: Option<&'static [u8]>,
85    body_zst: Option<&'static [u8]>,
86) -> Router<S>
87where
88    S: Clone + Send + Sync + 'static,
89{
90    router.route(
91        web_path,
92        get(
93            move |accept_encoding: AcceptEncoding, if_none_match: IfNoneMatch| async move {
94                static_inner(
95                    content_type,
96                    etag,
97                    body,
98                    body_gz,
99                    body_zst,
100                    accept_encoding,
101                    &if_none_match,
102                )
103            },
104        ),
105    )
106}
107
108#[doc(hidden)]
109/// Creates a route for a single static asset
110pub fn static_method_router(
111    content_type: &'static str,
112    etag: &'static str,
113    body: &'static [u8],
114    body_gz: Option<&'static [u8]>,
115    body_zst: Option<&'static [u8]>,
116) -> MethodRouter {
117    MethodRouter::get(
118        MethodRouter::new(),
119        move |accept_encoding: AcceptEncoding, if_none_match: IfNoneMatch| async move {
120            static_inner(
121                content_type,
122                etag,
123                body,
124                body_gz,
125                body_zst,
126                accept_encoding,
127                &if_none_match,
128            )
129        },
130    )
131}
132
133fn static_inner(
134    content_type: &'static str,
135    etag: &'static str,
136    body: &'static [u8],
137    body_gz: Option<&'static [u8]>,
138    body_zst: Option<&'static [u8]>,
139    accept_encoding: AcceptEncoding,
140    if_none_match: &IfNoneMatch,
141) -> impl IntoResponse {
142    let headers_base = [
143        (CONTENT_TYPE, HeaderValue::from_static(content_type)),
144        (ETAG, HeaderValue::from_static(etag)),
145        (
146            CACHE_CONTROL,
147            HeaderValue::from_static("public, max-age=31536000, immutable"),
148        ),
149        (VARY, HeaderValue::from_static("Accept-Encoding")),
150    ];
151
152    match (
153        if_none_match.matches(etag),
154        accept_encoding.gzip,
155        accept_encoding.zstd,
156        body_gz,
157        body_zst,
158    ) {
159        (true, _, _, _, _) => (headers_base, StatusCode::NOT_MODIFIED).into_response(),
160        (false, _, true, _, Some(body_zst)) => (
161            headers_base,
162            [(CONTENT_ENCODING, HeaderValue::from_static("zstd"))],
163            Bytes::from_static(body_zst),
164        )
165            .into_response(),
166        (false, true, _, Some(body_gz), _) => (
167            headers_base,
168            [(CONTENT_ENCODING, HeaderValue::from_static("gzip"))],
169            Bytes::from_static(body_gz),
170        )
171            .into_response(),
172        _ => (headers_base, Bytes::from_static(body)).into_response(),
173    }
174}