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() || resp.status() == StatusCode::NOT_MODIFIED {
97 return resp;
98 }
99
100 if *mode == MountMode::Spa && resp.status() == StatusCode::NOT_FOUND {
108 let spa_req = rebuild_request(&parts);
109 if is_spa_qualified(&spa_req) {
110 return serve_spa_index(serve_dir.clone(), spa_req, cache_control).await;
111 }
112 }
113
114 if let Some(error_resp) = try_serve_error_page(error_pages, resp.status().as_u16()).await {
116 return error_resp;
117 }
118 }
119
120 if let Some((_, error_pages)) = &best_match
122 && let Some(error_resp) =
123 try_serve_error_page(error_pages, StatusCode::NOT_FOUND.as_u16()).await
124 {
125 return error_resp;
126 }
127
128 let root_error_pages: Option<HashMap<u16, PathBuf>> = {
130 let inner = state.registry.inner.read().await;
131 inner
132 .mounts
133 .iter()
134 .find(|m| m.mount_path == "/")
135 .map(|m| m.error_pages.clone())
136 };
137 if let Some(pages) = root_error_pages
138 && let Some(error_resp) = try_serve_error_page(&pages, StatusCode::NOT_FOUND.as_u16()).await
139 {
140 return error_resp;
141 }
142
143 (StatusCode::NOT_FOUND, "Not Found").into_response()
144}
145
146fn rebuild_request(parts: &http::request::Parts) -> Request {
149 let mut builder = http::Request::builder()
150 .method(parts.method.clone())
151 .uri(parts.uri.clone())
152 .version(parts.version);
153 for (k, v) in &parts.headers {
154 builder = builder.header(k, v);
155 }
156 builder
157 .extension(parts.extensions.clone())
158 .body(AxumBody::empty())
159 .expect("valid request rebuild") }
161
162fn rebuild_request_with_path(parts: &http::request::Parts, path: &str) -> Request {
165 let uri = format!("/{path}");
166 let mut builder = http::Request::builder()
167 .method(parts.method.clone())
168 .uri(&uri)
169 .version(parts.version);
170 for (k, v) in &parts.headers {
171 builder = builder.header(k, v);
172 }
173 builder
174 .extension(parts.extensions.clone())
175 .body(AxumBody::empty())
176 .expect("valid request rebuild") }
178
179async fn serve_via_serve_dir(
181 serve_dir: ServeDir,
182 req: Request,
183 cache_control: &str,
184) -> axum::response::Response {
185 match serve_dir.oneshot(req).await {
186 Ok(res) => {
187 let (mut parts, body) = res.into_parts();
188 if parts.status.is_success() || parts.status == StatusCode::NOT_MODIFIED {
195 parts.headers.insert(
196 http::header::CACHE_CONTROL,
197 http::HeaderValue::from_str(cache_control)
198 .unwrap_or_else(|_| http::HeaderValue::from_static("public, max-age=0")),
199 );
200 }
201 Response::from_parts(parts, AxumBody::new(body))
202 }
203 Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
204 }
205}
206
207fn is_spa_qualified(req: &Request) -> bool {
214 let method = req.method();
215 if method != http::Method::GET && method != http::Method::HEAD {
216 return false;
217 }
218 let accept = req
219 .headers()
220 .get(http::header::ACCEPT)
221 .and_then(|v| v.to_str().ok())
222 .unwrap_or("");
223 if !accept.contains("text/html") && !accept.contains("*/*") {
224 return false;
225 }
226 let path = req.uri().path();
227 let has_extension = std::path::Path::new(path).extension().is_some();
228 !has_extension
229}
230
231async fn serve_spa_index(
235 serve_dir: ServeDir,
236 req: Request,
237 cache_control: &str,
238) -> axum::response::Response {
239 let method = req.method().clone();
240 let headers = req.headers().clone();
241 let mut builder = axum::http::Request::builder()
242 .method(method)
243 .uri(axum::http::Uri::from_static("/"));
244 for (k, v) in headers.iter() {
245 builder = builder.header(k, v);
246 }
247 let rewrite = builder.body(AxumBody::empty()).expect("valid request"); serve_via_serve_dir(serve_dir, rewrite, cache_control).await
249}
250
251async fn try_serve_error_page(
257 error_pages: &HashMap<u16, PathBuf>,
258 status: u16,
259) -> Option<axum::response::Response> {
260 if let Some(error_path) = error_pages.get(&status) {
261 if let Some(parent) = error_path.parent() {
263 let file_name = error_path.file_name()?.to_str()?;
264 let error_serve_dir = ServeDir::new(parent);
265 let error_req = axum::http::Request::builder()
266 .method(http::Method::GET)
267 .uri(format!("/{file_name}"))
268 .body(AxumBody::empty())
269 .expect("valid request"); let mut resp = serve_via_serve_dir(error_serve_dir, error_req, "no-cache").await;
271 if resp.status().is_success()
273 && let Ok(code) = StatusCode::from_u16(status)
274 {
275 *resp.status_mut() = code;
276 }
277 return Some(resp);
278 }
279 }
280 None
281}
282
283#[cfg(test)]
284mod tests {
285 use super::prefix_matches_segment;
286
287 #[test]
288 fn root_prefix_matches_everything() {
289 assert!(prefix_matches_segment("/", "/"));
290 assert!(prefix_matches_segment("/foo", "/"));
291 assert!(prefix_matches_segment("/foo/bar", "/"));
292 }
293
294 #[test]
295 fn exact_prefix_match() {
296 assert!(prefix_matches_segment("/asset", "/asset"));
297 assert!(prefix_matches_segment("/asset/file.txt", "/asset"));
298 }
299
300 #[test]
301 fn segment_boundary_rejects_partial_match() {
302 assert!(!prefix_matches_segment("/assets", "/asset"));
304 assert!(!prefix_matches_segment("/assets/file.txt", "/asset"));
305 assert!(!prefix_matches_segment("/assetx", "/asset"));
306 }
307
308 #[test]
309 fn non_matching_prefix() {
310 assert!(!prefix_matches_segment("/other", "/asset"));
311 assert!(!prefix_matches_segment("/other/file.txt", "/asset"));
312 }
313
314 #[test]
315 fn nested_prefix() {
316 assert!(prefix_matches_segment(
317 "/assets/sub/file.txt",
318 "/assets/sub"
319 ));
320 assert!(!prefix_matches_segment(
321 "/assets/other/file.txt",
322 "/assets/sub"
323 ));
324 }
325}