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            raw_query: None,
216        };
217
218        let config = ConfigWithParams::from_components(components).unwrap();
219        assert_eq!(config.name, "hello");
220        assert_eq!(config.timeout, 500);
221        assert!(!config.enabled);
222    }
223
224    // Test with validate
225    #[test]
226    fn test_validate_passthrough() {
227        let config = SimpleConfig::from_uri("test:hello")
228            .unwrap()
229            .validate()
230            .unwrap();
231        assert_eq!(config.name, "hello");
232    }
233
234    // Test: Issue 1 - Non-Option bool without default should error when missing
235    #[derive(Debug, Clone, UriConfig)]
236    #[uri_scheme = "feature"]
237    struct FeatureConfig {
238        feature_name: String,
239        #[uri_param]
240        enabled: bool, // No default - should require the parameter
241    }
242
243    #[test]
244    fn test_bool_without_default_missing_errors() {
245        // Should error because 'enabled' is required but not provided
246        let result = FeatureConfig::from_uri("feature:test");
247        assert!(result.is_err());
248        if let Err(CamelError::InvalidUri(msg)) = result {
249            assert!(
250                msg.contains("missing required parameter"),
251                "Error should mention missing parameter, got: {}",
252                msg
253            );
254            assert!(msg.contains("enabled"), "Error should mention 'enabled'");
255        } else {
256            panic!("Expected InvalidUri error for missing bool parameter");
257        }
258    }
259
260    #[test]
261    fn test_bool_without_default_provided_works() {
262        let config = FeatureConfig::from_uri("feature:test?enabled=true").unwrap();
263        assert_eq!(config.feature_name, "test");
264        assert!(config.enabled);
265        let config = FeatureConfig::from_uri("feature:test?enabled=false").unwrap();
266        assert_eq!(config.feature_name, "test");
267        assert!(!config.enabled);
268    }
269
270    // Test: EMAC-002 - Option<u64> with invalid value should return error, not silently None
271    #[test]
272    fn test_option_numeric_invalid_returns_error() {
273        // Invalid numeric value should propagate an error
274        let result = TimerConfig::from_uri("timer:tick?period=invalid");
275        assert!(result.is_err());
276        if let Err(CamelError::InvalidUri(msg)) = result {
277            assert!(
278                msg.contains("invalid value for period"),
279                "Error should mention the invalid param, got: {}",
280                msg
281            );
282        } else {
283            panic!("Expected InvalidUri error for invalid numeric Option value");
284        }
285    }
286
287    // Test: EMAC-003 - Boolean parsing is case-insensitive and accepts 1/0/yes/no
288    #[derive(Debug, Clone, UriConfig)]
289    #[uri_scheme = "booltest"]
290    struct BoolCaseConfig {
291        #[allow(dead_code)]
292        name: String,
293        #[uri_param]
294        flag: Option<bool>,
295    }
296
297    #[derive(Debug, Clone, UriConfig)]
298    #[uri_scheme = "booltest2"]
299    struct BoolDefaultConfig {
300        #[allow(dead_code)]
301        name: String,
302        #[uri_param(default = "false")]
303        enabled: bool,
304    }
305
306    #[test]
307    fn test_bool_case_insensitive_true_variants() {
308        for val in &["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
309            let uri = format!("booltest:foo?flag={}", val);
310            let config = BoolCaseConfig::from_uri(&uri).unwrap_or_else(|e| {
311                panic!("Failed to parse flag='{}' from URI '{}': {}", val, uri, e)
312            });
313            assert_eq!(
314                config.flag,
315                Some(true),
316                "flag='{}' should parse to Some(true)",
317                val
318            );
319        }
320    }
321
322    #[test]
323    fn test_bool_case_insensitive_false_variants() {
324        for val in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
325            let uri = format!("booltest:foo?flag={}", val);
326            let config = BoolCaseConfig::from_uri(&uri).unwrap_or_else(|e| {
327                panic!("Failed to parse flag='{}' from URI '{}': {}", val, uri, e)
328            });
329            assert_eq!(
330                config.flag,
331                Some(false),
332                "flag='{}' should parse to Some(false)",
333                val
334            );
335        }
336    }
337
338    #[test]
339    fn test_bool_invalid_returns_error() {
340        let result = BoolCaseConfig::from_uri("booltest:foo?flag=maybe");
341        assert!(result.is_err());
342        if let Err(CamelError::InvalidUri(msg)) = result {
343            assert!(
344                msg.contains("invalid boolean value"),
345                "Error should mention invalid boolean, got: {}",
346                msg
347            );
348        } else {
349            panic!("Expected InvalidUri error for invalid bool value");
350        }
351    }
352
353    #[test]
354    fn test_bool_default_case_insensitive() {
355        // Override default with various case variants
356        for val in &["TRUE", "1", "YES"] {
357            let uri = format!("booltest2:bar?enabled={}", val);
358            let config = BoolDefaultConfig::from_uri(&uri).unwrap();
359            assert!(config.enabled, "enabled='{}' should be true", val);
360        }
361        for val in &["FALSE", "0", "NO"] {
362            let uri = format!("booltest2:bar?enabled={}", val);
363            let config = BoolDefaultConfig::from_uri(&uri).unwrap();
364            assert!(!config.enabled, "enabled='{}' should be false", val);
365        }
366    }
367
368    // Test: Issue 3 - Generic type fallback should include parse error in message
369    #[derive(Debug, Clone, PartialEq, Eq)]
370    enum TestEnum {
371        Alpha,
372        Beta,
373    }
374
375    impl std::str::FromStr for TestEnum {
376        type Err = String;
377
378        fn from_str(s: &str) -> Result<Self, Self::Err> {
379            match s {
380                "alpha" => Ok(TestEnum::Alpha),
381                "beta" => Ok(TestEnum::Beta),
382                _ => Err(format!("unknown variant: {}", s)),
383            }
384        }
385    }
386
387    #[derive(Debug, Clone, UriConfig)]
388    #[uri_scheme = "enumtest"]
389    struct EnumConfig {
390        path: String,
391        #[uri_param]
392        mode: TestEnum,
393    }
394
395    #[test]
396    fn test_enum_invalid_value_includes_error() {
397        let result = EnumConfig::from_uri("enumtest:foo?mode=invalid");
398        assert!(result.is_err());
399        if let Err(CamelError::InvalidUri(msg)) = result {
400            assert!(
401                msg.contains("invalid value"),
402                "Error should mention invalid value, got: {}",
403                msg
404            );
405            // The error should include the actual parse error from FromStr
406            assert!(
407                msg.contains("unknown variant"),
408                "Error should include parse error details, got: {}",
409                msg
410            );
411            assert!(
412                msg.contains("invalid"),
413                "Error should include the invalid value, got: {}",
414                msg
415            );
416        } else {
417            panic!("Expected InvalidUri error for invalid enum value");
418        }
419    }
420
421    #[test]
422    fn test_enum_valid_value_works() {
423        let config = EnumConfig::from_uri("enumtest:foo?mode=alpha").unwrap();
424        assert_eq!(config.path, "foo");
425        assert_eq!(config.mode, TestEnum::Alpha);
426        let config = EnumConfig::from_uri("enumtest:foo?mode=beta").unwrap();
427        assert_eq!(config.path, "foo");
428        assert_eq!(config.mode, TestEnum::Beta);
429    }
430
431    // Duration type support tests
432    #[derive(Debug, Clone, UriConfig)]
433    #[uri_scheme = "timer"]
434    struct TimerTestConfig {
435        name: String,
436
437        #[uri_param(default = "1000")]
438        period_ms: u64,
439
440        period: std::time::Duration,
441    }
442
443    #[test]
444    fn test_duration_from_ms_field() {
445        let config = TimerTestConfig::from_uri("timer:tick?period_ms=500").unwrap();
446        assert_eq!(config.name, "tick");
447        assert_eq!(config.period, std::time::Duration::from_millis(500));
448    }
449
450    #[test]
451    fn test_duration_uses_default() {
452        let config = TimerTestConfig::from_uri("timer:tick").unwrap();
453        assert_eq!(config.name, "tick");
454        assert_eq!(config.period_ms, 1000);
455        assert_eq!(config.period, std::time::Duration::from_millis(1000));
456    }
457
458    // Test Duration with multiple Duration fields
459    #[derive(Debug, Clone, UriConfig)]
460    #[uri_scheme = "scheduler"]
461    struct SchedulerConfig {
462        task_name: String,
463
464        #[uri_param(default = "5000")]
465        initial_delay_ms: u64,
466
467        #[uri_param(default = "10000")]
468        interval_ms: u64,
469
470        initial_delay: std::time::Duration,
471        interval: std::time::Duration,
472    }
473
474    #[test]
475    fn test_multiple_duration_fields() {
476        let config =
477            SchedulerConfig::from_uri("scheduler:cleanup?initial_delay_ms=2000&interval_ms=3000")
478                .unwrap();
479        assert_eq!(config.task_name, "cleanup");
480        assert_eq!(config.initial_delay_ms, 2000);
481        assert_eq!(config.interval_ms, 3000);
482        assert_eq!(config.initial_delay, std::time::Duration::from_millis(2000));
483        assert_eq!(config.interval, std::time::Duration::from_millis(3000));
484    }
485
486    #[test]
487    fn test_multiple_duration_defaults() {
488        let config = SchedulerConfig::from_uri("scheduler:cleanup").unwrap();
489        assert_eq!(config.task_name, "cleanup");
490        assert_eq!(config.initial_delay_ms, 5000);
491        assert_eq!(config.interval_ms, 10000);
492        assert_eq!(config.initial_delay, std::time::Duration::from_millis(5000));
493        assert_eq!(config.interval, std::time::Duration::from_millis(10000));
494    }
495}