Skip to main content

camel_component_validator/
component.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5
6use tower::Service;
7use tracing::debug;
8
9use crate::compiled::CompiledValidator;
10use crate::config::ValidatorConfig;
11use crate::xsd_bridge::{XsdBridge, XsdBridgeBackend};
12use camel_component_api::ComponentContext;
13use camel_component_api::{
14    BoxProcessor, CamelError, Component, ComponentMetadata, Consumer, Endpoint, Exchange,
15    ProducerContext, RuntimeObservability,
16};
17
18use crate::metadata::ValidatorMetadataDescriptor;
19
20pub struct ValidatorComponent {
21    xsd_bridge: Arc<dyn XsdBridge>,
22    xsd_backend: Option<Arc<XsdBridgeBackend>>,
23}
24
25impl ValidatorComponent {
26    pub fn new() -> Self {
27        let xsd_backend = Arc::new(XsdBridgeBackend::new());
28        Self {
29            xsd_bridge: Arc::clone(&xsd_backend) as Arc<dyn XsdBridge>,
30            xsd_backend: Some(xsd_backend),
31        }
32    }
33
34    pub fn xsd_bridge_backend(&self) -> Option<Arc<XsdBridgeBackend>> {
35        self.xsd_backend.as_ref().map(Arc::clone)
36    }
37
38    #[cfg(test)]
39    fn with_xsd_bridge(xsd_bridge: Arc<dyn XsdBridge>) -> Self {
40        Self {
41            xsd_bridge,
42            xsd_backend: None,
43        }
44    }
45}
46
47impl Default for ValidatorComponent {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl Component for ValidatorComponent {
54    fn scheme(&self) -> &str {
55        "validator"
56    }
57
58    fn metadata(&self) -> ComponentMetadata {
59        ValidatorMetadataDescriptor::metadata()
60    }
61
62    fn create_endpoint(
63        &self,
64        uri: &str,
65        _ctx: &dyn ComponentContext,
66    ) -> Result<Box<dyn Endpoint>, CamelError> {
67        let config = ValidatorConfig::from_uri(uri)?;
68        let xsd_bridge = Arc::clone(&self.xsd_bridge);
69        let compiled = CompiledValidator::compile(&config, xsd_bridge)?;
70        Ok(Box::new(ValidatorEndpoint {
71            uri: uri.to_string(),
72            config,
73            compiled: Arc::new(compiled),
74            xsd_backend: self.xsd_backend.as_ref().map(Arc::clone),
75        }))
76    }
77}
78
79/// Validator endpoint that checks message bodies or headers against a schema.
80///
81/// Supports XML (XSD), JSON Schema, and YAML schema validation.
82/// RelaxNG and Schematron are accepted in URI parsing but rejected at endpoint
83/// creation with a clear error message.
84struct ValidatorEndpoint {
85    uri: String,
86    config: ValidatorConfig,
87    compiled: Arc<CompiledValidator>,
88    xsd_backend: Option<Arc<XsdBridgeBackend>>,
89}
90
91impl ValidatorEndpoint {
92    /// Returns a human-readable description of the configured schema.
93    #[allow(dead_code)]
94    pub fn schema_info(&self) -> String {
95        format!(
96            "{:?} schema: {}",
97            self.config.schema_type,
98            self.config.schema_path.display()
99        )
100    }
101}
102
103impl Endpoint for ValidatorEndpoint {
104    fn uri(&self) -> &str {
105        &self.uri
106    }
107
108    fn create_consumer(
109        &self,
110        _rt: Arc<dyn RuntimeObservability>,
111    ) -> Result<Box<dyn Consumer>, CamelError> {
112        Err(CamelError::EndpointCreationFailed(
113            "validator endpoint does not support consumers".to_string(),
114        ))
115    }
116
117    fn create_producer(
118        &self,
119        rt: Arc<dyn RuntimeObservability>,
120        ctx: &ProducerContext,
121    ) -> Result<BoxProcessor, CamelError> {
122        if let Some(ref backend) = self.xsd_backend {
123            backend.set_observability(
124                rt.clone(),
125                ctx.route_id().unwrap_or("validator-bridge").to_string(),
126            );
127        }
128        Ok(BoxProcessor::new(ValidatorProducer {
129            uri: self.uri.clone(),
130            config: self.config.clone(),
131            compiled: Arc::clone(&self.compiled),
132        }))
133    }
134}
135
136#[derive(Clone)]
137struct ValidatorProducer {
138    uri: String,
139    config: ValidatorConfig,
140    compiled: Arc<CompiledValidator>,
141}
142
143impl Service<Exchange> for ValidatorProducer {
144    type Response = Exchange;
145    type Error = CamelError;
146    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
147
148    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
149        Poll::Ready(Ok(()))
150    }
151
152    fn call(&mut self, exchange: Exchange) -> Self::Future {
153        let compiled = Arc::clone(&self.compiled);
154        let uri = self.uri.clone();
155        let config = self.config.clone();
156        Box::pin(async move {
157            debug!(
158                uri = %camel_api::redact::redact_url_fail_closed(&uri),
159                "validating exchange body"
160            );
161
162            // VAL-003: header_name mode — validate header value instead of body
163            if let Some(ref header_name) = config.header_name {
164                match exchange.input.header(header_name) {
165                    Some(value) => {
166                        // Convert header Value to a string body for validation
167                        let header_str = match value.as_str() {
168                            Some(s) => s.to_string(),
169                            None => value.to_string(),
170                        };
171                        let header_body = camel_component_api::Body::Text(header_str);
172                        compiled.validate(&header_body).await?;
173                    }
174                    None => {
175                        // VAL-004: failOnNullHeader
176                        if config.fail_on_null_header {
177                            return Err(CamelError::ProcessorError(format!(
178                                "header '{header_name}' is missing and failOnNullHeader is true"
179                            )));
180                        }
181                        // Pass through — no validation
182                    }
183                }
184                return Ok(exchange);
185            }
186
187            // VAL-002: failOnNullBody
188            if exchange.input.body.is_empty() {
189                if config.fail_on_null_body {
190                    return Err(CamelError::ProcessorError(
191                        "body is empty and failOnNullBody is true".to_string(),
192                    ));
193                }
194                // Pass through — no validation
195                return Ok(exchange);
196            }
197
198            compiled.validate(&exchange.input.body).await?;
199            Ok(exchange)
200        })
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use camel_component_api::test_support::PanicRuntimeObservability;
207    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
208        std::sync::Arc::new(PanicRuntimeObservability)
209    }
210
211    use super::*;
212    use crate::error::ValidatorError;
213    use async_trait::async_trait;
214    use camel_component_api::{Message, NoOpComponentContext};
215    use std::io::Write;
216    use std::sync::atomic::{AtomicUsize, Ordering};
217    use tower::ServiceExt;
218
219    fn json_schema_file() -> tempfile::NamedTempFile {
220        let mut f = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
221        f.write_all(br#"{"type":"object","required":["id"]}"#)
222            .unwrap();
223        f
224    }
225
226    fn xsd_file() -> tempfile::NamedTempFile {
227        let mut f = tempfile::Builder::new().suffix(".xsd").tempfile().unwrap();
228        f.write_all(
229            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:element name="order" type="xs:string"/></xs:schema>"#,
230        ).unwrap();
231        f
232    }
233
234    #[derive(Debug)]
235    struct MockXsdBridge {
236        register_calls: AtomicUsize,
237        validate_calls: AtomicUsize,
238        register_error: Option<ValidatorError>,
239        validate_error: Option<ValidatorError>,
240    }
241
242    #[async_trait]
243    impl XsdBridge for MockXsdBridge {
244        async fn register(
245            &self,
246            _schema_id: &str,
247            _set: &crate::schema_set::SchemaSet,
248        ) -> Result<String, ValidatorError> {
249            self.register_calls.fetch_add(1, Ordering::SeqCst);
250            if let Some(err) = &self.register_error {
251                return Err(err.clone());
252            }
253            Ok("xsd-mock-id".to_string())
254        }
255
256        async fn validate(
257            &self,
258            _schema_id: &str,
259            _doc_bytes: Vec<u8>,
260        ) -> Result<(), ValidatorError> {
261            self.validate_calls.fetch_add(1, Ordering::SeqCst);
262            if let Some(err) = &self.validate_error {
263                return Err(err.clone());
264            }
265            Ok(())
266        }
267    }
268
269    #[test]
270    fn scheme_is_validator() {
271        assert_eq!(ValidatorComponent::new().scheme(), "validator");
272    }
273
274    #[test]
275    fn consumer_not_supported() {
276        let f = json_schema_file();
277        let uri = format!("validator:{}", f.path().display());
278        let ep = ValidatorComponent::new()
279            .create_endpoint(&uri, &NoOpComponentContext)
280            .unwrap();
281        assert!(ep.create_consumer(rt()).is_err());
282    }
283
284    #[test]
285    fn endpoint_creation_fails_for_missing_schema() {
286        let result = ValidatorComponent::new()
287            .create_endpoint("validator:/nonexistent/schema.json", &NoOpComponentContext);
288        assert!(result.is_err());
289    }
290
291    #[tokio::test]
292    async fn valid_json_body_passes_through() {
293        let f = json_schema_file();
294        let uri = format!("validator:{}", f.path().display());
295        let ep = ValidatorComponent::new()
296            .create_endpoint(&uri, &NoOpComponentContext)
297            .unwrap();
298        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
299        let exchange = Exchange::new(Message::new(camel_component_api::Body::Json(
300            serde_json::json!({"id": "1"}),
301        )));
302        let result = producer.oneshot(exchange).await;
303        assert!(result.is_ok());
304    }
305
306    #[tokio::test]
307    async fn invalid_json_body_returns_err() {
308        let f = json_schema_file();
309        let uri = format!("validator:{}", f.path().display());
310        let ep = ValidatorComponent::new()
311            .create_endpoint(&uri, &NoOpComponentContext)
312            .unwrap();
313        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
314        let exchange = Exchange::new(Message::new(camel_component_api::Body::Json(
315            serde_json::json!({"name": "x"}),
316        )));
317        let result = producer.oneshot(exchange).await;
318        assert!(result.is_err());
319        let msg = result.unwrap_err().to_string();
320        assert!(msg.contains("validation failed"), "got: {msg}");
321    }
322
323    #[tokio::test]
324    async fn valid_xml_body_passes() {
325        let backend = Arc::new(MockXsdBridge {
326            register_calls: AtomicUsize::new(0),
327            validate_calls: AtomicUsize::new(0),
328            register_error: None,
329            validate_error: None,
330        });
331        let f = xsd_file();
332        let uri = format!("validator:{}", f.path().display());
333        let ep = ValidatorComponent::with_xsd_bridge(backend)
334            .create_endpoint(&uri, &NoOpComponentContext)
335            .unwrap();
336        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
337        let exchange = Exchange::new(Message::new(camel_component_api::Body::Xml(
338            "<order>hello</order>".to_string(),
339        )));
340        assert!(producer.oneshot(exchange).await.is_ok());
341    }
342
343    #[tokio::test]
344    async fn xsd_bridge_register_and_validate_mock() {
345        let backend = Arc::new(MockXsdBridge {
346            register_calls: AtomicUsize::new(0),
347            validate_calls: AtomicUsize::new(0),
348            register_error: None,
349            validate_error: None,
350        });
351
352        let f = xsd_file();
353        let uri = format!("validator:{}", f.path().display());
354        let ep = ValidatorComponent::with_xsd_bridge(Arc::clone(&backend) as Arc<dyn XsdBridge>)
355            .create_endpoint(&uri, &NoOpComponentContext)
356            .unwrap();
357
358        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
359        let exchange = Exchange::new(Message::new(camel_component_api::Body::Xml(
360            "<order>ok</order>".to_string(),
361        )));
362        assert!(producer.oneshot(exchange).await.is_ok());
363        assert_eq!(backend.register_calls.load(Ordering::SeqCst), 1);
364        assert_eq!(backend.validate_calls.load(Ordering::SeqCst), 1);
365    }
366
367    #[tokio::test]
368    async fn xsd_bridge_register_error_propagates_on_validate() {
369        let backend = Arc::new(MockXsdBridge {
370            register_calls: AtomicUsize::new(0),
371            validate_calls: AtomicUsize::new(0),
372            register_error: Some(ValidatorError::CompilationFailed {
373                message: "COMPILATION_FAILED".to_string(),
374                source: None,
375            }),
376            validate_error: None,
377        });
378        let f = xsd_file();
379        let uri = format!("validator:{}", f.path().display());
380        // Endpoint creation now always succeeds for XSD (registration is deferred).
381        let ep = ValidatorComponent::with_xsd_bridge(backend)
382            .create_endpoint(&uri, &NoOpComponentContext)
383            .expect("endpoint creation should succeed");
384        // The error surfaces when the first message is processed (register is called).
385        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
386        let exchange = Exchange::new(Message::new(camel_component_api::Body::Xml(
387            "<order/>".to_string(),
388        )));
389        let err = producer
390            .oneshot(exchange)
391            .await
392            .expect_err("expected validate to fail due to registration error");
393        assert!(err.to_string().contains("COMPILATION_FAILED"));
394    }
395
396    #[tokio::test]
397    async fn test_validator_rejects_oversized_payload() {
398        // Build validator with maxPayloadBytes=100
399        let f = json_schema_file();
400        let uri = format!("validator:{}?maxPayloadBytes=100", f.path().display());
401        let ep = ValidatorComponent::new()
402            .create_endpoint(&uri, &NoOpComponentContext)
403            .unwrap();
404        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
405        // Send a body that is definitely > 100 bytes
406        let big_body: String = "x".repeat(200);
407        let exchange = Exchange::new(Message::new(camel_component_api::Body::Text(big_body)));
408        let result = producer.oneshot(exchange).await;
409        assert!(result.is_err(), "expected oversized payload to be rejected");
410        let msg = result.unwrap_err().to_string();
411        assert!(
412            msg.contains("payload too large"),
413            "expected 'payload too large' in error, got: {msg}"
414        );
415    }
416
417    #[tokio::test]
418    async fn test_validator_allows_payload_under_limit() {
419        let f = json_schema_file();
420        let uri = format!("validator:{}?maxPayloadBytes=1024", f.path().display());
421        let ep = ValidatorComponent::new()
422            .create_endpoint(&uri, &NoOpComponentContext)
423            .unwrap();
424        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
425        let exchange = Exchange::new(Message::new(camel_component_api::Body::Json(
426            serde_json::json!({"id": "1"}),
427        )));
428        let result = producer.oneshot(exchange).await;
429        assert!(result.is_ok(), "expected valid payload under limit to pass");
430    }
431
432    #[tokio::test]
433    async fn test_validator_no_limit_allows_any_size() {
434        let f = json_schema_file();
435        let uri = format!("validator:{}", f.path().display());
436        let ep = ValidatorComponent::new()
437            .create_endpoint(&uri, &NoOpComponentContext)
438            .unwrap();
439        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
440        // Valid JSON that would exceed a 10-byte limit
441        let exchange = Exchange::new(Message::new(camel_component_api::Body::Json(
442            serde_json::json!({"id": "this is a longer value that would exceed small limits"}),
443        )));
444        let result = producer.oneshot(exchange).await;
445        assert!(
446            result.is_ok(),
447            "expected no-limit validator to pass any valid payload"
448        );
449    }
450
451    #[tokio::test]
452    async fn test_fail_on_null_body_default_rejects_empty() {
453        let f = json_schema_file();
454        let uri = format!("validator:{}", f.path().display());
455        let ep = ValidatorComponent::new()
456            .create_endpoint(&uri, &NoOpComponentContext)
457            .unwrap();
458        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
459        let exchange = Exchange::new(Message::new(camel_component_api::Body::Empty));
460        let result = producer.oneshot(exchange).await;
461        assert!(result.is_err());
462        let msg = result.unwrap_err().to_string();
463        assert!(
464            msg.contains("failOnNullBody"),
465            "expected failOnNullBody in error, got: {msg}"
466        );
467    }
468
469    #[tokio::test]
470    async fn test_fail_on_null_body_false_passes_empty() {
471        let f = json_schema_file();
472        let uri = format!("validator:{}?failOnNullBody=false", f.path().display());
473        let ep = ValidatorComponent::new()
474            .create_endpoint(&uri, &NoOpComponentContext)
475            .unwrap();
476        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
477        let exchange = Exchange::new(Message::new(camel_component_api::Body::Empty));
478        let result = producer.oneshot(exchange).await;
479        assert!(
480            result.is_ok(),
481            "expected empty body to pass with failOnNullBody=false"
482        );
483    }
484
485    #[tokio::test]
486    async fn test_header_name_validation_uses_header_value() {
487        let f = json_schema_file();
488        let uri = format!("validator:{}?headerName=X-Data", f.path().display());
489        let ep = ValidatorComponent::new()
490            .create_endpoint(&uri, &NoOpComponentContext)
491            .unwrap();
492        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
493        let mut msg = Message::new(camel_component_api::Body::Empty);
494        msg.set_header("X-Data", serde_json::json!({"id": "1"}).to_string());
495        let exchange = Exchange::new(msg);
496        let result = producer.oneshot(exchange).await;
497        // Header value {"id":"1"} is valid JSON matching schema
498        assert!(
499            result.is_ok(),
500            "expected valid header to pass: {:?}",
501            result
502        );
503    }
504
505    #[tokio::test]
506    async fn test_header_name_missing_header_fails_by_default() {
507        let f = json_schema_file();
508        let uri = format!("validator:{}?headerName=X-Missing", f.path().display());
509        let ep = ValidatorComponent::new()
510            .create_endpoint(&uri, &NoOpComponentContext)
511            .unwrap();
512        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
513        let exchange = Exchange::new(Message::new(camel_component_api::Body::Empty));
514        let result = producer.oneshot(exchange).await;
515        assert!(result.is_err());
516        let msg = result.unwrap_err().to_string();
517        assert!(
518            msg.contains("X-Missing"),
519            "expected header name in error, got: {msg}"
520        );
521    }
522
523    #[tokio::test]
524    async fn test_fail_on_null_header_false_passes_missing_header() {
525        let f = json_schema_file();
526        let uri = format!(
527            "validator:{}?headerName=X-Missing&failOnNullHeader=false",
528            f.path().display()
529        );
530        let ep = ValidatorComponent::new()
531            .create_endpoint(&uri, &NoOpComponentContext)
532            .unwrap();
533        let producer = ep.create_producer(rt(), &ProducerContext::new()).unwrap();
534        let exchange = Exchange::new(Message::new(camel_component_api::Body::Empty));
535        let result = producer.oneshot(exchange).await;
536        assert!(
537            result.is_ok(),
538            "expected missing header to pass with failOnNullHeader=false"
539        );
540    }
541
542    #[test]
543    fn test_relaxng_schema_type_rejected_at_creation() {
544        let mut f = tempfile::Builder::new().suffix(".rng").tempfile().unwrap();
545        use std::io::Write;
546        f.write_all(b"<grammar/>").unwrap();
547        let uri = format!("validator:{}", f.path().display());
548        let result = ValidatorComponent::new().create_endpoint(&uri, &NoOpComponentContext);
549        let err = result.err().expect("expected endpoint creation to fail");
550        let msg = err.to_string();
551        assert!(
552            msg.contains("not yet supported"),
553            "expected 'not yet supported' in error, got: {msg}"
554        );
555    }
556
557    #[test]
558    fn test_schematron_schema_type_rejected_at_creation() {
559        let mut f = tempfile::Builder::new().suffix(".sch").tempfile().unwrap();
560        use std::io::Write;
561        f.write_all(b"<schema/>").unwrap();
562        let uri = format!("validator:{}", f.path().display());
563        let result = ValidatorComponent::new().create_endpoint(&uri, &NoOpComponentContext);
564        let err = result.err().expect("expected endpoint creation to fail");
565        let msg = err.to_string();
566        assert!(
567            msg.contains("not yet supported"),
568            "expected 'not yet supported' in error, got: {msg}"
569        );
570    }
571
572    // ── schemaAliases endpoint wiring ───────────────────────────────────
573
574    #[test]
575    fn xsd_mock_compiles_against_new_trait_signature() {
576        fn assert_xsd_bridge<T: XsdBridge>() {}
577        assert_xsd_bridge::<MockXsdBridge>();
578    }
579
580    #[test]
581    fn create_endpoint_rejects_malformed_alias_uri() {
582        // The XSD file exists; a malformed schemaAliases value must fail during
583        // URI parsing, BEFORE any discovery or bridge start, and name the
584        // option rather than surfacing as a missing-file error.
585        let f = xsd_file();
586        let uri = format!("validator:{}?schemaAliases=%7Bbad", f.path().display());
587        let err = ValidatorComponent::new()
588            .create_endpoint(&uri, &NoOpComponentContext)
589            .err()
590            .expect("malformed schemaAliases must reject endpoint creation");
591        match err {
592            CamelError::InvalidUri(m) => assert!(
593                m.contains("schemaAliases"),
594                "error must name the schemaAliases option, got: {m}"
595            ),
596            other => panic!("expected InvalidUri, got: {other}"),
597        }
598    }
599
600    #[test]
601    fn create_endpoint_rejects_alias_cap() {
602        let over_cap = crate::schema_set::MAX_SCHEMA_ALIASES + 1;
603
604        // URI surface: an over-cap `schemaAliases` JSON object must fail during
605        // URI parsing, naming the 128-entry cap before any bridge startup.
606        let mut obj = serde_json::Map::new();
607        for i in 0..over_cap {
608            obj.insert(
609                format!("urn:a{i}"),
610                serde_json::Value::String("a/parent.xsd".to_string()),
611            );
612        }
613        let json = serde_json::Value::Object(obj).to_string();
614        let encoded =
615            percent_encoding::utf8_percent_encode(&json, percent_encoding::NON_ALPHANUMERIC)
616                .to_string();
617        let uri = format!("validator:/nonexistent/root.xsd?schemaAliases={encoded}");
618        let err = ValidatorComponent::new()
619            .create_endpoint(&uri, &NoOpComponentContext)
620            .err()
621            .expect("an over-cap schemaAliases URI must reject endpoint creation");
622        match err {
623            CamelError::InvalidUri(m) => assert!(
624                m.contains("128"),
625                "parse error must name the 128-entry cap, got: {m}"
626            ),
627            other => panic!("expected InvalidUri for the URI surface, got: {other}"),
628        }
629
630        // Programmatic surface: the public `schema_aliases` field can be set
631        // past the cap on a directly constructed config. Compile must reject it
632        // through the config-level guard (a `CamelError::Config`) before it
633        // touches the filesystem, proving the guard is wired (not dead code):
634        // without it this would surface from discovery/FS as a different
635        // variant such as `EndpointCreationFailed`.
636        let mut aliases = std::collections::BTreeMap::new();
637        for i in 0..over_cap {
638            aliases.insert(format!("urn:a{i}"), "a/parent.xsd".to_string());
639        }
640        let cfg = ValidatorConfig {
641            schema_path: std::path::PathBuf::from("/nonexistent/root.xsd"),
642            schema_type: crate::config::SchemaType::Xml,
643            max_payload_bytes: None,
644            schema_cache_max_entries: crate::config::DEFAULT_SCHEMA_CACHE_MAX_ENTRIES,
645            fail_on_null_body: true,
646            header_name: None,
647            fail_on_null_header: true,
648            schema_aliases: aliases,
649        };
650        let backend = Arc::new(MockXsdBridge {
651            register_calls: AtomicUsize::new(0),
652            validate_calls: AtomicUsize::new(0),
653            register_error: None,
654            validate_error: None,
655        });
656        let err = CompiledValidator::compile(&cfg, backend)
657            .expect_err("an over-cap programmatic config must fail to compile");
658        match err {
659            CamelError::Config(m) => assert!(
660                m.contains("128"),
661                "config guard must name the 128-entry cap, got: {m}"
662            ),
663            other => panic!(
664                "expected the config-level cap guard (CamelError::Config); the \
665                 guard is unwired and this surfaced as: {other}"
666            ),
667        }
668    }
669
670    #[test]
671    fn create_endpoint_accepts_alias_uri() {
672        let dir = tempfile::tempdir().unwrap();
673        std::fs::write(
674            dir.path().join("root.xsd"),
675            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"/>"#,
676        )
677        .unwrap();
678        std::fs::create_dir_all(dir.path().join("a")).unwrap();
679        std::fs::write(
680            dir.path().join("a/parent.xsd"),
681            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"/>"#,
682        )
683        .unwrap();
684
685        let uri = format!(
686            "validator:{}/root.xsd?schemaAliases=%7B%22urn%3Aalias%22%3A%22a%2Fparent.xsd%22%7D",
687            dir.path().display()
688        );
689        let result = ValidatorComponent::new().create_endpoint(&uri, &NoOpComponentContext);
690        assert!(
691            result.is_ok(),
692            "a valid root with the aliased schema present must create an endpoint: {:?}",
693            result.err().map(|e| e.to_string())
694        );
695    }
696
697    #[test]
698    fn test_schema_info_returns_description() {
699        let f = json_schema_file();
700        let uri = format!("validator:{}", f.path().display());
701        let ep = ValidatorComponent::new()
702            .create_endpoint(&uri, &NoOpComponentContext)
703            .unwrap();
704        // The endpoint is a Box<dyn Endpoint>, so we can't directly call schema_info().
705        // We verify endpoint creation succeeds (schema compiled).
706        assert_eq!(ep.uri(), uri);
707    }
708}