Skip to main content

faucet_cli/serve/
ui_assets.rs

1//! Embedded web-console assets (serve-ui feature). The static shell is PUBLIC;
2//! all data stays behind the bearer-gated `/v1` API. Assets are embedded at
3//! compile time from `src/serve/ui/` via `rust-embed`.
4
5use crate::serve::error::ServeError;
6use axum::extract::Path;
7use axum::http::{HeaderMap, Method, StatusCode, header};
8use axum::response::{IntoResponse, Response};
9
10#[derive(rust_embed::RustEmbed)]
11#[folder = "src/serve/ui/"]
12struct UiAssets;
13
14/// Send embedded asset bytes with the right content-type and `no-cache`
15/// (assets are tiny + embedded; correctness over caching โ€” see spec ยง11).
16fn serve_asset(path: &str) -> Response {
17    match UiAssets::get(path) {
18        Some(file) => {
19            let mime = file.metadata.mimetype();
20            (
21                StatusCode::OK,
22                [
23                    (header::CONTENT_TYPE, mime.to_string()),
24                    (header::CACHE_CONTROL, "no-cache".to_string()),
25                ],
26                file.data.into_owned(),
27            )
28                .into_response()
29        }
30        None => ServeError::NotFound.into_response(),
31    }
32}
33
34/// `GET /` โ†’ the SPA shell.
35pub async fn index() -> Response {
36    serve_asset("index.html")
37}
38
39/// `GET /assets/{*path}` โ†’ an embedded asset by relative path.
40pub async fn asset(Path(path): Path<String>) -> Response {
41    serve_asset(&path)
42}
43
44/// True when the request prefers an HTML document (so a deep-link / refresh of a
45/// client-side route should receive the SPA shell rather than a JSON 404).
46pub(crate) fn wants_html(headers: &HeaderMap) -> bool {
47    headers
48        .get(header::ACCEPT)
49        .and_then(|v| v.to_str().ok())
50        .map(|a| a.contains("text/html"))
51        .unwrap_or(false)
52}
53
54/// Router fallback: an HTML-accepting GET to an unmatched path returns the SPA
55/// shell (enables hash-route deep links); everything else gets the standard JSON
56/// 404 so the API's 404 shape is preserved.
57pub async fn spa_fallback(method: Method, headers: HeaderMap) -> Response {
58    if method == Method::GET && wants_html(&headers) {
59        index().await
60    } else {
61        ServeError::NotFound.into_response()
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use axum::http::HeaderValue;
69
70    #[test]
71    fn wants_html_true_for_browser_accept() {
72        let mut h = HeaderMap::new();
73        h.insert(
74            header::ACCEPT,
75            HeaderValue::from_static("text/html,application/xhtml+xml"),
76        );
77        assert!(wants_html(&h));
78    }
79
80    #[test]
81    fn wants_html_false_for_json_or_missing() {
82        let mut h = HeaderMap::new();
83        h.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
84        assert!(!wants_html(&h));
85        assert!(!wants_html(&HeaderMap::new()));
86    }
87
88    #[test]
89    fn index_asset_is_embedded_and_html() {
90        let resp = serve_asset("index.html");
91        assert_eq!(resp.status(), StatusCode::OK);
92        let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
93        assert!(ct.to_str().unwrap().contains("text/html"));
94    }
95
96    #[test]
97    fn missing_asset_is_not_found() {
98        let resp = serve_asset("does-not-exist.xyz");
99        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
100    }
101}