Skip to main content

camel_component_http/
static_config.rs

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/// Configuration for an `http-static:` server endpoint.
10///
11/// Parsed from either a Camel URI (`http-static:/path?port=8080&spaFallback=true`)
12/// or a Camel.toml profile section (`[default.components.http-static]`).
13///
14/// # Config Resolution
15///
16/// Camel.toml profile defaults are applied first, then URI params override
17/// individual fields. Use [`HttpStaticConfig::from_uri_with_defaults`] for
18/// the merged resolution.
19#[derive(Debug, Clone, Deserialize)]
20pub struct HttpStaticConfig {
21    /// Root directory to serve static files from.
22    pub dir: PathBuf,
23
24    /// TCP port to listen on.
25    #[serde(default = "default_port")]
26    pub port: u16,
27
28    /// Bind address.
29    #[serde(default = "default_host")]
30    pub host: String,
31
32    /// Serve `index.html` for unmatched paths (SPA mode).
33    #[serde(rename = "spaFallback", default)]
34    pub spa_fallback: bool,
35
36    /// Cache-Control header value applied to all static responses.
37    #[serde(rename = "cacheControl", default = "default_cache_control")]
38    pub cache_control: String,
39
40    /// Custom error pages mapped by HTTP status code.
41    #[serde(
42        rename = "errorPages",
43        default,
44        deserialize_with = "deserialize_error_pages"
45    )]
46    pub error_pages: HashMap<u16, PathBuf>,
47
48    /// URL path prefix this mount serves on (e.g. "/assets").
49    /// Derived from the URI path in `http-static:/assets?dir=/var/www`.
50    #[serde(default = "default_mount_path")]
51    pub mount_path: String,
52}
53
54fn default_port() -> u16 {
55    8080
56}
57
58/// Custom deserializer for `errorPages` that accepts both string and integer
59/// keys. TOML bare keys are always strings (e.g. `404 = "404.html"`), so we
60/// parse string keys to u16. Integer keys are accepted for robustness (e.g.
61/// from other formats or inline tables).
62fn 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        // The URI path becomes the mount_path (URL prefix).
137        // If a `dir` query param is present, it overrides the directory.
138        // Otherwise, the URI path itself is the directory (backward compat).
139        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            // New style: URI path = mount_path, dir param = directory
146            let mount_path = normalize_mount_path(&parts.path);
147            (PathBuf::from(dir_param), mount_path)
148        } else if parts.path == "/" {
149            // Root mount path with no dir param: the URI path "/" is the mount
150            // point, not the directory. Require explicit dir when serving from root.
151            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            // Old style (backward compat): URI path = directory, mount_path = "/"
158            (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        // errorPages is not supported as a URI param (it's a map);
191        // it comes exclusively from Camel.toml.
192        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
206/// Normalize a mount path: ensure leading `/`, no trailing `/` (except root).
207fn 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    /// Resolve config from a URI, with TOML-provided defaults applied first.
224    ///
225    /// Fields present in the URI params override the corresponding values from
226    /// `toml_defaults`. Fields absent from the URI retain their TOML value.
227    ///
228    /// # Config Resolution Order
229    ///
230    /// 1. Start with `toml_defaults` (from Camel.toml profile)
231    /// 2. Override individual fields where URI params are present
232    /// 3. Apply hardcoded defaults for any remaining unset fields
233    pub fn from_uri_with_defaults(uri: &str, toml_defaults: &Self) -> Result<Self, CamelError> {
234        let parts = parse_uri(uri)?;
235
236        // Validate scheme to avoid accidentally accepting wrong URIs here.
237        if parts.scheme != "http-static" {
238            return Err(CamelError::InvalidUri(format!(
239                "expected scheme 'http-static', got '{}'",
240                parts.scheme
241            )));
242        }
243
244        // Start from TOML defaults
245        let mut config = toml_defaults.clone();
246
247        // URI path and dir param handling
248        if !parts.path.is_empty() {
249            if let Some(dir_param) = parts.params.get("dir") {
250                // New style: URI path = mount_path, dir param = directory
251                config.dir = PathBuf::from(dir_param);
252                config.mount_path = normalize_mount_path(&parts.path);
253            } else if parts.path == "/" {
254                // Root mount path with no dir param: preserve TOML dir,
255                // only set mount_path (which already defaults to "/").
256                // This handles routes like `http-static:/` where the TOML
257                // config provides the directory (e.g. `dir = "public"`).
258            } else {
259                // Old style (backward compat): URI path = directory
260                config.dir = PathBuf::from(&parts.path);
261                config.mount_path = default_mount_path();
262            }
263        }
264
265        // URI params override individual fields
266        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 URI had no path and TOML had no dir, apply hardcoded default
285        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
295/// Parse a boolean parameter, case-insensitively.
296///
297/// Accepts: "true"/"True"/"TRUE"/"1"/"yes" as true,
298///          "false"/"False"/"FALSE"/"0"/"no" as false.
299fn 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    // -----------------------------------------------------------------------
314    // URI parsing tests
315    // -----------------------------------------------------------------------
316
317    #[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    // -----------------------------------------------------------------------
421    // TOML / serde parsing tests
422    // -----------------------------------------------------------------------
423
424    #[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    // -----------------------------------------------------------------------
476    // from_uri_with_defaults tests (TOML → URI override)
477    // -----------------------------------------------------------------------
478
479    #[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        // URI overrides
498        assert_eq!(config.dir, PathBuf::from("/override/dir"));
499        assert_eq!(config.port, 3000);
500        assert!(config.spa_fallback);
501
502        // TOML defaults retained for unspecified fields
503        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        // URI only specifies the path (dir)
521        let config =
522            HttpStaticConfig::from_uri_with_defaults("http-static:/uri/dir", &toml_defaults)
523                .unwrap();
524
525        // dir comes from URI
526        assert_eq!(config.dir, PathBuf::from("/uri/dir"));
527        // Everything else from TOML
528        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(), // empty dir in TOML
539            ..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    // -----------------------------------------------------------------------
569    // mount_path tests
570    // -----------------------------------------------------------------------
571
572    #[test]
573    fn test_mount_path_from_uri_with_dir_param() {
574        // New style: URI path = mount_path, dir param = directory
575        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        // Old style: URI path = directory, mount_path = "/"
583        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    // -----------------------------------------------------------------------
631    // PathBuf parsing validation
632    // -----------------------------------------------------------------------
633
634    #[test]
635    fn test_dir_pathbuf_accepts_various_paths() {
636        // Relative path
637        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        // Absolute path
642        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        // Path with spaces (percent-encoded)
647        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        // Config parsing does NOT validate directory existence — that happens
655        // at consumer start() via std::fs::canonicalize(). This test confirms
656        // that a non-existent path parses successfully at the config layer.
657        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        // Validation happens at consumer start, not config parse time.
665    }
666}