camel_component_http/
static_dispatch.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use axum::body::Body as AxumBody;
5use axum::extract::Request;
6use axum::http::{Response, StatusCode};
7use axum::response::IntoResponse;
8use tower::ServiceExt as TowerServiceExt;
9use tower_http::services::ServeDir;
10
11use crate::AppState;
12use crate::registry::MountMode;
13
14fn prefix_matches_segment(path: &str, prefix: &str) -> bool {
17 if prefix == "/" {
18 return true; }
20 if !path.starts_with(prefix) {
21 return false;
22 }
23 path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/'
24}
25
26pub(crate) async fn dispatch_static(
33 state: &AppState,
34 req: Request,
35 path: &str,
36) -> axum::response::Response {
37 let relative = path.trim_start_matches('/');
42
43 let (parts, _body) = req.into_parts();
45
46 let mounts: Vec<_> = {
48 let inner = state.registry.inner.read().await;
49 inner
50 .mounts
51 .iter()
52 .map(|m| {
53 (
54 m.mount_path.clone(),
55 m.mode,
56 m.serve_dir.clone(),
57 m.cache_control.clone(),
58 m.error_pages.clone(),
59 )
60 })
61 .collect()
62 }; let mut indexed: Vec<_> = mounts.into_iter().collect();
66 indexed.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
67
68 let mut best_match: Option<(String, HashMap<u16, PathBuf>)> = None;
70
71 for (mp, mode, serve_dir, cache_control, error_pages) in &indexed {
73 if !prefix_matches_segment(path, mp.as_str()) {
74 continue;
75 }
76
77 if best_match.is_none() {
79 best_match = Some((mp.clone(), error_pages.clone()));
80 }
81
82 let stripped_path = if mp == "/" {
84 relative.to_string()
85 } else {
86 let remainder = path.strip_prefix(mp.as_str()).unwrap_or("");
87 remainder.trim_start_matches('/').to_string()
88 };
89
90 let req = rebuild_request_with_path(&parts, &stripped_path);
91 let resp = serve_via_serve_dir(serve_dir.clone(), req, cache_control).await;
92 if resp.status().is_success() {
93 return resp;
94 }
95
96 if *mode == MountMode::Spa && resp.status() == StatusCode::NOT_FOUND {
104 let spa_req = rebuild_request(&parts);
105 if is_spa_qualified(&spa_req) {
106 return serve_spa_index(serve_dir.clone(), spa_req, cache_control).await;
107 }
108 }
109
110 if let Some(error_resp) = try_serve_error_page(error_pages, resp.status().as_u16()).await {
112 return error_resp;
113 }
114 }
115
116 if let Some((_, error_pages)) = &best_match
118 && let Some(error_resp) =
119 try_serve_error_page(error_pages, StatusCode::NOT_FOUND.as_u16()).await
120 {
121 return error_resp;
122 }
123
124 let root_error_pages: Option<HashMap<u16, PathBuf>> = {
126 let inner = state.registry.inner.read().await;
127 inner
128 .mounts
129 .iter()
130 .find(|m| m.mount_path == "/")
131 .map(|m| m.error_pages.clone())
132 };
133 if let Some(pages) = root_error_pages
134 && let Some(error_resp) = try_serve_error_page(&pages, StatusCode::NOT_FOUND.as_u16()).await
135 {
136 return error_resp;
137 }
138
139 (StatusCode::NOT_FOUND, "Not Found").into_response()
140}
141
142fn rebuild_request(parts: &http::request::Parts) -> Request {
145 let mut builder = http::Request::builder()
146 .method(parts.method.clone())
147 .uri(parts.uri.clone())
148 .version(parts.version);
149 for (k, v) in &parts.headers {
150 builder = builder.header(k, v);
151 }
152 builder
153 .extension(parts.extensions.clone())
154 .body(AxumBody::empty())
155 .expect("valid request rebuild") }
157
158fn rebuild_request_with_path(parts: &http::request::Parts, path: &str) -> Request {
161 let uri = format!("/{path}");
162 let mut builder = http::Request::builder()
163 .method(parts.method.clone())
164 .uri(&uri)
165 .version(parts.version);
166 for (k, v) in &parts.headers {
167 builder = builder.header(k, v);
168 }
169 builder
170 .extension(parts.extensions.clone())
171 .body(AxumBody::empty())
172 .expect("valid request rebuild") }
174
175async fn serve_via_serve_dir(
177 serve_dir: ServeDir,
178 req: Request,
179 cache_control: &str,
180) -> axum::response::Response {
181 match serve_dir.oneshot(req).await {
182 Ok(res) => {
183 let (mut parts, body) = res.into_parts();
184 if parts.status.is_success() {
185 parts.headers.insert(
186 http::header::CACHE_CONTROL,
187 http::HeaderValue::from_str(cache_control)
188 .unwrap_or_else(|_| http::HeaderValue::from_static("public, max-age=0")),
189 );
190 }
191 Response::from_parts(parts, AxumBody::new(body))
192 }
193 Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
194 }
195}
196
197fn is_spa_qualified(req: &Request) -> bool {
204 let method = req.method();
205 if method != http::Method::GET && method != http::Method::HEAD {
206 return false;
207 }
208 let accept = req
209 .headers()
210 .get(http::header::ACCEPT)
211 .and_then(|v| v.to_str().ok())
212 .unwrap_or("");
213 if !accept.contains("text/html") && !accept.contains("*/*") {
214 return false;
215 }
216 let path = req.uri().path();
217 let has_extension = std::path::Path::new(path).extension().is_some();
218 !has_extension
219}
220
221async fn serve_spa_index(
225 serve_dir: ServeDir,
226 req: Request,
227 cache_control: &str,
228) -> axum::response::Response {
229 let method = req.method().clone();
230 let headers = req.headers().clone();
231 let mut builder = axum::http::Request::builder()
232 .method(method)
233 .uri(axum::http::Uri::from_static("/"));
234 for (k, v) in headers.iter() {
235 builder = builder.header(k, v);
236 }
237 let rewrite = builder.body(AxumBody::empty()).expect("valid request"); serve_via_serve_dir(serve_dir, rewrite, cache_control).await
239}
240
241async fn try_serve_error_page(
247 error_pages: &HashMap<u16, PathBuf>,
248 status: u16,
249) -> Option<axum::response::Response> {
250 if let Some(error_path) = error_pages.get(&status) {
251 if let Some(parent) = error_path.parent() {
253 let file_name = error_path.file_name()?.to_str()?;
254 let error_serve_dir = ServeDir::new(parent);
255 let error_req = axum::http::Request::builder()
256 .method(http::Method::GET)
257 .uri(format!("/{file_name}"))
258 .body(AxumBody::empty())
259 .expect("valid request"); let mut resp = serve_via_serve_dir(error_serve_dir, error_req, "no-cache").await;
261 if resp.status().is_success()
263 && let Ok(code) = StatusCode::from_u16(status)
264 {
265 *resp.status_mut() = code;
266 }
267 return Some(resp);
268 }
269 }
270 None
271}
272
273#[cfg(test)]
274mod tests {
275 use super::prefix_matches_segment;
276
277 #[test]
278 fn root_prefix_matches_everything() {
279 assert!(prefix_matches_segment("/", "/"));
280 assert!(prefix_matches_segment("/foo", "/"));
281 assert!(prefix_matches_segment("/foo/bar", "/"));
282 }
283
284 #[test]
285 fn exact_prefix_match() {
286 assert!(prefix_matches_segment("/asset", "/asset"));
287 assert!(prefix_matches_segment("/asset/file.txt", "/asset"));
288 }
289
290 #[test]
291 fn segment_boundary_rejects_partial_match() {
292 assert!(!prefix_matches_segment("/assets", "/asset"));
294 assert!(!prefix_matches_segment("/assets/file.txt", "/asset"));
295 assert!(!prefix_matches_segment("/assetx", "/asset"));
296 }
297
298 #[test]
299 fn non_matching_prefix() {
300 assert!(!prefix_matches_segment("/other", "/asset"));
301 assert!(!prefix_matches_segment("/other/file.txt", "/asset"));
302 }
303
304 #[test]
305 fn nested_prefix() {
306 assert!(prefix_matches_segment(
307 "/assets/sub/file.txt",
308 "/assets/sub"
309 ));
310 assert!(!prefix_matches_segment(
311 "/assets/other/file.txt",
312 "/assets/sub"
313 ));
314 }
315}