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        #[allow(dead_code)]
291        name: String,
292        #[uri_param]
293        flag: Option<bool>,
294    }
295
296    #[derive(Debug, Clone, UriConfig)]
297    #[uri_scheme = "booltest2"]
298    struct BoolDefaultConfig {
299        #[allow(dead_code)]
300        name: String,
301        #[uri_param(default = "false")]
302        enabled: bool,
303    }
304
305    #[test]
306    fn test_bool_case_insensitive_true_variants() {
307        for val in &["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
308            let uri = format!("booltest:foo?flag={}", val);
309            let config = BoolCaseConfig::from_uri(&uri).unwrap_or_else(|e| {
310                panic!("Failed to parse flag='{}' from URI '{}': {}", val, uri, e)
311            });
312            assert_eq!(
313                config.flag,
314                Some(true),
315                "flag='{}' should parse to Some(true)",
316                val
317            );
318        }
319    }
320
321    #[test]
322    fn test_bool_case_insensitive_false_variants() {
323        for val in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
324            let uri = format!("booltest:foo?flag={}", val);
325            let config = BoolCaseConfig::from_uri(&uri).unwrap_or_else(|e| {
326                panic!("Failed to parse flag='{}' from URI '{}': {}", val, uri, e)
327            });
328            assert_eq!(
329                config.flag,
330                Some(false),
331                "flag='{}' should parse to Some(false)",
332                val
333            );
334        }
335    }
336
337    #[test]
338    fn test_bool_invalid_returns_error() {
339        let result = BoolCaseConfig::from_uri("booltest:foo?flag=maybe");
340        assert!(result.is_err());
341        if let Err(CamelError::InvalidUri(msg)) = result {
342            assert!(
343                msg.contains("invalid boolean value"),
344                "Error should mention invalid boolean, got: {}",
345                msg
346            );
347        } else {
348            panic!("Expected InvalidUri error for invalid bool value");
349        }
350    }
351
352    #[test]
353    fn test_bool_default_case_insensitive() {
354        // Override default with various case variants
355        for val in &["TRUE", "1", "YES"] {
356            let uri = format!("booltest2:bar?enabled={}", val);
357            let config = BoolDefaultConfig::from_uri(&uri).unwrap();
358            assert!(config.enabled, "enabled='{}' should be true", val);
359        }
360        for val in &["FALSE", "0", "NO"] {
361            let uri = format!("booltest2:bar?enabled={}", val);
362            let config = BoolDefaultConfig::from_uri(&uri).unwrap();
363            assert!(!config.enabled, "enabled='{}' should be false", val);
364        }
365    }
366
367    // Test: Issue 3 - Generic type fallback should include parse error in message
368    #[derive(Debug, Clone, PartialEq, Eq)]
369    enum TestEnum {
370        Alpha,
371        Beta,
372    }
373
374    impl std::str::FromStr for TestEnum {
375        type Err = String;
376
377        fn from_str(s: &str) -> Result<Self, Self::Err> {
378            match s {
379                "alpha" => Ok(TestEnum::Alpha),
380                "beta" => Ok(TestEnum::Beta),
381                _ => Err(format!("unknown variant: {}", s)),
382            }
383        }
384    }
385
386    #[derive(Debug, Clone, UriConfig)]
387    #[uri_scheme = "enumtest"]
388    struct EnumConfig {
389        path: String,
390        #[uri_param]
391        mode: TestEnum,
392    }
393
394    #[test]
395    fn test_enum_invalid_value_includes_error() {
396        let result = EnumConfig::from_uri("enumtest:foo?mode=invalid");
397        assert!(result.is_err());
398        if let Err(CamelError::InvalidUri(msg)) = result {
399            assert!(
400                msg.contains("invalid value"),
401                "Error should mention invalid value, got: {}",
402                msg
403            );
404            // The error should include the actual parse error from FromStr
405            assert!(
406                msg.contains("unknown variant"),
407                "Error should include parse error details, got: {}",
408                msg
409            );
410            assert!(
411                msg.contains("invalid"),
412                "Error should include the invalid value, got: {}",
413                msg
414            );
415        } else {
416            panic!("Expected InvalidUri error for invalid enum value");
417        }
418    }
419
420    #[test]
421    fn test_enum_valid_value_works() {
422        let config = EnumConfig::from_uri("enumtest:foo?mode=alpha").unwrap();
423        assert_eq!(config.path, "foo");
424        assert_eq!(config.mode, TestEnum::Alpha);
425        let config = EnumConfig::from_uri("enumtest:foo?mode=beta").unwrap();
426        assert_eq!(config.path, "foo");
427        assert_eq!(config.mode, TestEnum::Beta);
428    }
429
430    // Duration type support tests
431    #[derive(Debug, Clone, UriConfig)]
432    #[uri_scheme = "timer"]
433    struct TimerTestConfig {
434        name: String,
435
436        #[uri_param(default = "1000")]
437        period_ms: u64,
438
439        period: std::time::Duration,
440    }
441
442    #[test]
443    fn test_duration_from_ms_field() {
444        let config = TimerTestConfig::from_uri("timer:tick?period_ms=500").unwrap();
445        assert_eq!(config.name, "tick");
446        assert_eq!(config.period, std::time::Duration::from_millis(500));
447    }
448
449    #[test]
450    fn test_duration_uses_default() {
451        let config = TimerTestConfig::from_uri("timer:tick").unwrap();
452        assert_eq!(config.name, "tick");
453        assert_eq!(config.period_ms, 1000);
454        assert_eq!(config.period, std::time::Duration::from_millis(1000));
455    }
456
457    // Test Duration with multiple Duration fields
458    #[derive(Debug, Clone, UriConfig)]
459    #[uri_scheme = "scheduler"]
460    struct SchedulerConfig {
461        task_name: String,
462
463        #[uri_param(default = "5000")]
464        initial_delay_ms: u64,
465
466        #[uri_param(default = "10000")]
467        interval_ms: u64,
468
469        initial_delay: std::time::Duration,
470        interval: std::time::Duration,
471    }
472
473    #[test]
474    fn test_multiple_duration_fields() {
475        let config =
476            SchedulerConfig::from_uri("scheduler:cleanup?initial_delay_ms=2000&interval_ms=3000")
477                .unwrap();
478        assert_eq!(config.task_name, "cleanup");
479        assert_eq!(config.initial_delay_ms, 2000);
480        assert_eq!(config.interval_ms, 3000);
481        assert_eq!(config.initial_delay, std::time::Duration::from_millis(2000));
482        assert_eq!(config.interval, std::time::Duration::from_millis(3000));
483    }
484
485    #[test]
486    fn test_multiple_duration_defaults() {
487        let config = SchedulerConfig::from_uri("scheduler:cleanup").unwrap();
488        assert_eq!(config.task_name, "cleanup");
489        assert_eq!(config.initial_delay_ms, 5000);
490        assert_eq!(config.interval_ms, 10000);
491        assert_eq!(config.initial_delay, std::time::Duration::from_millis(5000));
492        assert_eq!(config.interval, std::time::Duration::from_millis(10000));
493    }
494}