Skip to main content

trillium_static_compiled/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(
3    clippy::dbg_macro,
4    missing_copy_implementations,
5    rustdoc::missing_crate_level_docs,
6    missing_debug_implementations,
7    missing_docs,
8    nonstandard_style,
9    unused_qualifications
10)]
11
12//! Serves static file assets from memory, as included in the binary at
13//! compile time. Because this includes file system content at compile
14//! time, it requires a macro interface, [`static_compiled`].
15//!
16//! If the root is a directory, it will recursively serve any files
17//! relative to the path that this handler is mounted at, or an index file
18//! if one is configured with
19//! [`with_index_file`](crate::StaticCompiledHandler::with_index_file).
20//!
21//! If the root is a file, it will serve that file at all request paths.
22//!
23//! This crate contains code from [`include_dir`][include_dir], but with
24//! several tweaks to make it more suitable for this specific use case.
25//!
26//! [include_dir]:https://docs.rs/include_dir/latest/include_dir/
27//!
28//! ```
29//! use trillium_static_compiled::static_compiled;
30//! use trillium_testing::TestServer;
31//!
32//! # trillium_testing::block_on(async {
33//! let handler = static_compiled!("./examples/files").with_index_file("index.html");
34//!
35//! // given the following directory layout
36//! //
37//! // examples/files
38//! // ├── index.html
39//! // ├── subdir
40//! // │  └── index.html
41//! // └── subdir_with_no_index
42//! //    └── plaintext.txt
43//!
44//! let app = TestServer::new(handler).await;
45//!
46//! let index = include_str!("../examples/files/index.html");
47//! app.get("/")
48//!     .await
49//!     .assert_ok()
50//!     .assert_body(index)
51//!     .assert_header("content-type", "text/html");
52//!
53//! app.get("/file_that_does_not_exist.txt")
54//!     .await
55//!     .assert_status(404);
56//! app.get("/index.html").await.assert_ok();
57//!
58//! app.get("/subdir/index.html")
59//!     .await
60//!     .assert_ok()
61//!     .assert_body("subdir index.html 🎈\n")
62//!     .assert_header("content-type", "text/html; charset=utf-8");
63//!
64//! app.get("/subdir")
65//!     .await
66//!     .assert_ok()
67//!     .assert_body("subdir index.html 🎈\n");
68//!
69//! app.get("/subdir_with_no_index").await.assert_status(404);
70//!
71//! app.get("/subdir_with_no_index/plaintext.txt")
72//!     .await
73//!     .assert_ok()
74//!     .assert_body("plaintext file\n")
75//!     .assert_header("content-type", "text/plain");
76//!
77//! // with a different index file
78//! let plaintext_index = static_compiled!("./examples/files").with_index_file("plaintext.txt");
79//! let app2 = TestServer::new(plaintext_index).await;
80//!
81//! app2.get("/").await.assert_status(404);
82//! app2.get("/subdir").await.assert_status(404);
83//!
84//! app2.get("/subdir_with_no_index")
85//!     .await
86//!     .assert_ok()
87//!     .assert_body("plaintext file\n")
88//!     .assert_header("content-type", "text/plain");
89//!
90//! // with no index file
91//! let no_index = static_compiled!("./examples/files");
92//! let app3 = TestServer::new(no_index).await;
93//!
94//! app3.get("/").await.assert_status(404);
95//! app3.get("/subdir").await.assert_status(404);
96//! app3.get("/subdir_with_no_index").await.assert_status(404);
97//! # });
98//! ```
99
100#[cfg(test)]
101#[doc = include_str!("../README.md")]
102mod readme {}
103
104use trillium::{
105    Conn, Handler,
106    KnownHeaderName::{ContentType, LastModified},
107};
108
109mod dir;
110mod dir_entry;
111mod file;
112mod metadata;
113
114pub(crate) use crate::{dir::Dir, dir_entry::DirEntry, file::File, metadata::Metadata};
115
116#[doc(hidden)]
117pub mod __macro_internals {
118    pub use crate::{dir::Dir, dir_entry::DirEntry, file::File, metadata::Metadata};
119    pub use trillium_static_compiled_macros::{include_dir, include_entry};
120}
121
122/// The static compiled handler which contains the compile-time loaded
123/// assets
124#[derive(Debug, Clone, Copy)]
125pub struct StaticCompiledHandler {
126    root: DirEntry,
127    index_file: Option<&'static str>,
128}
129
130impl StaticCompiledHandler {
131    /// Constructs a new StaticCompiledHandler. This must be used in
132    /// conjunction with [`root!`](crate::root). See crate-level docs for
133    /// example usage.
134    pub fn new(root: DirEntry) -> Self {
135        Self {
136            root,
137            index_file: None,
138        }
139    }
140
141    /// Configures the optional index file for this
142    /// StaticCompiledHandler. See the crate-level docs for example
143    /// usage.
144    pub fn with_index_file(mut self, file: &'static str) -> Self {
145        if let Some(file) = self.root.as_file() {
146            panic!(
147                "root is a file ({:?}), with_index_file is not meaningful.",
148                file.path()
149            );
150        }
151        self.index_file = Some(file);
152        self
153    }
154
155    fn serve_file(&self, mut conn: Conn, file: File) -> Conn {
156        let mime = mime_guess::from_path(file.path()).first_or_text_plain();
157
158        let is_ascii = file.contents().is_ascii();
159        let is_text = matches!(
160            (mime.type_(), mime.subtype()),
161            (mime::APPLICATION, mime::JAVASCRIPT) | (mime::TEXT, _) | (_, mime::HTML)
162        );
163
164        conn.response_headers_mut().try_insert(
165            ContentType,
166            if is_text && !is_ascii {
167                format!("{mime}; charset=utf-8")
168            } else {
169                mime.to_string()
170            },
171        );
172
173        if let Some(metadata) = file.metadata() {
174            conn.response_headers_mut()
175                .try_insert(LastModified, httpdate::fmt_http_date(metadata.modified()));
176        }
177
178        conn.ok(file.contents())
179    }
180
181    fn get_item(&self, path: &str) -> Option<DirEntry> {
182        if path.is_empty() || self.root.is_file() {
183            Some(self.root)
184        } else {
185            self.root.as_dir().and_then(|dir| {
186                dir.get_dir(path)
187                    .copied()
188                    .map(DirEntry::Dir)
189                    .or_else(|| dir.get_file(path).copied().map(DirEntry::File))
190            })
191        }
192    }
193}
194
195impl Handler for StaticCompiledHandler {
196    async fn run(&self, conn: Conn) -> Conn {
197        match (
198            self.get_item(conn.path().trim_start_matches('/')),
199            self.index_file,
200        ) {
201            (None, _) => conn,
202            (Some(DirEntry::File(file)), _) => self.serve_file(conn, file),
203            (Some(DirEntry::Dir(_)), None) => conn,
204            (Some(DirEntry::Dir(dir)), Some(index_file)) => {
205                if let Some(file) = dir.get_file(dir.path().join(index_file)) {
206                    self.serve_file(conn, *file)
207                } else {
208                    conn
209                }
210            }
211        }
212    }
213}
214
215/// The preferred interface to build a StaticCompiledHandler
216///
217/// Macro interface to build a
218/// [`StaticCompiledHandler`]. `static_compiled!("assets")` is
219/// identical to
220/// `StaticCompiledHandler::new(root!("assets"))`.
221///
222/// This takes one argument, which must be a string literal.
223///
224/// ## Relative paths
225///
226/// Relative paths are expanded and canonicalized relative to
227/// `$CARGO_MANIFEST_DIR`, which is usually the directory that contains
228/// your Cargo.toml. If compiled within a workspace, this will be the
229/// subcrate's Cargo.toml.
230///
231/// ## Environment variable expansion
232///
233/// If the argument to `static_compiled` contains substrings that are
234/// formatted like an environment variable, beginning with a $, they will
235/// be interpreted in the compile time environment.
236///
237/// For example "$OUT_DIR/some_directory" will expand to the directory
238/// `some_directory` within the env variable `$OUT_DIR` set by cargo. See
239/// [this link][env_vars] for further documentation on the environment
240/// variables set by cargo.
241///
242/// [env_vars]:https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
243#[macro_export]
244macro_rules! static_compiled {
245    ($path:tt) => {
246        $crate::StaticCompiledHandler::new($crate::root!($path))
247    };
248}
249
250/// Include the path as root. To be passed into [`StaticCompiledHandler::new`].
251///
252/// This takes one argument, which must be a string literal.
253///
254/// ## Relative paths
255///
256/// Relative paths are expanded and canonicalized relative to
257/// `$CARGO_MANIFEST_DIR`, which is usually the directory that contains
258/// your Cargo.toml. If compiled within a workspace, this will be the
259/// subcrate's Cargo.toml.
260///
261/// ## Environment variable expansion
262///
263/// If the argument to `static_compiled` contains substrings that are
264/// formatted like an environment variable, beginning with a $, they will
265/// be interpreted in the compile time environment.
266///
267/// For example "$OUT_DIR/some_directory" will expand to the directory
268/// `some_directory` within the env variable `$OUT_DIR` set by cargo. See
269/// [this link][env_vars] for further documentation on the environment
270/// variables set by cargo.
271///
272/// [env_vars]:https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
273#[macro_export]
274macro_rules! root {
275    ($path:tt) => {{
276        use $crate::__macro_internals::{Dir, DirEntry, File, Metadata, include_entry};
277        const ENTRY: DirEntry = include_entry!($path);
278        ENTRY
279    }};
280}