1use std::collections::HashMap;
2use std::fmt;
3use std::path::PathBuf;
4
5use camel_component_api::{CamelError, UriComponents, UriConfig, parse_uri};
6use serde::Deserialize;
7use serde::de::{self, Deserializer, MapAccess, Visitor};
8
9#[derive(Debug, Clone, Deserialize)]
20pub struct HttpStaticConfig {
21 pub dir: PathBuf,
23
24 #[serde(default = "default_port")]
26 pub port: u16,
27
28 #[serde(default = "default_host")]
30 pub host: String,
31
32 #[serde(rename = "spaFallback", default)]
34 pub spa_fallback: bool,
35
36 #[serde(rename = "cacheControl", default = "default_cache_control")]
38 pub cache_control: String,
39
40 #[serde(
42 rename = "errorPages",
43 default,
44 deserialize_with = "deserialize_error_pages"
45 )]
46 pub error_pages: HashMap<u16, PathBuf>,
47
48 #[serde(default = "default_mount_path")]
51 pub mount_path: String,
52}
53
54fn default_port() -> u16 {
55 8080
56}
57
58fn deserialize_error_pages<'de, D>(deserializer: D) -> Result<HashMap<u16, PathBuf>, D::Error>
63where
64 D: Deserializer<'de>,
65{
66 struct ErrorPagesVisitor;
67
68 impl<'de> Visitor<'de> for ErrorPagesVisitor {
69 type Value = HashMap<u16, PathBuf>;
70
71 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
72 formatter.write_str("a map with integer or string keys representing HTTP status codes")
73 }
74
75 fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
76 where
77 M: MapAccess<'de>,
78 {
79 let mut map = HashMap::new();
80 while let Some(key) = access.next_key::<String>()? {
81 let code: u16 = key.parse().map_err(de::Error::custom)?;
82 let path: PathBuf = access.next_value()?;
83 map.insert(code, path);
84 }
85 Ok(map)
86 }
87 }
88
89 deserializer.deserialize_map(ErrorPagesVisitor)
90}
91
92fn default_host() -> String {
93 "0.0.0.0".to_string()
94}
95
96fn default_cache_control() -> String {
97 "public, max-age=0".to_string()
98}
99
100fn default_mount_path() -> String {
101 "/".to_string()
102}
103
104impl Default for HttpStaticConfig {
105 fn default() -> Self {
106 Self {
107 dir: PathBuf::new(),
108 port: default_port(),
109 host: default_host(),
110 spa_fallback: false,
111 cache_control: default_cache_control(),
112 error_pages: HashMap::new(),
113 mount_path: default_mount_path(),
114 }
115 }
116}
117
118impl UriConfig for HttpStaticConfig {
119 fn scheme() -> &'static str {
120 "http-static"
121 }
122
123 fn from_uri(uri: &str) -> Result<Self, CamelError> {
124 let parts = parse_uri(uri)?;
125 Self::from_components(parts)
126 }
127
128 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
129 if parts.scheme != "http-static" {
130 return Err(CamelError::InvalidUri(format!(
131 "expected scheme 'http-static', got '{}'",
132 parts.scheme
133 )));
134 }
135
136 let (dir, mount_path) = if parts.path.is_empty() {
140 return Err(CamelError::InvalidUri(
141 "http-static URI requires a path (e.g. http-static:/path or http-static:/prefix?dir=/var/www)"
142 .to_string(),
143 ));
144 } else if let Some(dir_param) = parts.params.get("dir") {
145 let mount_path = normalize_mount_path(&parts.path);
147 (PathBuf::from(dir_param), mount_path)
148 } else if parts.path == "/" {
149 return Err(CamelError::InvalidUri(
152 "http-static:/ requires a dir query parameter when mount_path is root \
153 (e.g. http-static:/?dir=/var/www)"
154 .to_string(),
155 ));
156 } else {
157 (PathBuf::from(&parts.path), default_mount_path())
159 };
160
161 let port = parts
162 .params
163 .get("port")
164 .map(|v| {
165 v.parse::<u16>()
166 .map_err(|e| CamelError::InvalidUri(format!("invalid value for port: {e}")))
167 })
168 .transpose()?
169 .unwrap_or_else(default_port);
170
171 let host = parts
172 .params
173 .get("host")
174 .cloned()
175 .unwrap_or_else(default_host);
176
177 let spa_fallback = parts
178 .params
179 .get("spaFallback")
180 .map(|v| parse_bool_param_static(v))
181 .transpose()?
182 .unwrap_or(false);
183
184 let cache_control = parts
185 .params
186 .get("cacheControl")
187 .cloned()
188 .unwrap_or_else(default_cache_control);
189
190 let error_pages = HashMap::new();
193
194 Ok(Self {
195 dir,
196 port,
197 host,
198 spa_fallback,
199 cache_control,
200 error_pages,
201 mount_path,
202 })
203 }
204}
205
206fn normalize_mount_path(path: &str) -> String {
208 let mut path = path.to_string();
209 if !path.starts_with('/') {
210 path.insert(0, '/');
211 }
212 if path.len() > 1 {
213 path = path.trim_end_matches('/').to_string();
214 }
215 if path.is_empty() {
216 "/".to_string()
217 } else {
218 path
219 }
220}
221
222impl HttpStaticConfig {
223 pub fn from_uri_with_defaults(uri: &str, toml_defaults: &Self) -> Result<Self, CamelError> {
234 let parts = parse_uri(uri)?;
235
236 if parts.scheme != "http-static" {
238 return Err(CamelError::InvalidUri(format!(
239 "expected scheme 'http-static', got '{}'",
240 parts.scheme
241 )));
242 }
243
244 let mut config = toml_defaults.clone();
246
247 if !parts.path.is_empty() {
249 if let Some(dir_param) = parts.params.get("dir") {
250 config.dir = PathBuf::from(dir_param);
252 config.mount_path = normalize_mount_path(&parts.path);
253 } else if parts.path == "/" {
254 } else {
259 config.dir = PathBuf::from(&parts.path);
261 config.mount_path = default_mount_path();
262 }
263 }
264
265 if let Some(v) = parts.params.get("port") {
267 config.port = v
268 .parse::<u16>()
269 .map_err(|e| CamelError::InvalidUri(format!("invalid value for port: {e}")))?;
270 }
271
272 if let Some(v) = parts.params.get("host") {
273 config.host = v.clone();
274 }
275
276 if let Some(v) = parts.params.get("spaFallback") {
277 config.spa_fallback = parse_bool_param_static(v)?;
278 }
279
280 if let Some(v) = parts.params.get("cacheControl") {
281 config.cache_control = v.clone();
282 }
283
284 if config.dir.as_os_str().is_empty() {
286 return Err(CamelError::InvalidUri(
287 "http-static requires a directory path (from URI or Camel.toml)".to_string(),
288 ));
289 }
290
291 Ok(config)
292 }
293}
294
295fn parse_bool_param_static(value: &str) -> Result<bool, CamelError> {
300 match value.to_ascii_lowercase().as_str() {
301 "true" | "1" | "yes" => Ok(true),
302 "false" | "0" | "no" => Ok(false),
303 _ => Err(CamelError::InvalidUri(format!(
304 "invalid boolean value: '{value}'"
305 ))),
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
318 fn test_parse_uri_full() {
319 let config =
320 HttpStaticConfig::from_uri("http-static:/app/spa?port=3000&spaFallback=true").unwrap();
321 assert_eq!(config.dir, PathBuf::from("/app/spa"));
322 assert_eq!(config.port, 3000);
323 assert_eq!(config.host, "0.0.0.0");
324 assert!(config.spa_fallback);
325 assert_eq!(config.cache_control, "public, max-age=0");
326 assert!(config.error_pages.is_empty());
327 assert_eq!(config.mount_path, "/");
328 }
329
330 #[test]
331 fn test_parse_uri_defaults_when_params_omitted() {
332 let config = HttpStaticConfig::from_uri("http-static:/var/www").unwrap();
333 assert_eq!(config.dir, PathBuf::from("/var/www"));
334 assert_eq!(config.port, 8080);
335 assert_eq!(config.host, "0.0.0.0");
336 assert!(!config.spa_fallback);
337 assert_eq!(config.cache_control, "public, max-age=0");
338 assert_eq!(config.mount_path, "/");
339 }
340
341 #[test]
342 fn test_parse_uri_all_params() {
343 let config = HttpStaticConfig::from_uri(
344 "http-static:/app/dist?port=9090&host=127.0.0.1&spaFallback=true&cacheControl=no-cache",
345 )
346 .unwrap();
347 assert_eq!(config.dir, PathBuf::from("/app/dist"));
348 assert_eq!(config.port, 9090);
349 assert_eq!(config.host, "127.0.0.1");
350 assert!(config.spa_fallback);
351 assert_eq!(config.cache_control, "no-cache");
352 assert_eq!(config.mount_path, "/");
353 }
354
355 #[test]
356 fn test_parse_uri_rejects_wrong_scheme() {
357 let result = HttpStaticConfig::from_uri("http:/app/spa");
358 assert!(result.is_err());
359 if let Err(CamelError::InvalidUri(msg)) = result {
360 assert!(msg.contains("expected scheme 'http-static'"));
361 assert!(msg.contains("got 'http'"));
362 } else {
363 panic!("Expected InvalidUri error");
364 }
365 }
366
367 #[test]
368 fn test_parse_uri_rejects_empty_path() {
369 let result = HttpStaticConfig::from_uri("http-static:");
370 assert!(result.is_err());
371 if let Err(CamelError::InvalidUri(msg)) = result {
372 assert!(msg.contains("requires a path"));
373 } else {
374 panic!("Expected InvalidUri error for empty path");
375 }
376 }
377
378 #[test]
379 fn test_parse_uri_invalid_port() {
380 let result = HttpStaticConfig::from_uri("http-static:/app?port=notanumber");
381 assert!(result.is_err());
382 if let Err(CamelError::InvalidUri(msg)) = result {
383 assert!(msg.contains("invalid value for port"));
384 } else {
385 panic!("Expected InvalidUri error for invalid port");
386 }
387 }
388
389 #[test]
390 fn test_parse_uri_boolean_variants() {
391 for val in &["true", "True", "TRUE", "1", "yes"] {
392 let uri = format!("http-static:/app?spaFallback={val}");
393 let config = HttpStaticConfig::from_uri(&uri).unwrap();
394 assert!(
395 config.spa_fallback,
396 "spaFallback='{val}' should parse to true"
397 );
398 }
399 for val in &["false", "False", "FALSE", "0", "no"] {
400 let uri = format!("http-static:/app?spaFallback={val}");
401 let config = HttpStaticConfig::from_uri(&uri).unwrap();
402 assert!(
403 !config.spa_fallback,
404 "spaFallback='{val}' should parse to false"
405 );
406 }
407 }
408
409 #[test]
410 fn test_parse_uri_invalid_boolean() {
411 let result = HttpStaticConfig::from_uri("http-static:/app?spaFallback=maybe");
412 assert!(result.is_err());
413 if let Err(CamelError::InvalidUri(msg)) = result {
414 assert!(msg.contains("invalid boolean value"));
415 } else {
416 panic!("Expected InvalidUri error for invalid boolean");
417 }
418 }
419
420 #[test]
425 fn test_toml_parsing_with_renamed_keys() {
426 let toml_str = r#"
427 dir = "/app/spa"
428 port = 3000
429 host = "127.0.0.1"
430 spaFallback = true
431 cacheControl = "no-cache"
432 "#;
433 let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
434 assert_eq!(config.dir, PathBuf::from("/app/spa"));
435 assert_eq!(config.port, 3000);
436 assert_eq!(config.host, "127.0.0.1");
437 assert!(config.spa_fallback);
438 assert_eq!(config.cache_control, "no-cache");
439 }
440
441 #[test]
442 fn test_toml_error_pages_parsing() {
443 let toml_str = r#"
444 dir = "/app/spa"
445 [errorPages]
446 404 = "/app/errors/404.html"
447 500 = "/app/errors/500.html"
448 "#;
449 let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
450 assert_eq!(config.dir, PathBuf::from("/app/spa"));
451 assert_eq!(config.error_pages.len(), 2);
452 assert_eq!(
453 config.error_pages.get(&404),
454 Some(&PathBuf::from("/app/errors/404.html"))
455 );
456 assert_eq!(
457 config.error_pages.get(&500),
458 Some(&PathBuf::from("/app/errors/500.html"))
459 );
460 }
461
462 #[test]
463 fn test_toml_defaults() {
464 let toml_str = r#"
465 dir = "/app/spa"
466 "#;
467 let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
468 assert_eq!(config.port, 8080);
469 assert_eq!(config.host, "0.0.0.0");
470 assert!(!config.spa_fallback);
471 assert_eq!(config.cache_control, "public, max-age=0");
472 assert!(config.error_pages.is_empty());
473 }
474
475 #[test]
480 fn test_uri_overrides_toml_defaults() {
481 let toml_defaults = HttpStaticConfig {
482 dir: PathBuf::from("/default/dir"),
483 port: 8080,
484 host: "0.0.0.0".to_string(),
485 spa_fallback: false,
486 cache_control: "public, max-age=0".to_string(),
487 error_pages: HashMap::new(),
488 ..HttpStaticConfig::default()
489 };
490
491 let config = HttpStaticConfig::from_uri_with_defaults(
492 "http-static:/override/dir?port=3000&spaFallback=true",
493 &toml_defaults,
494 )
495 .unwrap();
496
497 assert_eq!(config.dir, PathBuf::from("/override/dir"));
499 assert_eq!(config.port, 3000);
500 assert!(config.spa_fallback);
501
502 assert_eq!(config.host, "0.0.0.0");
504 assert_eq!(config.cache_control, "public, max-age=0");
505 assert_eq!(config.mount_path, "/");
506 }
507
508 #[test]
509 fn test_uri_preserves_toml_when_params_absent() {
510 let toml_defaults = HttpStaticConfig {
511 dir: PathBuf::from("/toml/dir"),
512 port: 9090,
513 host: "127.0.0.1".to_string(),
514 spa_fallback: true,
515 cache_control: "no-cache".to_string(),
516 error_pages: HashMap::new(),
517 ..HttpStaticConfig::default()
518 };
519
520 let config =
522 HttpStaticConfig::from_uri_with_defaults("http-static:/uri/dir", &toml_defaults)
523 .unwrap();
524
525 assert_eq!(config.dir, PathBuf::from("/uri/dir"));
527 assert_eq!(config.port, 9090);
529 assert_eq!(config.host, "127.0.0.1");
530 assert!(config.spa_fallback);
531 assert_eq!(config.cache_control, "no-cache");
532 assert_eq!(config.mount_path, "/");
533 }
534
535 #[test]
536 fn test_uri_with_defaults_rejects_empty_dir() {
537 let toml_defaults = HttpStaticConfig {
538 dir: PathBuf::new(), ..HttpStaticConfig::default()
540 };
541
542 let result = HttpStaticConfig::from_uri_with_defaults("http-static:", &toml_defaults);
543 assert!(result.is_err());
544 if let Err(CamelError::InvalidUri(msg)) = result {
545 assert!(msg.contains("directory path"));
546 } else {
547 panic!("Expected InvalidUri error");
548 }
549 }
550
551 #[test]
552 fn test_uri_with_defaults_rejects_wrong_scheme() {
553 let toml_defaults = HttpStaticConfig {
554 dir: PathBuf::from("/default"),
555 ..HttpStaticConfig::default()
556 };
557
558 let result = HttpStaticConfig::from_uri_with_defaults("http:/app", &toml_defaults);
559 assert!(result.is_err());
560 if let Err(CamelError::InvalidUri(msg)) = result {
561 assert!(msg.contains("expected scheme 'http-static'"));
562 assert!(msg.contains("got 'http'"));
563 } else {
564 panic!("Expected InvalidUri error for wrong scheme in from_uri_with_defaults");
565 }
566 }
567
568 #[test]
573 fn test_mount_path_from_uri_with_dir_param() {
574 let config = HttpStaticConfig::from_uri("http-static:/assets?dir=/var/www").unwrap();
576 assert_eq!(config.dir, PathBuf::from("/var/www"));
577 assert_eq!(config.mount_path, "/assets");
578 }
579
580 #[test]
581 fn test_mount_path_root_when_no_dir_param() {
582 let config = HttpStaticConfig::from_uri("http-static:/var/www").unwrap();
584 assert_eq!(config.dir, PathBuf::from("/var/www"));
585 assert_eq!(config.mount_path, "/");
586 }
587
588 #[test]
589 fn test_mount_path_normalized_leading_slash() {
590 let config = HttpStaticConfig::from_uri("http-static:assets?dir=/var/www").unwrap();
591 assert_eq!(config.mount_path, "/assets");
592 }
593
594 #[test]
595 fn test_mount_path_normalized_no_trailing_slash() {
596 let config = HttpStaticConfig::from_uri("http-static:/assets/?dir=/var/www").unwrap();
597 assert_eq!(config.mount_path, "/assets");
598 }
599
600 #[test]
601 fn test_mount_path_root_stays_root() {
602 let config = HttpStaticConfig::from_uri("http-static:/?dir=/var/www").unwrap();
603 assert_eq!(config.mount_path, "/");
604 }
605
606 #[test]
607 fn test_mount_path_nested() {
608 let config = HttpStaticConfig::from_uri("http-static:/assets/sub?dir=/var/www").unwrap();
609 assert_eq!(config.mount_path, "/assets/sub");
610 }
611
612 #[test]
613 fn test_from_uri_with_defaults_mount_path_with_dir_param() {
614 let toml_defaults = HttpStaticConfig {
615 dir: PathBuf::from("/default/dir"),
616 ..HttpStaticConfig::default()
617 };
618
619 let config = HttpStaticConfig::from_uri_with_defaults(
620 "http-static:/assets?dir=/var/www&port=3000",
621 &toml_defaults,
622 )
623 .unwrap();
624
625 assert_eq!(config.dir, PathBuf::from("/var/www"));
626 assert_eq!(config.mount_path, "/assets");
627 assert_eq!(config.port, 3000);
628 }
629
630 #[test]
635 fn test_dir_pathbuf_accepts_various_paths() {
636 let config = HttpStaticConfig::from_uri("http-static:./frontend/dist").unwrap();
638 assert_eq!(config.dir, PathBuf::from("./frontend/dist"));
639 assert_eq!(config.mount_path, "/");
640
641 let config = HttpStaticConfig::from_uri("http-static:/var/www/html").unwrap();
643 assert_eq!(config.dir, PathBuf::from("/var/www/html"));
644 assert_eq!(config.mount_path, "/");
645
646 let config = HttpStaticConfig::from_uri("http-static:/app/my%20files").unwrap();
648 assert_eq!(config.dir, PathBuf::from("/app/my files"));
649 assert_eq!(config.mount_path, "/");
650 }
651
652 #[test]
653 fn test_dir_nonexistent_is_detectable() {
654 let config =
658 HttpStaticConfig::from_uri("http-static:/nonexistent/path/that/does/not/exist")
659 .unwrap();
660 assert_eq!(
661 config.dir,
662 PathBuf::from("/nonexistent/path/that/does/not/exist")
663 );
664 }
666}