camel_component_validator/
config.rs1use 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
15pub const DEFAULT_SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
17pub const DEFAULT_FAIL_ON_NULL_BODY: bool = true;
19pub 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 pub max_payload_bytes: Option<usize>,
29 pub schema_cache_max_entries: usize,
33 pub fail_on_null_body: bool,
36 pub header_name: Option<String>,
38 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 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}