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}