Skip to main content

jsonschema/
retriever.rs

1//! Logic for retrieving external resources.
2use referencing::{Retrieve, Uri};
3use serde_json::Value;
4
5#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
6use crate::HttpOptions;
7
8/// Error that can occur when creating an HTTP retriever.
9#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
10#[derive(Debug)]
11pub enum HttpRetrieverError {
12    /// Failed to read certificate file.
13    CertificateRead {
14        path: std::path::PathBuf,
15        source: std::io::Error,
16    },
17    /// Failed to parse certificate.
18    CertificateParse {
19        path: std::path::PathBuf,
20        source: reqwest::Error,
21    },
22    /// Failed to build HTTP client.
23    ClientBuild(reqwest::Error),
24}
25
26#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
27impl std::fmt::Display for HttpRetrieverError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::CertificateRead { path, source } => {
31                write!(
32                    f,
33                    "Failed to read certificate file '{}': {source}",
34                    path.display()
35                )
36            }
37            Self::CertificateParse { path, source } => {
38                write!(
39                    f,
40                    "Failed to parse certificate '{}': {source}",
41                    path.display()
42                )
43            }
44            Self::ClientBuild(e) => write!(f, "Failed to build HTTP client: {e}"),
45        }
46    }
47}
48
49#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
50impl std::error::Error for HttpRetrieverError {
51    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
52        match self {
53            Self::CertificateRead { source, .. } => Some(source),
54            Self::CertificateParse { source, .. } | Self::ClientBuild(source) => Some(source),
55        }
56    }
57}
58
59/// Load a certificate from a PEM file.
60#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
61fn load_certificate(path: &std::path::Path) -> Result<reqwest::Certificate, HttpRetrieverError> {
62    let cert_data = std::fs::read(path).map_err(|e| HttpRetrieverError::CertificateRead {
63        path: path.to_path_buf(),
64        source: e,
65    })?;
66    reqwest::Certificate::from_pem(&cert_data).map_err(|e| HttpRetrieverError::CertificateParse {
67        path: path.to_path_buf(),
68        source: e,
69    })
70}
71
72/// Configure an HTTP client builder with the given options.
73/// Works with both `reqwest::ClientBuilder` and `reqwest::blocking::ClientBuilder`.
74#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
75macro_rules! configure_http_client {
76    ($builder:expr, $options:expr) => {{
77        let mut builder = $builder;
78        if let Some(connect_timeout) = $options.connect_timeout {
79            builder = builder.connect_timeout(connect_timeout);
80        }
81        if let Some(timeout) = $options.timeout {
82            builder = builder.timeout(timeout);
83        }
84        if $options.danger_accept_invalid_certs {
85            builder = builder.danger_accept_invalid_certs(true);
86        }
87        if let Some(ref cert_path) = &$options.root_certificate {
88            builder = builder.add_root_certificate(load_certificate(cert_path)?);
89        }
90        builder
91    }};
92}
93
94#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
95fn install_tls_provider() {
96    // When both features are enabled (e.g. `--all-features`), keep aws-lc-rs
97    // as the effective default provider.
98    #[cfg(feature = "tls-aws-lc-rs")]
99    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
100    #[cfg(all(not(feature = "tls-aws-lc-rs"), feature = "tls-ring"))]
101    let _ = rustls::crypto::ring::default_provider().install_default();
102}
103
104pub(crate) struct DefaultRetriever;
105
106/// HTTP-based schema retriever with configurable client options.
107///
108/// This retriever fetches external schemas over HTTP/HTTPS using a
109/// configured [`reqwest`](https://docs.rs/reqwest) client.
110///
111/// # Example
112///
113/// ```rust,no_run
114/// use std::time::Duration;
115/// use jsonschema::{HttpOptions, HttpRetriever};
116///
117/// let http_options = HttpOptions::new()
118///     .timeout(Duration::from_secs(30));
119///
120/// let retriever = HttpRetriever::new(&http_options)
121///     .expect("Failed to create HTTP retriever");
122/// ```
123#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
124#[derive(Debug)]
125pub struct HttpRetriever {
126    client: reqwest::blocking::Client,
127}
128
129#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
130impl HttpRetriever {
131    /// Create a new HTTP retriever with the given options.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if:
136    /// - The certificate file cannot be read
137    /// - The certificate is not valid PEM
138    /// - The HTTP client cannot be built
139    pub fn new(options: &HttpOptions) -> Result<Self, HttpRetrieverError> {
140        install_tls_provider();
141
142        let builder = configure_http_client!(reqwest::blocking::Client::builder(), options);
143        Ok(Self {
144            client: builder.build().map_err(HttpRetrieverError::ClientBuild)?,
145        })
146    }
147}
148
149#[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
150impl Retrieve for HttpRetriever {
151    fn retrieve(
152        &self,
153        uri: &Uri<String>,
154    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
155        match uri.scheme().as_str() {
156            "http" | "https" => Ok(self.client.get(uri.as_str()).send()?.json()?),
157            "file" => {
158                #[cfg(feature = "resolve-file")]
159                {
160                    let path = uri.path().as_str();
161                    let path = {
162                        #[cfg(windows)]
163                        {
164                            let path = path.trim_start_matches('/').replace('/', "\\");
165                            std::path::PathBuf::from(path)
166                        }
167                        #[cfg(not(windows))]
168                        {
169                            std::path::PathBuf::from(path)
170                        }
171                    };
172                    let file = std::fs::File::open(path)?;
173                    Ok(serde_json::from_reader(file)?)
174                }
175                #[cfg(not(feature = "resolve-file"))]
176                {
177                    Err("`resolve-file` feature or a custom resolver is required to resolve external schemas via files".into())
178                }
179            }
180            scheme => Err(format!("Unknown scheme {scheme}").into()),
181        }
182    }
183}
184
185/// Async HTTP-based schema retriever with configurable client options.
186///
187/// This retriever fetches external schemas over HTTP/HTTPS using a
188/// configured async [`reqwest`](https://docs.rs/reqwest) client.
189#[cfg(all(
190    feature = "resolve-http",
191    feature = "resolve-async",
192    not(target_arch = "wasm32")
193))]
194#[derive(Debug)]
195pub struct AsyncHttpRetriever {
196    client: reqwest::Client,
197}
198
199#[cfg(all(
200    feature = "resolve-http",
201    feature = "resolve-async",
202    not(target_arch = "wasm32")
203))]
204impl AsyncHttpRetriever {
205    /// Create a new async HTTP retriever with the given options.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if:
210    /// - The certificate file cannot be read
211    /// - The certificate is not valid PEM
212    /// - The HTTP client cannot be built
213    pub fn new(options: &HttpOptions) -> Result<Self, HttpRetrieverError> {
214        install_tls_provider();
215
216        let builder = configure_http_client!(reqwest::Client::builder(), options);
217        Ok(Self {
218            client: builder.build().map_err(HttpRetrieverError::ClientBuild)?,
219        })
220    }
221}
222
223#[cfg(all(
224    feature = "resolve-http",
225    feature = "resolve-async",
226    not(target_arch = "wasm32")
227))]
228#[async_trait::async_trait]
229impl referencing::AsyncRetrieve for AsyncHttpRetriever {
230    async fn retrieve(
231        &self,
232        uri: &Uri<String>,
233    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
234        match uri.scheme().as_str() {
235            "http" | "https" => Ok(self.client.get(uri.as_str()).send().await?.json().await?),
236            "file" => {
237                #[cfg(feature = "resolve-file")]
238                {
239                    let path = uri.path().as_str().to_string();
240                    let contents = tokio::task::spawn_blocking(
241                        move || -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
242                            let path = {
243                                #[cfg(windows)]
244                                {
245                                    let path = path.trim_start_matches('/').replace('/', "\\");
246                                    std::path::PathBuf::from(path)
247                                }
248                                #[cfg(not(windows))]
249                                {
250                                    std::path::PathBuf::from(path)
251                                }
252                            };
253                            let file = std::fs::File::open(path)?;
254                            Ok(serde_json::from_reader(file)?)
255                        },
256                    )
257                    .await??;
258                    Ok(contents)
259                }
260                #[cfg(not(feature = "resolve-file"))]
261                {
262                    Err("`resolve-file` feature or a custom resolver is required to resolve external schemas via files".into())
263                }
264            }
265            scheme => Err(format!("Unknown scheme {scheme}").into()),
266        }
267    }
268}
269
270impl Retrieve for DefaultRetriever {
271    #[allow(unused)]
272    fn retrieve(
273        &self,
274        uri: &Uri<String>,
275    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
276        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
277        {
278            Err("External references are not supported on wasm32-unknown-unknown".into())
279        }
280        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
281        match uri.scheme().as_str() {
282            "http" | "https" => {
283                #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
284                {
285                    install_tls_provider();
286
287                    Ok(reqwest::blocking::get(uri.as_str())?.json()?)
288                }
289                #[cfg(all(feature = "resolve-http", target_arch = "wasm32"))]
290                {
291                    Err("Synchronous HTTP retrieval is not supported on wasm32 targets. Use async_validator_for with the resolve-async feature instead".into())
292                }
293                #[cfg(not(feature = "resolve-http"))]
294                {
295                    Err("`resolve-http` feature or a custom resolver is required to resolve external schemas via HTTP".into())
296                }
297            }
298            "file" => {
299                #[cfg(feature = "resolve-file")]
300                {
301                    let path = uri.path().as_str();
302                    let path = {
303                        #[cfg(windows)]
304                        {
305                            // Remove the leading slash and replace forward slashes with backslashes
306                            let path = path.trim_start_matches('/').replace('/', "\\");
307                            std::path::PathBuf::from(path)
308                        }
309                        #[cfg(not(windows))]
310                        {
311                            std::path::PathBuf::from(path)
312                        }
313                    };
314                    let file = std::fs::File::open(path)?;
315                    Ok(serde_json::from_reader(file)?)
316                }
317                #[cfg(not(feature = "resolve-file"))]
318                {
319                    Err("`resolve-file` feature or a custom resolver is required to resolve external schemas via files".into())
320                }
321            }
322            scheme => Err(format!("Unknown scheme {scheme}").into()),
323        }
324    }
325}
326
327#[cfg(feature = "resolve-async")]
328#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
329#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
330impl referencing::AsyncRetrieve for DefaultRetriever {
331    async fn retrieve(
332        &self,
333        uri: &Uri<String>,
334    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
335        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
336        {
337            Err("External references are not supported on wasm32-unknown-unknown".into())
338        }
339        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
340        match uri.scheme().as_str() {
341            "http" | "https" => {
342                #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
343                {
344                    install_tls_provider();
345
346                    Ok(reqwest::get(uri.as_str()).await?.json().await?)
347                }
348                #[cfg(all(feature = "resolve-http", target_arch = "wasm32"))]
349                {
350                    Ok(reqwest::get(uri.as_str()).await?.json().await?)
351                }
352                #[cfg(not(feature = "resolve-http"))]
353                Err("`resolve-http` feature or a custom resolver is required to resolve external schemas via HTTP".into())
354            }
355            "file" => {
356                #[cfg(feature = "resolve-file")]
357                {
358                    // File operations are blocking, so we use tokio's spawn_blocking
359                    let path = uri.path().as_str().to_string();
360                    let contents = tokio::task::spawn_blocking(
361                        move || -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
362                            let path = {
363                                #[cfg(windows)]
364                                {
365                                    let path = path.trim_start_matches('/').replace('/', "\\");
366                                    std::path::PathBuf::from(path)
367                                }
368                                #[cfg(not(windows))]
369                                {
370                                    std::path::PathBuf::from(path)
371                                }
372                            };
373                            let file = std::fs::File::open(path)?;
374                            Ok(serde_json::from_reader(file)?)
375                        },
376                    )
377                    .await??;
378                    Ok(contents)
379                }
380                #[cfg(not(feature = "resolve-file"))]
381                {
382                    Err("`resolve-file` feature or a custom resolver is required to resolve external schemas via files".into())
383                }
384            }
385            scheme => Err(format!("Unknown scheme {scheme}").into()),
386        }
387    }
388}
389
390#[cfg(all(test, not(target_arch = "wasm32")))]
391use percent_encoding::{AsciiSet, CONTROLS};
392
393#[cfg(all(test, not(target_arch = "wasm32")))]
394const URI_SEGMENT: &AsciiSet = &CONTROLS
395    .add(b' ')
396    .add(b'"')
397    .add(b'<')
398    .add(b'>')
399    .add(b'`')
400    .add(b'#')
401    .add(b'?')
402    .add(b'{')
403    .add(b'}')
404    .add(b'/')
405    .add(b'%');
406
407#[cfg(all(test, not(target_arch = "wasm32"), not(target_os = "windows")))]
408const UNIX_URI_SEGMENT: &AsciiSet = &URI_SEGMENT.add(b'\\');
409
410#[cfg(all(test, not(target_arch = "wasm32")))]
411pub(crate) fn path_to_uri(path: &std::path::Path) -> String {
412    use percent_encoding::percent_encode;
413
414    let mut result = "file://".to_owned();
415
416    #[cfg(not(target_os = "windows"))]
417    {
418        use std::os::unix::ffi::OsStrExt;
419
420        for component in path.components().skip(1) {
421            result.push('/');
422            result.extend(percent_encode(
423                component.as_os_str().as_bytes(),
424                UNIX_URI_SEGMENT,
425            ));
426        }
427    }
428    #[cfg(target_os = "windows")]
429    {
430        use std::path::{Component, Prefix};
431        let mut components = path.components();
432
433        match components.next() {
434            Some(Component::Prefix(ref p)) => match p.kind() {
435                Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
436                    result.push('/');
437                    result.push(letter as char);
438                    result.push(':');
439                }
440                _ => panic!("Unexpected path"),
441            },
442            _ => panic!("Unexpected path"),
443        }
444
445        for component in components {
446            if component == Component::RootDir {
447                continue;
448            }
449
450            let component = component.as_os_str().to_str().expect("Unexpected path");
451
452            result.push('/');
453            result.extend(percent_encode(component.as_bytes(), URI_SEGMENT));
454        }
455    }
456    result
457}
458
459#[cfg(test)]
460mod tests {
461    #[cfg(not(target_arch = "wasm32"))]
462    use super::path_to_uri;
463    #[cfg(all(
464        feature = "resolve-http",
465        feature = "resolve-async",
466        not(target_arch = "wasm32")
467    ))]
468    use crate::AsyncHttpRetriever;
469    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
470    use crate::{HttpOptions, HttpRetriever, HttpRetrieverError};
471    use serde_json::json;
472    #[cfg(not(target_arch = "wasm32"))]
473    use std::io::Write;
474
475    #[test]
476    #[cfg(all(not(target_arch = "wasm32"), feature = "resolve-file"))]
477    fn test_retrieve_from_file() {
478        let mut temp_file = tempfile::NamedTempFile::new().expect("Failed to create temp file");
479        let external_schema = json!({
480            "type": "object",
481            "properties": {
482                "name": { "type": "string" }
483            },
484            "required": ["name"]
485        });
486        write!(temp_file, "{external_schema}").expect("Failed to write to temp file");
487
488        let uri = path_to_uri(temp_file.path());
489
490        let schema = json!({
491            "type": "object",
492            "properties": {
493                "user": { "$ref": uri }
494            }
495        });
496
497        let validator = crate::validator_for(&schema).expect("Schema compilation failed");
498
499        let valid = json!({"user": {"name": "John Doe"}});
500        assert!(validator.is_valid(&valid));
501
502        let invalid = json!({"user": {}});
503        assert!(!validator.is_valid(&invalid));
504    }
505
506    #[test]
507    fn test_unknown_scheme() {
508        let schema = json!({
509            "type": "object",
510            "properties": {
511                "test": { "$ref": "unknown-schema://test" }
512            }
513        });
514
515        let result = crate::validator_for(&schema);
516
517        assert!(result.is_err());
518        let error = result.unwrap_err().to_string();
519        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
520        assert!(error.contains("Unknown scheme"));
521        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
522        assert!(error.contains("External references are not supported on wasm32-unknown-unknown"));
523    }
524
525    #[cfg(not(target_arch = "wasm32"))]
526    fn create_temp_file(dir: &tempfile::TempDir, name: &str, content: &str) -> String {
527        let file_path = dir.path().join(name);
528        std::fs::write(&file_path, content).unwrap();
529        file_path.to_str().unwrap().to_string()
530    }
531
532    #[test]
533    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
534    fn test_http_retriever_with_default_options() {
535        let options = HttpOptions::new();
536        let retriever = HttpRetriever::new(&options);
537        assert!(retriever.is_ok());
538    }
539
540    #[test]
541    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
542    fn test_http_retriever_nonexistent_cert() {
543        let options = HttpOptions::new().add_root_certificate("/nonexistent/cert.pem");
544        let result = HttpRetriever::new(&options);
545        assert!(result.is_err());
546        let err = result.unwrap_err();
547        assert!(matches!(err, HttpRetrieverError::CertificateRead { .. }));
548        assert!(err.to_string().contains("/nonexistent/cert.pem"));
549    }
550
551    #[test]
552    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
553    fn test_http_retriever_error_source() {
554        use std::error::Error;
555
556        let options = HttpOptions::new().add_root_certificate("/nonexistent/cert.pem");
557        let err = HttpRetriever::new(&options).unwrap_err();
558        assert!(err.source().is_some());
559    }
560
561    #[test]
562    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
563    fn test_http_retriever_with_valid_certificate() {
564        // Test certificate file generated with:
565        // openssl req -x509 -newkey rsa:2048 -keyout /dev/null -out cert.pem -days 3650 -nodes -subj "/CN=test"
566        let cert_path =
567            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/test_cert.pem");
568        let options = HttpOptions::new().add_root_certificate(&cert_path);
569        let retriever = HttpRetriever::new(&options);
570        assert!(retriever.is_ok(), "Failed: {:?}", retriever.err());
571    }
572
573    #[test]
574    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
575    fn test_with_http_options_default() {
576        let http_options = HttpOptions::new();
577        let schema = json!({"type": "string"});
578        let result = crate::options().with_http_options(&http_options);
579        assert!(result.is_ok());
580        let validator = result.unwrap().build(&schema);
581        assert!(validator.is_ok());
582        assert!(validator.unwrap().is_valid(&json!("test")));
583    }
584
585    #[test]
586    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
587    fn test_with_http_options_with_timeouts() {
588        use std::time::Duration;
589
590        let http_options = HttpOptions::new()
591            .connect_timeout(Duration::from_secs(10))
592            .timeout(Duration::from_secs(30));
593        let schema = json!({"type": "integer"});
594        let result = crate::options().with_http_options(&http_options);
595        assert!(result.is_ok());
596        let validator = result.unwrap().build(&schema).unwrap();
597        assert!(validator.is_valid(&json!(42)));
598        assert!(!validator.is_valid(&json!("not an integer")));
599    }
600
601    #[test]
602    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
603    fn test_with_http_options_invalid_cert() {
604        let http_options = HttpOptions::new().add_root_certificate("/nonexistent/cert.pem");
605        let result = crate::options().with_http_options(&http_options);
606        assert!(result.is_err());
607        let err = result.unwrap_err();
608        assert!(err.to_string().contains("/nonexistent/cert.pem"));
609    }
610
611    #[test]
612    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
613    fn test_with_http_options_danger_accept_invalid_certs() {
614        let http_options = HttpOptions::new().danger_accept_invalid_certs(true);
615        let schema = json!({"type": "boolean"});
616        let result = crate::options().with_http_options(&http_options);
617        assert!(result.is_ok());
618        let validator = result.unwrap().build(&schema).unwrap();
619        assert!(validator.is_valid(&json!(true)));
620    }
621
622    #[test]
623    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
624    fn test_http_retriever_retrieve_trait() {
625        use referencing::Retrieve;
626        use std::time::Duration;
627
628        let options = HttpOptions::new().timeout(Duration::from_secs(30));
629        let retriever = HttpRetriever::new(&options).unwrap();
630        let uri =
631            referencing::uri::from_str("https://json-schema.org/draft/2020-12/schema").unwrap();
632        let result = retriever.retrieve(&uri);
633        assert!(result.is_ok());
634        let schema = result.unwrap();
635        // The meta-schema should be an object with $schema and $id
636        assert!(schema.is_object());
637        assert!(schema.get("$schema").is_some());
638    }
639
640    #[test]
641    #[cfg(all(
642        feature = "resolve-http",
643        feature = "resolve-file",
644        not(target_arch = "wasm32")
645    ))]
646    fn test_http_retriever_file_scheme() {
647        use referencing::Retrieve;
648
649        let dir = tempfile::tempdir().unwrap();
650        let schema_content = r#"{"type": "string"}"#;
651        let schema_path = dir.path().join("schema.json");
652        std::fs::write(&schema_path, schema_content).unwrap();
653
654        let options = HttpOptions::new();
655        let retriever = HttpRetriever::new(&options).unwrap();
656        let uri = referencing::uri::from_str(&path_to_uri(&schema_path)).unwrap();
657        let result = retriever.retrieve(&uri);
658        assert!(result.is_ok());
659        let schema = result.unwrap();
660        assert_eq!(schema, json!({"type": "string"}));
661    }
662
663    #[test]
664    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
665    fn test_http_retriever_unknown_scheme() {
666        use referencing::Retrieve;
667
668        let options = HttpOptions::new();
669        let retriever = HttpRetriever::new(&options).unwrap();
670        let uri = referencing::uri::from_str("ftp://example.com/schema.json").unwrap();
671        let result = retriever.retrieve(&uri);
672        assert!(result.is_err());
673        assert!(result.unwrap_err().to_string().contains("Unknown scheme"));
674    }
675
676    #[test]
677    #[cfg(all(feature = "resolve-http", not(target_arch = "wasm32")))]
678    fn test_http_retriever_error_invalid_certificate() {
679        use std::io::Write;
680
681        let mut temp = tempfile::NamedTempFile::new().unwrap();
682        temp.write_all(b"-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----")
683            .unwrap();
684        temp.flush().unwrap();
685
686        let options = HttpOptions::new().add_root_certificate(temp.path());
687        let result = HttpRetriever::new(&options);
688        assert!(result.is_err());
689        let err = result.unwrap_err();
690        assert!(matches!(err, HttpRetrieverError::ClientBuild(_)));
691        assert!(err.to_string().contains("Failed to build HTTP client"));
692    }
693
694    #[tokio::test]
695    #[cfg(all(
696        feature = "resolve-http",
697        feature = "resolve-async",
698        not(target_arch = "wasm32")
699    ))]
700    async fn test_async_http_retriever_retrieve_trait() {
701        use referencing::AsyncRetrieve;
702        use serde_json::Value;
703        use std::time::Duration;
704
705        let options = HttpOptions::new().timeout(Duration::from_secs(30));
706        let retriever = AsyncHttpRetriever::new(&options).unwrap();
707        let uri =
708            referencing::uri::from_str("https://json-schema.org/draft/2020-12/schema").unwrap();
709        let result: Result<Value, _> = retriever.retrieve(&uri).await;
710        assert!(result.is_ok());
711        let schema = result.unwrap();
712        // The meta-schema should be an object with $schema and $id
713        assert!(schema.is_object());
714        assert!(schema.get("$schema").is_some());
715    }
716
717    #[tokio::test]
718    #[cfg(all(
719        feature = "resolve-http",
720        feature = "resolve-async",
721        feature = "resolve-file",
722        not(target_arch = "wasm32")
723    ))]
724    async fn test_async_http_retriever_file_scheme() {
725        use referencing::AsyncRetrieve;
726        use serde_json::Value;
727
728        let dir = tempfile::tempdir().unwrap();
729        let schema_content = r#"{"type": "integer"}"#;
730        let schema_path = dir.path().join("schema.json");
731        std::fs::write(&schema_path, schema_content).unwrap();
732
733        let options = HttpOptions::new();
734        let retriever = AsyncHttpRetriever::new(&options).unwrap();
735        let uri = referencing::uri::from_str(&path_to_uri(&schema_path)).unwrap();
736        let result: Result<Value, _> = retriever.retrieve(&uri).await;
737        assert!(result.is_ok());
738        let schema = result.unwrap();
739        assert_eq!(schema, json!({"type": "integer"}));
740    }
741
742    #[tokio::test]
743    #[cfg(all(
744        feature = "resolve-http",
745        feature = "resolve-async",
746        not(target_arch = "wasm32")
747    ))]
748    async fn test_async_http_retriever_unknown_scheme() {
749        use referencing::AsyncRetrieve;
750        use serde_json::Value;
751
752        let options = HttpOptions::new();
753        let retriever = AsyncHttpRetriever::new(&options).unwrap();
754        let uri = referencing::uri::from_str("ftp://example.com/schema.json").unwrap();
755        let result: Result<Value, _> = retriever.retrieve(&uri).await;
756        assert!(result.is_err());
757        assert!(result.unwrap_err().to_string().contains("Unknown scheme"));
758    }
759
760    #[test]
761    #[cfg(all(not(target_arch = "wasm32"), feature = "resolve-file"))]
762    fn test_with_base_uri_resolution() {
763        let dir = tempfile::tempdir().unwrap();
764
765        let b_schema = r#"
766        {
767            "type": "object",
768            "properties": {
769                "age": { "type": "number" }
770            },
771            "required": ["age"]
772        }
773        "#;
774        let _b_path = create_temp_file(&dir, "b.json", b_schema);
775
776        let a_schema = r#"
777        {
778            "$schema": "https://json-schema.org/draft/2020-12/schema",
779            "$ref": "./b.json",
780            "type": "object"
781        }
782        "#;
783        let a_path = create_temp_file(&dir, "a.json", a_schema);
784
785        let valid_instance = serde_json::json!({ "age": 30 });
786
787        let schema_str = std::fs::read_to_string(&a_path).unwrap();
788        let schema_json: serde_json::Value = serde_json::from_str(&schema_str).unwrap();
789
790        let base_uri = path_to_uri(dir.path());
791        let validator = crate::options()
792            .with_base_uri(format!("{base_uri}/"))
793            .build(&schema_json)
794            .expect("Schema compilation failed");
795
796        assert!(validator.is_valid(&valid_instance));
797
798        let invalid_instance = serde_json::json!({ "age": "thirty" });
799        assert!(!validator.is_valid(&invalid_instance));
800    }
801}
802
803#[cfg(all(test, feature = "resolve-async", not(target_arch = "wasm32")))]
804mod async_tests {
805    use super::*;
806    use crate::Registry;
807    use serde_json::json;
808    use std::io::Write;
809
810    #[tokio::test]
811    #[cfg(feature = "resolve-file")]
812    async fn test_async_retrieve_from_file() {
813        let mut temp_file = tempfile::NamedTempFile::new().expect("Failed to create temp file");
814        let external_schema = json!({
815            "type": "object",
816            "properties": {
817                "name": { "type": "string" }
818            },
819            "required": ["name"]
820        });
821        write!(temp_file, "{external_schema}").expect("Failed to write to temp file");
822
823        let uri = path_to_uri(temp_file.path());
824
825        let schema = json!({
826            "type": "object",
827            "properties": {
828                "user": { "$ref": uri }
829            }
830        });
831
832        let validator = crate::async_options()
833            .with_base_uri("http://example.com/schema")
834            .with_retriever(DefaultRetriever)
835            .build(&schema)
836            .await
837            .expect("Invalid schema");
838
839        let valid = json!({"user": {"name": "John Doe"}});
840        assert!(validator.is_valid(&valid));
841
842        let invalid = json!({"user": {}});
843        assert!(!validator.is_valid(&invalid));
844    }
845
846    #[tokio::test]
847    async fn test_async_unknown_scheme() {
848        let schema = json!({
849            "type": "object",
850            "properties": {
851                "test": { "$ref": "unknown-schema://test" }
852            }
853        });
854
855        let result = Registry::new()
856            .async_retriever(DefaultRetriever)
857            .add("http://example.com/schema", schema)
858            .expect("Resource should be accepted")
859            .async_prepare()
860            .await;
861
862        assert!(result.is_err());
863        let error = result.unwrap_err().to_string();
864        assert!(error.contains("Unknown scheme"));
865    }
866
867    #[tokio::test]
868    #[cfg(feature = "resolve-file")]
869    async fn test_async_concurrent_retrievals() {
870        let mut temp_files = vec![];
871        let mut uris = vec![];
872
873        // Create multiple temp files with different schemas
874        for i in 0..3 {
875            let mut temp_file = tempfile::NamedTempFile::new().expect("Failed to create temp file");
876            let schema = json!({
877                "type": "object",
878                "properties": {
879                    "field": { "type": "string", "minLength": i }
880                }
881            });
882            write!(temp_file, "{schema}").expect("Failed to write to temp file");
883            uris.push(path_to_uri(temp_file.path()));
884            temp_files.push(temp_file);
885        }
886
887        // Create a schema that references all temp files
888        let schema = json!({
889            "type": "object",
890            "properties": {
891                "obj1": { "$ref": uris[0] },
892                "obj2": { "$ref": uris[1] },
893                "obj3": { "$ref": uris[2] }
894            }
895        });
896
897        let validator = crate::async_options()
898            .with_base_uri("http://example.com/schema")
899            .with_retriever(DefaultRetriever)
900            .build(&schema)
901            .await
902            .expect("Invalid schema");
903
904        let valid = json!({
905            "obj1": { "field": "" },      // minLength: 0
906            "obj2": { "field": "a" },     // minLength: 1
907            "obj3": { "field": "ab" }     // minLength: 2
908        });
909        assert!(validator.is_valid(&valid));
910
911        // Test invalid data
912        let invalid = json!({
913            "obj1": { "field": "" },
914            "obj2": { "field": "" },      // should be at least 1 char
915            "obj3": { "field": "a" }      // should be at least 2 chars
916        });
917        assert!(!validator.is_valid(&invalid));
918    }
919}