Skip to main content

camel_endpoint/
config.rs

1use camel_api::CamelError;
2
3use crate::UriComponents;
4
5/// Trait for configuration types that can be parsed from Camel URIs.
6///
7/// This trait is typically implemented via the `#[derive(UriConfig)]` macro
8/// from `camel-endpoint-macros`.
9pub trait UriConfig: Sized {
10    /// Returns the URI scheme this config handles (e.g., "timer", "http").
11    fn scheme() -> &'static str;
12
13    /// Parse a URI string into this configuration.
14    fn from_uri(uri: &str) -> Result<Self, CamelError>;
15
16    /// Parse already-extracted URI components into this configuration.
17    fn from_components(parts: UriComponents) -> Result<Self, CamelError>;
18
19    /// Override to add validation logic after parsing.
20    fn validate(self) -> Result<Self, CamelError> {
21        Ok(self)
22    }
23}
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28
29    #[test]
30    fn test_trait_exists() {
31        // Just verify the trait is defined
32        fn _uses_trait<T: UriConfig>() {}
33    }
34}
35
36#[cfg(test)]
37mod derive_tests {
38    use super::*;
39    use crate::UriConfig;
40
41    // Allow the derive macro to reference `camel_endpoint::` when used within this crate
42    extern crate self as camel_endpoint;
43
44    // Simple config with just path
45    #[derive(Debug, Clone, UriConfig)]
46    #[uri_scheme = "test"]
47    struct SimpleConfig {
48        name: String,
49    }
50
51    #[test]
52    fn test_simple_path_extraction() {
53        let config = SimpleConfig::from_uri("test:hello").unwrap();
54        assert_eq!(config.name, "hello");
55    }
56
57    #[test]
58    fn test_simple_scheme() {
59        assert_eq!(SimpleConfig::scheme(), "test");
60    }
61
62    // Config with parameters and defaults
63    #[derive(Debug, Clone, UriConfig)]
64    #[uri_scheme = "test"]
65    struct ConfigWithParams {
66        name: String,
67        #[uri_param(default = "1000")]
68        timeout: u64,
69        #[uri_param(default = "true")]
70        enabled: bool,
71    }
72
73    #[test]
74    fn test_params_with_defaults() {
75        let config = ConfigWithParams::from_uri("test:foo?timeout=500").unwrap();
76        assert_eq!(config.name, "foo");
77        assert_eq!(config.timeout, 500);
78        assert!(config.enabled); // uses default
79    }
80
81    #[test]
82    fn test_params_all_specified() {
83        let config = ConfigWithParams::from_uri("test:bar?timeout=2000&enabled=false").unwrap();
84        assert_eq!(config.name, "bar");
85        assert_eq!(config.timeout, 2000);
86        assert!(!config.enabled);
87    }
88
89    #[test]
90    fn test_scheme_validation() {
91        let result = SimpleConfig::from_uri("wrong:hello");
92        assert!(result.is_err());
93        if let Err(CamelError::InvalidUri(msg)) = result {
94            assert!(msg.contains("expected scheme 'test'"));
95            assert!(msg.contains("got 'wrong'"));
96        } else {
97            panic!("Expected InvalidUri error");
98        }
99    }
100
101    // Config with Option fields
102    #[derive(Debug, Clone, UriConfig)]
103    #[uri_scheme = "timer"]
104    struct TimerConfig {
105        timer_name: String,
106        #[uri_param]
107        period: Option<u64>,
108        #[uri_param]
109        repeat: Option<bool>,
110        #[uri_param]
111        description: Option<String>,
112    }
113
114    #[test]
115    fn test_option_params_present() {
116        let config =
117            TimerConfig::from_uri("timer:tick?period=1000&repeat=true&description=hello").unwrap();
118        assert_eq!(config.timer_name, "tick");
119        assert_eq!(config.period, Some(1000));
120        assert_eq!(config.repeat, Some(true));
121        assert_eq!(config.description, Some("hello".to_string()));
122    }
123
124    #[test]
125    fn test_option_params_absent() {
126        let config = TimerConfig::from_uri("timer:tick").unwrap();
127        assert_eq!(config.timer_name, "tick");
128        assert_eq!(config.period, None);
129        assert_eq!(config.repeat, None);
130        assert_eq!(config.description, None);
131    }
132
133    // Config with custom param names
134    #[derive(Debug, Clone, UriConfig)]
135    #[uri_scheme = "http"]
136    struct HttpConfig {
137        url: String,
138        #[uri_param(name = "httpMethod")]
139        method: Option<String>,
140        #[uri_param(name = "connectTimeout", default = "5000")]
141        timeout_ms: u64,
142    }
143
144    #[test]
145    fn test_custom_param_names() {
146        let config =
147            HttpConfig::from_uri("http://example.com?httpMethod=POST&connectTimeout=10000")
148                .unwrap();
149        assert_eq!(config.url, "//example.com");
150        assert_eq!(config.method, Some("POST".to_string()));
151        assert_eq!(config.timeout_ms, 10000);
152    }
153
154    #[test]
155    fn test_custom_param_name_default() {
156        let config = HttpConfig::from_uri("http://example.com").unwrap();
157        assert_eq!(config.url, "//example.com");
158        assert_eq!(config.method, None);
159        assert_eq!(config.timeout_ms, 5000); // default
160    }
161
162    // Config with multiple numeric types
163    #[derive(Debug, Clone, UriConfig)]
164    #[uri_scheme = "data"]
165    struct NumericConfig {
166        path: String,
167        #[uri_param(default = "100")]
168        count_u32: u32,
169        #[uri_param(default = "1000")]
170        count_u64: u64,
171        #[uri_param(default = "10")]
172        count_usize: usize,
173        #[uri_param(default = "-5")]
174        offset_i32: i32,
175    }
176
177    #[test]
178    fn test_numeric_types() {
179        let config = NumericConfig::from_uri(
180            "data:test?count_u32=50&count_u64=500&count_usize=5&offset_i32=-10",
181        )
182        .unwrap();
183        assert_eq!(config.path, "test");
184        assert_eq!(config.count_u32, 50);
185        assert_eq!(config.count_u64, 500);
186        assert_eq!(config.count_usize, 5);
187        assert_eq!(config.offset_i32, -10);
188    }
189
190    #[test]
191    fn test_numeric_defaults() {
192        let config = NumericConfig::from_uri("data:test").unwrap();
193        assert_eq!(config.count_u32, 100);
194        assert_eq!(config.count_u64, 1000);
195        assert_eq!(config.count_usize, 10);
196        assert_eq!(config.offset_i32, -5);
197    }
198
199    #[test]
200    fn test_invalid_numeric_value() {
201        let result = NumericConfig::from_uri("data:test?count_u32=abc");
202        assert!(result.is_err());
203    }
204
205    // Test from_components directly
206    #[test]
207    fn test_from_components() {
208        let components = UriComponents {
209            scheme: "test".to_string(),
210            path: "hello".to_string(),
211            params: std::collections::HashMap::from([
212                ("timeout".to_string(), "500".to_string()),
213                ("enabled".to_string(), "false".to_string()),
214            ]),
215        };
216
217        let config = ConfigWithParams::from_components(components).unwrap();
218        assert_eq!(config.name, "hello");
219        assert_eq!(config.timeout, 500);
220        assert!(!config.enabled);
221    }
222
223    // Test with validate
224    #[test]
225    fn test_validate_passthrough() {
226        let config = SimpleConfig::from_uri("test:hello")
227            .unwrap()
228            .validate()
229            .unwrap();
230        assert_eq!(config.name, "hello");
231    }
232
233    // Test: Issue 1 - Non-Option bool without default should error when missing
234    #[derive(Debug, Clone, UriConfig)]
235    #[uri_scheme = "feature"]
236    struct FeatureConfig {
237        feature_name: String,
238        #[uri_param]
239        enabled: bool, // No default - should require the parameter
240    }
241
242    #[test]
243    fn test_bool_without_default_missing_errors() {
244        // Should error because 'enabled' is required but not provided
245        let result = FeatureConfig::from_uri("feature:test");
246        assert!(result.is_err());
247        if let Err(CamelError::InvalidUri(msg)) = result {
248            assert!(
249                msg.contains("missing required parameter"),
250                "Error should mention missing parameter, got: {}",
251                msg
252            );
253            assert!(msg.contains("enabled"), "Error should mention 'enabled'");
254        } else {
255            panic!("Expected InvalidUri error for missing bool parameter");
256        }
257    }
258
259    #[test]
260    fn test_bool_without_default_provided_works() {
261        let config = FeatureConfig::from_uri("feature:test?enabled=true").unwrap();
262        assert_eq!(config.feature_name, "test");
263        assert!(config.enabled);
264        let config = FeatureConfig::from_uri("feature:test?enabled=false").unwrap();
265        assert_eq!(config.feature_name, "test");
266        assert!(!config.enabled);
267    }
268
269    // Test: EMAC-002 - Option<u64> with invalid value should return error, not silently None
270    #[test]
271    fn test_option_numeric_invalid_returns_error() {
272        // Invalid numeric value should propagate an error
273        let result = TimerConfig::from_uri("timer:tick?period=invalid");
274        assert!(result.is_err());
275        if let Err(CamelError::InvalidUri(msg)) = result {
276            assert!(
277                msg.contains("invalid value for period"),
278                "Error should mention the invalid param, got: {}",
279                msg
280            );
281        } else {
282            panic!("Expected InvalidUri error for invalid numeric Option value");
283        }
284    }
285
286    // Test: EMAC-003 - Boolean parsing is case-insensitive and accepts 1/0/yes/no
287    #[derive(Debug, Clone, UriConfig)]
288    #[uri_scheme = "booltest"]
289    struct BoolCaseConfig {
290        name: String,
291        #[uri_param]
292        flag: Option<bool>,
293    }
294
295    #[derive(Debug, Clone, UriConfig)]
296    #[uri_scheme = "booltest2"]
297    struct BoolDefaultConfig {
298        name: String,
299        #[uri_param(default = "false")]
300        enabled: bool,
301    }
302
303    #[test]
304    fn test_bool_case_insensitive_true_variants() {
305        for val in &["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
306            let uri = format!("booltest:foo?flag={}", val);
307            let config = BoolCaseConfig::from_uri(&uri).unwrap_or_else(|e| {
308                panic!("Failed to parse flag='{}' from URI '{}': {}", val, uri, e)
309            });
310            assert_eq!(
311                config.flag,
312                Some(true),
313                "flag='{}' should parse to Some(true)",
314                val
315            );
316        }
317    }
318
319    #[test]
320    fn test_bool_case_insensitive_false_variants() {
321        for val in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
322            let uri = format!("booltest:foo?flag={}", val);
323            let config = BoolCaseConfig::from_uri(&uri).unwrap_or_else(|e| {
324                panic!("Failed to parse flag='{}' from URI '{}': {}", val, uri, e)
325            });
326            assert_eq!(
327                config.flag,
328                Some(false),
329                "flag='{}' should parse to Some(false)",
330                val
331            );
332        }
333    }
334
335    #[test]
336    fn test_bool_invalid_returns_error() {
337        let result = BoolCaseConfig::from_uri("booltest:foo?flag=maybe");
338        assert!(result.is_err());
339        if let Err(CamelError::InvalidUri(msg)) = result {
340            assert!(
341                msg.contains("invalid boolean value"),
342                "Error should mention invalid boolean, got: {}",
343                msg
344            );
345        } else {
346            panic!("Expected InvalidUri error for invalid bool value");
347        }
348    }
349
350    #[test]
351    fn test_bool_default_case_insensitive() {
352        // Override default with various case variants
353        for val in &["TRUE", "1", "YES"] {
354            let uri = format!("booltest2:bar?enabled={}", val);
355            let config = BoolDefaultConfig::from_uri(&uri).unwrap();
356            assert!(config.enabled, "enabled='{}' should be true", val);
357        }
358        for val in &["FALSE", "0", "NO"] {
359            let uri = format!("booltest2:bar?enabled={}", val);
360            let config = BoolDefaultConfig::from_uri(&uri).unwrap();
361            assert!(!config.enabled, "enabled='{}' should be false", val);
362        }
363    }
364
365    // Test: Issue 3 - Generic type fallback should include parse error in message
366    #[derive(Debug, Clone, PartialEq, Eq)]
367    enum TestEnum {
368        Alpha,
369        Beta,
370    }
371
372    impl std::str::FromStr for TestEnum {
373        type Err = String;
374
375        fn from_str(s: &str) -> Result<Self, Self::Err> {
376            match s {
377                "alpha" => Ok(TestEnum::Alpha),
378                "beta" => Ok(TestEnum::Beta),
379                _ => Err(format!("unknown variant: {}", s)),
380            }
381        }
382    }
383
384    #[derive(Debug, Clone, UriConfig)]
385    #[uri_scheme = "enumtest"]
386    struct EnumConfig {
387        path: String,
388        #[uri_param]
389        mode: TestEnum,
390    }
391
392    #[test]
393    fn test_enum_invalid_value_includes_error() {
394        let result = EnumConfig::from_uri("enumtest:foo?mode=invalid");
395        assert!(result.is_err());
396        if let Err(CamelError::InvalidUri(msg)) = result {
397            assert!(
398                msg.contains("invalid value"),
399                "Error should mention invalid value, got: {}",
400                msg
401            );
402            // The error should include the actual parse error from FromStr
403            assert!(
404                msg.contains("unknown variant"),
405                "Error should include parse error details, got: {}",
406                msg
407            );
408            assert!(
409                msg.contains("invalid"),
410                "Error should include the invalid value, got: {}",
411                msg
412            );
413        } else {
414            panic!("Expected InvalidUri error for invalid enum value");
415        }
416    }
417
418    #[test]
419    fn test_enum_valid_value_works() {
420        let config = EnumConfig::from_uri("enumtest:foo?mode=alpha").unwrap();
421        assert_eq!(config.path, "foo");
422        assert_eq!(config.mode, TestEnum::Alpha);
423        let config = EnumConfig::from_uri("enumtest:foo?mode=beta").unwrap();
424        assert_eq!(config.path, "foo");
425        assert_eq!(config.mode, TestEnum::Beta);
426    }
427
428    // Duration type support tests
429    #[derive(Debug, Clone, UriConfig)]
430    #[uri_scheme = "timer"]
431    struct TimerTestConfig {
432        name: String,
433
434        #[uri_param(default = "1000")]
435        period_ms: u64,
436
437        period: std::time::Duration,
438    }
439
440    #[test]
441    fn test_duration_from_ms_field() {
442        let config = TimerTestConfig::from_uri("timer:tick?period_ms=500").unwrap();
443        assert_eq!(config.name, "tick");
444        assert_eq!(config.period, std::time::Duration::from_millis(500));
445    }
446
447    #[test]
448    fn test_duration_uses_default() {
449        let config = TimerTestConfig::from_uri("timer:tick").unwrap();
450        assert_eq!(config.name, "tick");
451        assert_eq!(config.period_ms, 1000);
452        assert_eq!(config.period, std::time::Duration::from_millis(1000));
453    }
454
455    // Test Duration with multiple Duration fields
456    #[derive(Debug, Clone, UriConfig)]
457    #[uri_scheme = "scheduler"]
458    struct SchedulerConfig {
459        task_name: String,
460
461        #[uri_param(default = "5000")]
462        initial_delay_ms: u64,
463
464        #[uri_param(default = "10000")]
465        interval_ms: u64,
466
467        initial_delay: std::time::Duration,
468        interval: std::time::Duration,
469    }
470
471    #[test]
472    fn test_multiple_duration_fields() {
473        let config =
474            SchedulerConfig::from_uri("scheduler:cleanup?initial_delay_ms=2000&interval_ms=3000")
475                .unwrap();
476        assert_eq!(config.task_name, "cleanup");
477        assert_eq!(config.initial_delay_ms, 2000);
478        assert_eq!(config.interval_ms, 3000);
479        assert_eq!(config.initial_delay, std::time::Duration::from_millis(2000));
480        assert_eq!(config.interval, std::time::Duration::from_millis(3000));
481    }
482
483    #[test]
484    fn test_multiple_duration_defaults() {
485        let config = SchedulerConfig::from_uri("scheduler:cleanup").unwrap();
486        assert_eq!(config.task_name, "cleanup");
487        assert_eq!(config.initial_delay_ms, 5000);
488        assert_eq!(config.interval_ms, 10000);
489        assert_eq!(config.initial_delay, std::time::Duration::from_millis(5000));
490        assert_eq!(config.interval, std::time::Duration::from_millis(10000));
491    }
492}