tower_embed/
lib.rs

1//! This crate provides a [`tower`] service designed to provide embedded static
2//! assets support for web application. This service includes the following HTTP features:
3//!
4//! - Support for GET and HEAD requests
5//! - Content-Type header generation based on file MIME types
6//! - ETag header generation and validation
7//! - Last-Modified header generation and validation
8//!
9//! # Usage
10//!
11//! Please see the [examples] directory for a working example.
12//!
13//! [`tower`]: https://crates.io/crates/tower
14//! [examples]: https://github.com/mattiapenati/tower-embed/tree/main/examples
15
16use std::{
17    borrow::Cow,
18    marker::PhantomData,
19    task::{Context, Poll},
20};
21
22#[doc(no_inline)]
23pub use rust_embed;
24
25use rust_embed::RustEmbed;
26use tower_service::Service;
27
28use self::headers::HeaderMapExt;
29
30#[doc(inline)]
31pub use self::response::{ResponseBody, ResponseFuture};
32
33mod headers;
34mod response;
35
36/// Service that serves files from embedded assets.
37pub struct ServeEmbed<E: RustEmbed> {
38    _embed: PhantomData<E>,
39}
40
41impl<E: RustEmbed> Clone for ServeEmbed<E> {
42    fn clone(&self) -> Self {
43        *self
44    }
45}
46
47impl<E: RustEmbed> Copy for ServeEmbed<E> {}
48
49impl<E: RustEmbed> ServeEmbed<E> {
50    /// Create a new [`ServeEmbed`] service.
51    pub fn new() -> Self {
52        Self {
53            _embed: PhantomData,
54        }
55    }
56}
57
58impl<E: RustEmbed> Default for ServeEmbed<E> {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl<E, ReqBody> Service<http::Request<ReqBody>> for ServeEmbed<E>
65where
66    E: RustEmbed,
67{
68    type Response = http::Response<ResponseBody>;
69    type Error = std::convert::Infallible;
70    type Future = ResponseFuture;
71
72    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
73        Poll::Ready(Ok(()))
74    }
75
76    fn call(&mut self, req: http::Request<ReqBody>) -> Self::Future {
77        if req.method() != http::Method::GET && req.method() != http::Method::HEAD {
78            return ResponseFuture::method_not_allowed();
79        }
80
81        let path = get_file_path_from_uri(req.uri());
82        let Some(file) = E::get(path.as_ref()) else {
83            return ResponseFuture::file_not_found();
84        };
85
86        // Get response headers
87        let content_type = file.metadata.content_type_header();
88        let etag = file.metadata.etag_header();
89        let last_modified = file.metadata.last_modified_header();
90
91        // Make the request conditional if an If-None-Match header is present
92        if let Some(if_none_match) = req.headers().typed_get::<headers::IfNoneMatch>()
93            && !if_none_match.condition_passes(&etag)
94        {
95            return ResponseFuture::not_modified();
96        }
97
98        // Make the request conditional if an If-Modified-Since header is present
99        if let Some(if_modified_since) = req.headers().typed_get::<headers::IfModifiedSince>()
100            && let Some(last_modified) = last_modified
101            && !if_modified_since.condition_passes(&last_modified)
102        {
103            return ResponseFuture::not_modified();
104        }
105
106        ResponseFuture::file(response::File {
107            content: file.data.clone(),
108            content_type,
109            etag,
110            last_modified,
111        })
112    }
113}
114
115fn get_file_path_from_uri(uri: &http::Uri) -> Cow<'_, str> {
116    let path = uri.path();
117    if path.ends_with("/") {
118        Cow::Owned(format!("{}index.html", path.trim_start_matches('/')))
119    } else {
120        Cow::Borrowed(path.trim_start_matches('/'))
121    }
122}
123
124trait MetadataExt {
125    /// Compute the ETag for the asset.
126    fn etag_header(&self) -> headers::ETag;
127
128    /// Returns the content type of the asset.
129    fn content_type_header(&self) -> headers::ContentType;
130
131    /// Return the last modified time formatted as an HTTP date.
132    fn last_modified_header(&self) -> Option<headers::LastModified>;
133}
134
135impl MetadataExt for rust_embed::Metadata {
136    fn etag_header(&self) -> headers::ETag {
137        let etag = self
138            .sha256_hash()
139            .iter()
140            .map(|b| format!("{b:02x}"))
141            .collect::<String>();
142        headers::ETag::new(&etag).unwrap()
143    }
144
145    fn content_type_header(&self) -> headers::ContentType {
146        headers::ContentType::from_str(self.mimetype())
147            .unwrap_or_else(headers::ContentType::octet_stream)
148    }
149
150    fn last_modified_header(&self) -> Option<headers::LastModified> {
151        let unix_timestamp = self.last_modified()?;
152        headers::LastModified::from_unix_timestamp(unix_timestamp)
153    }
154}