Skip to main content

camel_component_validator/
config.rs

1use std::path::{Path, PathBuf};
2
3use camel_component_api::CamelError;
4use percent_encoding::percent_decode_str;
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum SchemaType {
8    Xml,
9    Json,
10    Yaml,
11    RelaxNg,
12    Schematron,
13}
14
15/// Maximum number of schemas retained in the XSD bridge cache before eviction.
16pub const DEFAULT_SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
17/// Default for `fail_on_null_body`.
18pub const DEFAULT_FAIL_ON_NULL_BODY: bool = true;
19/// Default for `fail_on_null_header`.
20pub const DEFAULT_FAIL_ON_NULL_HEADER: bool = true;
21
22#[derive(Debug, Clone)]
23pub struct ValidatorConfig {
24    pub schema_path: PathBuf,
25    pub schema_type: SchemaType,
26    /// Maximum allowed body size in bytes. Bodies exceeding this limit are
27    /// rejected before validation begins. `None` means no limit.
28    pub max_payload_bytes: Option<usize>,
29    /// Maximum number of entries in the XSD bridge schema cache. When the
30    /// cache exceeds this limit, all entries are evicted before inserting the
31    /// new one. Only relevant for XSD validation.
32    pub schema_cache_max_entries: usize,
33    /// When `true` (default), reject exchanges whose body is empty/null.
34    /// When `false`, empty bodies pass through without validation.
35    pub fail_on_null_body: bool,
36    /// When set, validate the value of this header instead of the body.
37    pub header_name: Option<String>,
38    /// When `true` (default) and `header_name` is set, reject exchanges where
39    /// the header is missing. When `false`, missing headers pass through.
40    pub fail_on_null_header: bool,
41}
42
43impl ValidatorConfig {
44    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
45        let without_scheme = uri.strip_prefix("validator:").ok_or_else(|| {
46            CamelError::InvalidUri(format!(
47                "invalid validator URI: must start with 'validator:' — got '{uri}'"
48            ))
49        })?;
50
51        let (path_str, query) = match without_scheme.find('?') {
52            Some(idx) => (&without_scheme[..idx], Some(&without_scheme[idx + 1..])),
53            None => (without_scheme, None),
54        };
55
56        if path_str.is_empty() {
57            return Err(CamelError::InvalidUri(
58                "validator URI must specify a schema path".to_string(),
59            ));
60        }
61
62        validate_percent_encoding(path_str)?;
63        let decoded_path = percent_decode_str(path_str)
64            .decode_utf8()
65            .map_err(|e| CamelError::InvalidUri(format!("invalid UTF-8 in path: {e}")))?;
66        let schema_path = PathBuf::from(decoded_path.as_ref());
67
68        let schema_type = if let Some(q) = query {
69            let type_val = q.split('&').find_map(|kv| kv.strip_prefix("type="));
70            match type_val {
71                Some("xml") | Some("xml-schema") | Some("xsd") => SchemaType::Xml,
72                Some("json") | Some("json-schema") => SchemaType::Json,
73                Some("yaml") | Some("yaml-schema") => SchemaType::Yaml,
74                Some("rng") | Some("relaxng") => SchemaType::RelaxNg,
75                Some("sch") | Some("schematron") => SchemaType::Schematron,
76                Some(other) => {
77                    return Err(CamelError::InvalidUri(format!(
78                        "unknown schema type '{other}'; expected xml, json, yaml, rng, or schematron"
79                    )));
80                }
81                None => detect_type_from_extension(&schema_path)?,
82            }
83        } else {
84            detect_type_from_extension(&schema_path)?
85        };
86
87        let mut max_payload_bytes: Option<usize> = None;
88        let mut schema_cache_max_entries: usize = DEFAULT_SCHEMA_CACHE_MAX_ENTRIES;
89        let mut fail_on_null_body: bool = DEFAULT_FAIL_ON_NULL_BODY;
90        let mut header_name: Option<String> = None;
91        let mut fail_on_null_header: bool = DEFAULT_FAIL_ON_NULL_HEADER;
92
93        if let Some(q) = query {
94            for kv in q.split('&') {
95                if let Some(val) = kv.strip_prefix("maxPayloadBytes=") {
96                    max_payload_bytes = Some(val.parse::<usize>().map_err(|e| {
97                        CamelError::InvalidUri(format!("invalid maxPayloadBytes '{val}': {e}"))
98                    })?);
99                } else if let Some(val) = kv.strip_prefix("maxPayloadBytes:") {
100                    max_payload_bytes = Some(val.parse::<usize>().map_err(|e| {
101                        CamelError::InvalidUri(format!("invalid maxPayloadBytes '{val}': {e}"))
102                    })?);
103                } else if let Some(val) = kv.strip_prefix("schemaCacheMaxEntries=") {
104                    schema_cache_max_entries = val.parse::<usize>().map_err(|e| {
105                        CamelError::InvalidUri(format!(
106                            "invalid schemaCacheMaxEntries '{val}': {e}"
107                        ))
108                    })?;
109                } else if let Some(val) = kv.strip_prefix("failOnNullBody=") {
110                    fail_on_null_body = val.parse::<bool>().map_err(|e| {
111                        CamelError::InvalidUri(format!("invalid failOnNullBody '{val}': {e}"))
112                    })?;
113                } else if let Some(val) = kv.strip_prefix("headerName=") {
114                    header_name = Some(val.to_string());
115                } else if let Some(val) = kv.strip_prefix("failOnNullHeader=") {
116                    fail_on_null_header = val.parse::<bool>().map_err(|e| {
117                        CamelError::InvalidUri(format!("invalid failOnNullHeader '{val}': {e}"))
118                    })?;
119                }
120            }
121        }
122
123        Ok(ValidatorConfig {
124            schema_path,
125            schema_type,
126            max_payload_bytes,
127            schema_cache_max_entries,
128            fail_on_null_body,
129            header_name,
130            fail_on_null_header,
131        })
132    }
133}
134
135fn detect_type_from_extension(path: &Path) -> Result<SchemaType, CamelError> {
136    match path.extension().and_then(|e| e.to_str()) {
137        Some("xsd") => Ok(SchemaType::Xml),
138        Some("json") => Ok(SchemaType::Json),
139        Some("yaml") | Some("yml") => Ok(SchemaType::Yaml),
140        Some("rng") | Some("rnc") => Ok(SchemaType::RelaxNg),
141        Some("sch") => Ok(SchemaType::Schematron),
142        ext => Err(CamelError::InvalidUri(format!(
143            "cannot infer schema type from extension {ext:?}; use ?type=xml|json|yaml|rng|schematron"
144        ))),
145    }
146}
147
148fn validate_percent_encoding(input: &str) -> Result<(), CamelError> {
149    let bytes = input.as_bytes();
150    let mut i = 0usize;
151    while i < bytes.len() {
152        if bytes[i] == b'%' {
153            if i + 2 >= bytes.len() {
154                return Err(CamelError::InvalidUri(format!(
155                    "invalid percent-encoding in path: '{input}'"
156                )));
157            }
158            let is_hex = |b: u8| b.is_ascii_hexdigit();
159            if !is_hex(bytes[i + 1]) || !is_hex(bytes[i + 2]) {
160                return Err(CamelError::InvalidUri(format!(
161                    "invalid percent-encoding in path: '{input}'"
162                )));
163            }
164            i += 3;
165            continue;
166        }
167        i += 1;
168    }
169    Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn detects_xml_from_xsd_extension() {
178        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
179        assert_eq!(cfg.schema_path, PathBuf::from("schemas/order.xsd"));
180        assert_eq!(cfg.schema_type, SchemaType::Xml);
181    }
182
183    #[test]
184    fn detects_json_from_json_extension() {
185        let cfg = ValidatorConfig::from_uri("validator:schemas/order.json").unwrap();
186        assert_eq!(cfg.schema_type, SchemaType::Json);
187    }
188
189    #[test]
190    fn detects_yaml_from_yaml_extension() {
191        let cfg = ValidatorConfig::from_uri("validator:schemas/order.yaml").unwrap();
192        assert_eq!(cfg.schema_type, SchemaType::Yaml);
193    }
194
195    #[test]
196    fn detects_yaml_from_yml_extension() {
197        let cfg = ValidatorConfig::from_uri("validator:schemas/order.yml").unwrap();
198        assert_eq!(cfg.schema_type, SchemaType::Yaml);
199    }
200
201    #[test]
202    fn type_param_overrides_extension() {
203        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?type=json").unwrap();
204        assert_eq!(cfg.schema_type, SchemaType::Json);
205    }
206
207    #[test]
208    fn wrong_scheme_errors() {
209        assert!(ValidatorConfig::from_uri("timer:tick").is_err());
210    }
211
212    #[test]
213    fn empty_path_errors() {
214        assert!(ValidatorConfig::from_uri("validator:").is_err());
215    }
216
217    #[test]
218    fn unknown_type_param_errors() {
219        assert!(ValidatorConfig::from_uri("validator:schema.xsd?type=csv").is_err());
220    }
221
222    #[test]
223    fn no_extension_no_type_param_errors() {
224        assert!(ValidatorConfig::from_uri("validator:schema").is_err());
225    }
226
227    #[test]
228    fn percent_encoded_path_decoded() {
229        let cfg = ValidatorConfig::from_uri("validator:/path/to/my%20schema.xsd").unwrap();
230        assert!(
231            cfg.schema_path.to_str().unwrap().contains("my schema.xsd"),
232            "expected decoded path, got {:?}",
233            cfg.schema_path
234        );
235    }
236
237    #[test]
238    fn normal_path_unchanged() {
239        let cfg = ValidatorConfig::from_uri("validator:/path/to/schema.xsd").unwrap();
240        assert_eq!(cfg.schema_path, PathBuf::from("/path/to/schema.xsd"));
241    }
242
243    #[test]
244    fn percent_encoded_multiple_segments() {
245        let cfg = ValidatorConfig::from_uri("validator:/my%20dir/my%20file.xsd").unwrap();
246        assert!(
247            cfg.schema_path.to_str().unwrap().contains("my dir"),
248            "expected decoded 'my dir', got {:?}",
249            cfg.schema_path
250        );
251        assert!(
252            cfg.schema_path.to_str().unwrap().contains("my file.xsd"),
253            "expected decoded 'my file.xsd', got {:?}",
254            cfg.schema_path
255        );
256    }
257
258    #[test]
259    fn percent_encoded_with_query_params() {
260        let cfg =
261            ValidatorConfig::from_uri("validator:/path/to/my%20schema.xsd?type=json").unwrap();
262        assert!(
263            cfg.schema_path.to_str().unwrap().contains("my schema.xsd"),
264            "expected decoded path, got {:?}",
265            cfg.schema_path
266        );
267        assert_eq!(cfg.schema_type, SchemaType::Json);
268    }
269
270    #[test]
271    fn invalid_percent_encoding_errors() {
272        // %ZZ is not a valid percent-encoding sequence
273        let result = ValidatorConfig::from_uri("validator:/path/%ZZfile.xsd");
274        assert!(
275            matches!(result, Err(CamelError::InvalidUri(msg)) if msg.contains("percent-encoding"))
276        );
277    }
278
279    #[test]
280    fn test_fail_on_null_body_default_true() {
281        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
282        assert!(cfg.fail_on_null_body);
283    }
284
285    #[test]
286    fn test_fail_on_null_body_false_passes_empty() {
287        let cfg =
288            ValidatorConfig::from_uri("validator:schemas/order.xsd?failOnNullBody=false").unwrap();
289        assert!(!cfg.fail_on_null_body);
290    }
291
292    #[test]
293    fn test_header_name_validation_option_parsed() {
294        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?headerName=X-My-Header")
295            .unwrap();
296        assert_eq!(cfg.header_name.as_deref(), Some("X-My-Header"));
297    }
298
299    #[test]
300    fn test_header_name_defaults_to_none() {
301        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
302        assert!(cfg.header_name.is_none());
303    }
304
305    #[test]
306    fn test_fail_on_null_header_default_true() {
307        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?headerName=X-H").unwrap();
308        assert!(cfg.fail_on_null_header);
309    }
310
311    #[test]
312    fn test_fail_on_null_header_false_passes_missing_header() {
313        let cfg = ValidatorConfig::from_uri(
314            "validator:schemas/order.xsd?headerName=X-H&failOnNullHeader=false",
315        )
316        .unwrap();
317        assert!(!cfg.fail_on_null_header);
318    }
319}