Skip to main content

solverforge_ui/
lib.rs

1#[cfg(feature = "axum")]
2use axum::{
3    extract::Path,
4    http::{header, StatusCode},
5    response::{IntoResponse, Response},
6    routing::get,
7    Router,
8};
9
10pub mod assets;
11
12#[cfg(feature = "axum")]
13pub fn routes() -> Router {
14    Router::new().route("/sf/{*path}", get(serve_asset))
15}
16
17#[cfg(feature = "axum")]
18async fn serve_asset(Path(path): Path<String>) -> Response {
19    let Ok(asset) = assets::get(&path) else {
20        return StatusCode::NOT_FOUND.into_response();
21    };
22
23    (
24        StatusCode::OK,
25        [
26            (header::CONTENT_TYPE, asset.content_type()),
27            (header::CACHE_CONTROL, asset.cache_control()),
28        ],
29        asset.bytes(),
30    )
31        .into_response()
32}
33
34#[cfg(all(test, feature = "axum"))]
35mod tests {
36    use super::*;
37    use axum::{
38        body::{to_bytes, Body},
39        http::{Method, Request, StatusCode},
40    };
41    use tower::util::ServiceExt;
42
43    #[tokio::test]
44    async fn serves_assets_with_expected_headers() {
45        let app = routes();
46
47        let immutable_resp = app
48            .clone()
49            .oneshot(
50                Request::builder()
51                    .method(Method::GET)
52                    .uri("/sf/fonts/jetbrains-mono.woff2")
53                    .body(Body::empty())
54                    .unwrap(),
55            )
56            .await
57            .unwrap();
58
59        assert_eq!(immutable_resp.status(), StatusCode::OK);
60        assert_eq!(
61            immutable_resp.headers().get("cache-control").unwrap(),
62            "public, max-age=31536000, immutable"
63        );
64        assert_eq!(
65            immutable_resp.headers().get("content-type").unwrap(),
66            "font/woff2"
67        );
68        assert!(!to_bytes(immutable_resp.into_body(), 16_000_000)
69            .await
70            .unwrap()
71            .is_empty());
72
73        let mutable_resp = app
74            .clone()
75            .oneshot(
76                Request::builder()
77                    .method(Method::GET)
78                    .uri("/sf/sf.css")
79                    .body(Body::empty())
80                    .unwrap(),
81            )
82            .await
83            .unwrap();
84
85        assert_eq!(mutable_resp.status(), StatusCode::OK);
86        assert_eq!(
87            mutable_resp.headers().get("cache-control").unwrap(),
88            "public, max-age=3600"
89        );
90        assert_eq!(
91            mutable_resp.headers().get("content-type").unwrap(),
92            "text/css; charset=utf-8"
93        );
94        assert!(!to_bytes(mutable_resp.into_body(), 16_000_000)
95            .await
96            .unwrap()
97            .is_empty());
98
99        let missing_resp = app
100            .oneshot(
101                Request::builder()
102                    .method(Method::GET)
103                    .uri("/sf/does-not-exist")
104                    .body(Body::empty())
105                    .unwrap(),
106            )
107            .await
108            .unwrap();
109
110        assert_eq!(missing_resp.status(), StatusCode::NOT_FOUND);
111    }
112
113    #[tokio::test]
114    async fn serves_top_level_assets_with_short_cache_and_expected_mime() {
115        let response = routes()
116            .oneshot(
117                Request::builder()
118                    .uri("/sf/sf.css")
119                    .body(Body::empty())
120                    .unwrap(),
121            )
122            .await
123            .unwrap();
124
125        assert_eq!(response.status(), StatusCode::OK);
126        assert_eq!(
127            response.headers().get(header::CONTENT_TYPE).unwrap(),
128            "text/css; charset=utf-8"
129        );
130        assert_eq!(
131            response.headers().get(header::CACHE_CONTROL).unwrap(),
132            "public, max-age=3600"
133        );
134
135        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
136        let css = String::from_utf8(body.to_vec()).unwrap();
137        assert!(css.contains("--sf-emerald-50"));
138        assert!(css.contains(".sf-gantt-split"));
139    }
140
141    #[tokio::test]
142    async fn serves_immutable_assets_with_long_cache_and_expected_mime() {
143        let image = routes()
144            .oneshot(
145                Request::builder()
146                    .method(Method::GET)
147                    .uri("/sf/img/ouroboros.svg")
148                    .body(Body::empty())
149                    .unwrap(),
150            )
151            .await
152            .unwrap();
153
154        assert_eq!(image.status(), StatusCode::OK);
155        assert_eq!(
156            image.headers().get(header::CONTENT_TYPE).unwrap(),
157            "image/svg+xml"
158        );
159        assert_eq!(
160            image.headers().get(header::CACHE_CONTROL).unwrap(),
161            "public, max-age=31536000, immutable"
162        );
163
164        let vendor = routes()
165            .oneshot(
166                Request::builder()
167                    .method(Method::GET)
168                    .uri("/sf/vendor/frappe-gantt/frappe-gantt.min.js")
169                    .body(Body::empty())
170                    .unwrap(),
171            )
172            .await
173            .unwrap();
174
175        assert_eq!(vendor.status(), StatusCode::OK);
176        assert_eq!(
177            vendor.headers().get(header::CONTENT_TYPE).unwrap(),
178            "application/javascript; charset=utf-8"
179        );
180        assert_eq!(
181            vendor.headers().get(header::CACHE_CONTROL).unwrap(),
182            "public, max-age=31536000, immutable"
183        );
184    }
185
186    #[tokio::test]
187    async fn returns_not_found_for_missing_assets() {
188        let response = routes()
189            .oneshot(
190                Request::builder()
191                    .method(Method::GET)
192                    .uri("/sf/does-not-exist.js")
193                    .body(Body::empty())
194                    .unwrap(),
195            )
196            .await
197            .unwrap();
198
199        assert_eq!(response.status(), StatusCode::NOT_FOUND);
200    }
201}