1use std::collections::HashMap;
2use std::fmt;
3use std::path::PathBuf;
4
5use camel_api::component_metadata::ComponentMetadata;
6use camel_component_api::{CamelError, UriComponents, UriConfig, parse_uri};
7use serde::Deserialize;
8use serde::de::{self, Deserializer, MapAccess, Visitor};
9
10#[derive(Debug, Clone, Deserialize)]
21pub struct HttpStaticConfig {
22 pub dir: PathBuf,
24
25 #[serde(default = "default_port")]
27 pub port: u16,
28
29 #[serde(default = "default_host")]
31 pub host: String,
32
33 #[serde(rename = "spaFallback", default)]
35 pub spa_fallback: bool,
36
37 #[serde(rename = "cacheControl", default = "default_cache_control")]
39 pub cache_control: String,
40
41 #[serde(
43 rename = "errorPages",
44 default,
45 deserialize_with = "deserialize_error_pages"
46 )]
47 pub error_pages: HashMap<u16, PathBuf>,
48
49 #[serde(default = "default_mount_path")]
52 pub mount_path: String,
53}
54
55fn default_port() -> u16 {
56 8080
57}
58
59fn deserialize_error_pages<'de, D>(deserializer: D) -> Result<HashMap<u16, PathBuf>, D::Error>
64where
65 D: Deserializer<'de>,
66{
67 struct ErrorPagesVisitor;
68
69 impl<'de> Visitor<'de> for ErrorPagesVisitor {
70 type Value = HashMap<u16, PathBuf>;
71
72 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
73 formatter.write_str("a map with integer or string keys representing HTTP status codes")
74 }
75
76 fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
77 where
78 M: MapAccess<'de>,
79 {
80 let mut map = HashMap::new();
81 while let Some(key) = access.next_key::<String>()? {
82 let code: u16 = key.parse().map_err(de::Error::custom)?;
83 let path: PathBuf = access.next_value()?;
84 map.insert(code, path);
85 }
86 Ok(map)
87 }
88 }
89
90 deserializer.deserialize_map(ErrorPagesVisitor)
91}
92
93fn default_host() -> String {
94 "0.0.0.0".to_string()
95}
96
97fn default_cache_control() -> String {
98 "public, max-age=0".to_string()
99}
100
101fn default_mount_path() -> String {
102 "/".to_string()
103}
104
105impl Default for HttpStaticConfig {
106 fn default() -> Self {
107 Self {
108 dir: PathBuf::new(),
109 port: default_port(),
110 host: default_host(),
111 spa_fallback: false,
112 cache_control: default_cache_control(),
113 error_pages: HashMap::new(),
114 mount_path: default_mount_path(),
115 }
116 }
117}
118
119impl UriConfig for HttpStaticConfig {
120 fn scheme() -> &'static str {
121 "http-static"
122 }
123
124 fn from_uri(uri: &str) -> Result<Self, CamelError> {
125 let parts = parse_uri(uri)?;
126 Self::from_components(parts)
127 }
128
129 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
130 if parts.scheme != "http-static" {
131 return Err(CamelError::InvalidUri(format!(
132 "expected scheme 'http-static', got '{}'",
133 parts.scheme
134 )));
135 }
136
137 let (dir, mount_path) = if parts.path.is_empty() {
141 return Err(CamelError::InvalidUri(
142 "http-static URI requires a path (e.g. http-static:/path or http-static:/prefix?dir=/var/www)"
143 .to_string(),
144 ));
145 } else if let Some(dir_param) = parts.params.get("dir") {
146 let mount_path = normalize_mount_path(&parts.path);
148 (PathBuf::from(dir_param), mount_path)
149 } else if parts.path == "/" {
150 return Err(CamelError::InvalidUri(
153 "http-static:/ requires a dir query parameter when mount_path is root \
154 (e.g. http-static:/?dir=/var/www)"
155 .to_string(),
156 ));
157 } else {
158 (PathBuf::from(&parts.path), default_mount_path())
160 };
161
162 let port = parts
163 .params
164 .get("port")
165 .map(|v| {
166 v.parse::<u16>()
167 .map_err(|e| CamelError::InvalidUri(format!("invalid value for port: {e}")))
168 })
169 .transpose()?
170 .unwrap_or_else(default_port);
171
172 let host = parts
173 .params
174 .get("host")
175 .cloned()
176 .unwrap_or_else(default_host);
177
178 let spa_fallback = parts
179 .params
180 .get("spaFallback")
181 .map(|v| parse_bool_param_static(v))
182 .transpose()?
183 .unwrap_or(false);
184
185 let cache_control = parts
186 .params
187 .get("cacheControl")
188 .cloned()
189 .unwrap_or_else(default_cache_control);
190
191 let error_pages = HashMap::new();
194
195 Ok(Self {
196 dir,
197 port,
198 host,
199 spa_fallback,
200 cache_control,
201 error_pages,
202 mount_path,
203 })
204 }
205}
206
207fn normalize_mount_path(path: &str) -> String {
209 let mut path = path.to_string();
210 if !path.starts_with('/') {
211 path.insert(0, '/');
212 }
213 if path.len() > 1 {
214 path = path.trim_end_matches('/').to_string();
215 }
216 if path.is_empty() {
217 "/".to_string()
218 } else {
219 path
220 }
221}
222
223impl HttpStaticConfig {
224 pub fn from_uri_with_defaults(uri: &str, toml_defaults: &Self) -> Result<Self, CamelError> {
235 let parts = parse_uri(uri)?;
236
237 if parts.scheme != "http-static" {
239 return Err(CamelError::InvalidUri(format!(
240 "expected scheme 'http-static', got '{}'",
241 parts.scheme
242 )));
243 }
244
245 let mut config = toml_defaults.clone();
247
248 if !parts.path.is_empty() {
250 if let Some(dir_param) = parts.params.get("dir") {
251 config.dir = PathBuf::from(dir_param);
253 config.mount_path = normalize_mount_path(&parts.path);
254 } else if parts.path == "/" {
255 } else {
260 config.dir = PathBuf::from(&parts.path);
262 config.mount_path = default_mount_path();
263 }
264 }
265
266 if let Some(v) = parts.params.get("port") {
268 config.port = v
269 .parse::<u16>()
270 .map_err(|e| CamelError::InvalidUri(format!("invalid value for port: {e}")))?;
271 }
272
273 if let Some(v) = parts.params.get("host") {
274 config.host = v.clone();
275 }
276
277 if let Some(v) = parts.params.get("spaFallback") {
278 config.spa_fallback = parse_bool_param_static(v)?;
279 }
280
281 if let Some(v) = parts.params.get("cacheControl") {
282 config.cache_control = v.clone();
283 }
284
285 if config.dir.as_os_str().is_empty() {
287 return Err(CamelError::InvalidUri(
288 "http-static requires a directory path (from URI or Camel.toml)".to_string(),
289 ));
290 }
291
292 Ok(config)
293 }
294}
295
296#[derive(Debug, Clone, UriConfig)]
302#[allow(dead_code)]
303#[uri_scheme = "http-static"]
304#[uri_config(
305 skip_impl,
306 metadata(
307 scheme = "http-static",
308 description = "Static file server component",
309 consumer
310 ),
311 crate = "camel_component_api"
312)]
313struct HttpStaticUriConfig {
314 #[allow(dead_code)]
315 _mount_path: String,
316
317 #[uri_param(name = "dir", desc = "Root directory to serve files from")]
318 dir: String,
319
320 #[uri_param(name = "port", default = "8080", desc = "TCP listen port")]
321 port: u16,
322
323 #[uri_param(name = "host", default = "0.0.0.0", desc = "Bind address")]
324 host: String,
325
326 #[uri_param(
327 name = "spaFallback",
328 default = "false",
329 desc = "Serve index.html for unmatched paths"
330 )]
331 spa_fallback: bool,
332
333 #[uri_param(
334 name = "cacheControl",
335 default = "public, max-age=0",
336 desc = "Cache-Control header value"
337 )]
338 cache_control: String,
339}
340
341impl HttpStaticConfig {
342 pub fn metadata() -> ComponentMetadata {
345 HttpStaticUriConfig::metadata()
346 }
347
348 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
350 HttpStaticUriConfig::uri_options()
351 }
352}
353
354fn parse_bool_param_static(value: &str) -> Result<bool, CamelError> {
359 match value.to_ascii_lowercase().as_str() {
360 "true" | "1" | "yes" => Ok(true),
361 "false" | "0" | "no" => Ok(false),
362 _ => Err(CamelError::InvalidUri(format!(
363 "invalid boolean value: '{value}'"
364 ))),
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
377 fn test_parse_uri_full() {
378 let config =
379 HttpStaticConfig::from_uri("http-static:/app/spa?port=3000&spaFallback=true").unwrap();
380 assert_eq!(config.dir, PathBuf::from("/app/spa"));
381 assert_eq!(config.port, 3000);
382 assert_eq!(config.host, "0.0.0.0");
383 assert!(config.spa_fallback);
384 assert_eq!(config.cache_control, "public, max-age=0");
385 assert!(config.error_pages.is_empty());
386 assert_eq!(config.mount_path, "/");
387 }
388
389 #[test]
390 fn test_parse_uri_defaults_when_params_omitted() {
391 let config = HttpStaticConfig::from_uri("http-static:/var/www").unwrap();
392 assert_eq!(config.dir, PathBuf::from("/var/www"));
393 assert_eq!(config.port, 8080);
394 assert_eq!(config.host, "0.0.0.0");
395 assert!(!config.spa_fallback);
396 assert_eq!(config.cache_control, "public, max-age=0");
397 assert_eq!(config.mount_path, "/");
398 }
399
400 #[test]
401 fn test_parse_uri_all_params() {
402 let config = HttpStaticConfig::from_uri(
403 "http-static:/app/dist?port=9090&host=127.0.0.1&spaFallback=true&cacheControl=no-cache",
404 )
405 .unwrap();
406 assert_eq!(config.dir, PathBuf::from("/app/dist"));
407 assert_eq!(config.port, 9090);
408 assert_eq!(config.host, "127.0.0.1");
409 assert!(config.spa_fallback);
410 assert_eq!(config.cache_control, "no-cache");
411 assert_eq!(config.mount_path, "/");
412 }
413
414 #[test]
415 fn test_parse_uri_rejects_wrong_scheme() {
416 let result = HttpStaticConfig::from_uri("http:/app/spa");
417 assert!(result.is_err());
418 if let Err(CamelError::InvalidUri(msg)) = result {
419 assert!(msg.contains("expected scheme 'http-static'"));
420 assert!(msg.contains("got 'http'"));
421 } else {
422 panic!("Expected InvalidUri error");
423 }
424 }
425
426 #[test]
427 fn test_parse_uri_rejects_empty_path() {
428 let result = HttpStaticConfig::from_uri("http-static:");
429 assert!(result.is_err());
430 if let Err(CamelError::InvalidUri(msg)) = result {
431 assert!(msg.contains("requires a path"));
432 } else {
433 panic!("Expected InvalidUri error for empty path");
434 }
435 }
436
437 #[test]
438 fn test_parse_uri_invalid_port() {
439 let result = HttpStaticConfig::from_uri("http-static:/app?port=notanumber");
440 assert!(result.is_err());
441 if let Err(CamelError::InvalidUri(msg)) = result {
442 assert!(msg.contains("invalid value for port"));
443 } else {
444 panic!("Expected InvalidUri error for invalid port");
445 }
446 }
447
448 #[test]
449 fn test_parse_uri_boolean_variants() {
450 for val in &["true", "True", "TRUE", "1", "yes"] {
451 let uri = format!("http-static:/app?spaFallback={val}");
452 let config = HttpStaticConfig::from_uri(&uri).unwrap();
453 assert!(
454 config.spa_fallback,
455 "spaFallback='{val}' should parse to true"
456 );
457 }
458 for val in &["false", "False", "FALSE", "0", "no"] {
459 let uri = format!("http-static:/app?spaFallback={val}");
460 let config = HttpStaticConfig::from_uri(&uri).unwrap();
461 assert!(
462 !config.spa_fallback,
463 "spaFallback='{val}' should parse to false"
464 );
465 }
466 }
467
468 #[test]
469 fn test_parse_uri_invalid_boolean() {
470 let result = HttpStaticConfig::from_uri("http-static:/app?spaFallback=maybe");
471 assert!(result.is_err());
472 if let Err(CamelError::InvalidUri(msg)) = result {
473 assert!(msg.contains("invalid boolean value"));
474 } else {
475 panic!("Expected InvalidUri error for invalid boolean");
476 }
477 }
478
479 #[test]
484 fn test_toml_parsing_with_renamed_keys() {
485 let toml_str = r#"
486 dir = "/app/spa"
487 port = 3000
488 host = "127.0.0.1"
489 spaFallback = true
490 cacheControl = "no-cache"
491 "#;
492 let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
493 assert_eq!(config.dir, PathBuf::from("/app/spa"));
494 assert_eq!(config.port, 3000);
495 assert_eq!(config.host, "127.0.0.1");
496 assert!(config.spa_fallback);
497 assert_eq!(config.cache_control, "no-cache");
498 }
499
500 #[test]
501 fn test_toml_error_pages_parsing() {
502 let toml_str = r#"
503 dir = "/app/spa"
504 [errorPages]
505 404 = "/app/errors/404.html"
506 500 = "/app/errors/500.html"
507 "#;
508 let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
509 assert_eq!(config.dir, PathBuf::from("/app/spa"));
510 assert_eq!(config.error_pages.len(), 2);
511 assert_eq!(
512 config.error_pages.get(&404),
513 Some(&PathBuf::from("/app/errors/404.html"))
514 );
515 assert_eq!(
516 config.error_pages.get(&500),
517 Some(&PathBuf::from("/app/errors/500.html"))
518 );
519 }
520
521 #[test]
522 fn test_toml_defaults() {
523 let toml_str = r#"
524 dir = "/app/spa"
525 "#;
526 let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
527 assert_eq!(config.port, 8080);
528 assert_eq!(config.host, "0.0.0.0");
529 assert!(!config.spa_fallback);
530 assert_eq!(config.cache_control, "public, max-age=0");
531 assert!(config.error_pages.is_empty());
532 }
533
534 #[test]
539 fn test_uri_overrides_toml_defaults() {
540 let toml_defaults = HttpStaticConfig {
541 dir: PathBuf::from("/default/dir"),
542 port: 8080,
543 host: "0.0.0.0".to_string(),
544 spa_fallback: false,
545 cache_control: "public, max-age=0".to_string(),
546 error_pages: HashMap::new(),
547 ..HttpStaticConfig::default()
548 };
549
550 let config = HttpStaticConfig::from_uri_with_defaults(
551 "http-static:/override/dir?port=3000&spaFallback=true",
552 &toml_defaults,
553 )
554 .unwrap();
555
556 assert_eq!(config.dir, PathBuf::from("/override/dir"));
558 assert_eq!(config.port, 3000);
559 assert!(config.spa_fallback);
560
561 assert_eq!(config.host, "0.0.0.0");
563 assert_eq!(config.cache_control, "public, max-age=0");
564 assert_eq!(config.mount_path, "/");
565 }
566
567 #[test]
568 fn test_uri_preserves_toml_when_params_absent() {
569 let toml_defaults = HttpStaticConfig {
570 dir: PathBuf::from("/toml/dir"),
571 port: 9090,
572 host: "127.0.0.1".to_string(),
573 spa_fallback: true,
574 cache_control: "no-cache".to_string(),
575 error_pages: HashMap::new(),
576 ..HttpStaticConfig::default()
577 };
578
579 let config =
581 HttpStaticConfig::from_uri_with_defaults("http-static:/uri/dir", &toml_defaults)
582 .unwrap();
583
584 assert_eq!(config.dir, PathBuf::from("/uri/dir"));
586 assert_eq!(config.port, 9090);
588 assert_eq!(config.host, "127.0.0.1");
589 assert!(config.spa_fallback);
590 assert_eq!(config.cache_control, "no-cache");
591 assert_eq!(config.mount_path, "/");
592 }
593
594 #[test]
595 fn test_uri_with_defaults_rejects_empty_dir() {
596 let toml_defaults = HttpStaticConfig {
597 dir: PathBuf::new(), ..HttpStaticConfig::default()
599 };
600
601 let result = HttpStaticConfig::from_uri_with_defaults("http-static:", &toml_defaults);
602 assert!(result.is_err());
603 if let Err(CamelError::InvalidUri(msg)) = result {
604 assert!(msg.contains("directory path"));
605 } else {
606 panic!("Expected InvalidUri error");
607 }
608 }
609
610 #[test]
611 fn test_uri_with_defaults_rejects_wrong_scheme() {
612 let toml_defaults = HttpStaticConfig {
613 dir: PathBuf::from("/default"),
614 ..HttpStaticConfig::default()
615 };
616
617 let result = HttpStaticConfig::from_uri_with_defaults("http:/app", &toml_defaults);
618 assert!(result.is_err());
619 if let Err(CamelError::InvalidUri(msg)) = result {
620 assert!(msg.contains("expected scheme 'http-static'"));
621 assert!(msg.contains("got 'http'"));
622 } else {
623 panic!("Expected InvalidUri error for wrong scheme in from_uri_with_defaults");
624 }
625 }
626
627 #[test]
632 fn test_mount_path_from_uri_with_dir_param() {
633 let config = HttpStaticConfig::from_uri("http-static:/assets?dir=/var/www").unwrap();
635 assert_eq!(config.dir, PathBuf::from("/var/www"));
636 assert_eq!(config.mount_path, "/assets");
637 }
638
639 #[test]
640 fn test_mount_path_root_when_no_dir_param() {
641 let config = HttpStaticConfig::from_uri("http-static:/var/www").unwrap();
643 assert_eq!(config.dir, PathBuf::from("/var/www"));
644 assert_eq!(config.mount_path, "/");
645 }
646
647 #[test]
648 fn test_mount_path_normalized_leading_slash() {
649 let config = HttpStaticConfig::from_uri("http-static:assets?dir=/var/www").unwrap();
650 assert_eq!(config.mount_path, "/assets");
651 }
652
653 #[test]
654 fn test_mount_path_normalized_no_trailing_slash() {
655 let config = HttpStaticConfig::from_uri("http-static:/assets/?dir=/var/www").unwrap();
656 assert_eq!(config.mount_path, "/assets");
657 }
658
659 #[test]
660 fn test_mount_path_root_stays_root() {
661 let config = HttpStaticConfig::from_uri("http-static:/?dir=/var/www").unwrap();
662 assert_eq!(config.mount_path, "/");
663 }
664
665 #[test]
666 fn test_mount_path_nested() {
667 let config = HttpStaticConfig::from_uri("http-static:/assets/sub?dir=/var/www").unwrap();
668 assert_eq!(config.mount_path, "/assets/sub");
669 }
670
671 #[test]
672 fn test_from_uri_with_defaults_mount_path_with_dir_param() {
673 let toml_defaults = HttpStaticConfig {
674 dir: PathBuf::from("/default/dir"),
675 ..HttpStaticConfig::default()
676 };
677
678 let config = HttpStaticConfig::from_uri_with_defaults(
679 "http-static:/assets?dir=/var/www&port=3000",
680 &toml_defaults,
681 )
682 .unwrap();
683
684 assert_eq!(config.dir, PathBuf::from("/var/www"));
685 assert_eq!(config.mount_path, "/assets");
686 assert_eq!(config.port, 3000);
687 }
688
689 #[test]
694 fn test_dir_pathbuf_accepts_various_paths() {
695 let config = HttpStaticConfig::from_uri("http-static:./frontend/dist").unwrap();
697 assert_eq!(config.dir, PathBuf::from("./frontend/dist"));
698 assert_eq!(config.mount_path, "/");
699
700 let config = HttpStaticConfig::from_uri("http-static:/var/www/html").unwrap();
702 assert_eq!(config.dir, PathBuf::from("/var/www/html"));
703 assert_eq!(config.mount_path, "/");
704
705 let config = HttpStaticConfig::from_uri("http-static:/app/my%20files").unwrap();
707 assert_eq!(config.dir, PathBuf::from("/app/my files"));
708 assert_eq!(config.mount_path, "/");
709 }
710
711 #[test]
712 fn test_dir_nonexistent_is_detectable() {
713 let config =
717 HttpStaticConfig::from_uri("http-static:/nonexistent/path/that/does/not/exist")
718 .unwrap();
719 assert_eq!(
720 config.dir,
721 PathBuf::from("/nonexistent/path/that/does/not/exist")
722 );
723 }
725
726 #[test]
727 fn uri_options_count_parity() {
728 assert_eq!(
729 HttpStaticConfig::uri_options().len(),
730 5,
731 "HttpStaticUriConfig #[uri_param] count drifted from parser"
732 );
733 }
734}