referencing/
registry.rs

1use std::{
2    collections::{hash_map::Entry, VecDeque},
3    num::NonZeroUsize,
4    pin::Pin,
5    sync::{Arc, LazyLock},
6};
7
8use ahash::{AHashMap, AHashSet};
9use fluent_uri::{pct_enc::EStr, Uri};
10use serde_json::Value;
11
12use crate::{
13    anchors::{AnchorKey, AnchorKeyRef},
14    cache::{SharedUriCache, UriCache},
15    meta::{self, metas_for_draft},
16    resource::{unescape_segment, InnerResourcePtr, JsonSchemaResource},
17    uri,
18    vocabularies::{self, VocabularySet},
19    Anchor, DefaultRetriever, Draft, Error, Resolver, Resource, ResourceRef, Retrieve,
20};
21
22/// An owned-or-refstatic wrapper for JSON `Value`.
23#[derive(Debug)]
24pub(crate) enum ValueWrapper {
25    Owned(Value),
26    StaticRef(&'static Value),
27}
28
29impl AsRef<Value> for ValueWrapper {
30    fn as_ref(&self) -> &Value {
31        match self {
32            ValueWrapper::Owned(value) => value,
33            ValueWrapper::StaticRef(value) => value,
34        }
35    }
36}
37
38// SAFETY: `Pin` guarantees stable memory locations for resource pointers,
39// while `Arc` enables cheap sharing between multiple registries
40type DocumentStore = AHashMap<Arc<Uri<String>>, Pin<Arc<ValueWrapper>>>;
41type ResourceMap = AHashMap<Arc<Uri<String>>, InnerResourcePtr>;
42
43/// Pre-loaded registry containing all JSON Schema meta-schemas and their vocabularies
44pub static SPECIFICATIONS: LazyLock<Registry> =
45    LazyLock::new(|| Registry::build_from_meta_schemas(meta::META_SCHEMAS_ALL.as_slice()));
46
47/// A registry of JSON Schema resources, each identified by their canonical URIs.
48///
49/// Registries store a collection of in-memory resources and their anchors.
50/// They eagerly process all added resources, including their subresources and anchors.
51/// This means that subresources contained within any added resources are immediately
52/// discoverable and retrievable via their own IDs.
53///
54/// # Resource Retrieval
55///
56/// Registry supports both blocking and non-blocking retrieval of external resources.
57///
58/// ## Blocking Retrieval
59///
60/// ```rust
61/// use referencing::{Registry, Resource, Retrieve, Uri};
62/// use serde_json::{json, Value};
63///
64/// struct ExampleRetriever;
65///
66/// impl Retrieve for ExampleRetriever {
67///     fn retrieve(
68///         &self,
69///         uri: &Uri<String>
70///     ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
71///         // Always return the same value for brevity
72///         Ok(json!({"type": "string"}))
73///     }
74/// }
75///
76/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
77/// let registry = Registry::options()
78///     .retriever(ExampleRetriever)
79///     .build([
80///         // Initial schema that might reference external schemas
81///         (
82///             "https://example.com/user.json",
83///             Resource::from_contents(json!({
84///                 "type": "object",
85///                 "properties": {
86///                     // Should be retrieved by `ExampleRetriever`
87///                     "role": {"$ref": "https://example.com/role.json"}
88///                 }
89///             }))
90///         )
91///     ])?;
92/// # Ok(())
93/// # }
94/// ```
95///
96/// ## Non-blocking Retrieval
97///
98/// ```rust
99/// # #[cfg(feature = "retrieve-async")]
100/// # mod example {
101/// use referencing::{Registry, Resource, AsyncRetrieve, Uri};
102/// use serde_json::{json, Value};
103///
104/// struct ExampleRetriever;
105///
106/// #[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
107/// #[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
108/// impl AsyncRetrieve for ExampleRetriever {
109///     async fn retrieve(
110///         &self,
111///         uri: &Uri<String>
112///     ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
113///         // Always return the same value for brevity
114///         Ok(json!({"type": "string"}))
115///     }
116/// }
117///
118///  # async fn example() -> Result<(), Box<dyn std::error::Error>> {
119/// let registry = Registry::options()
120///     .async_retriever(ExampleRetriever)
121///     .build([
122///         (
123///             "https://example.com/user.json",
124///             Resource::from_contents(json!({
125///                 // Should be retrieved by `ExampleRetriever`
126///                 "$ref": "https://example.com/common/user.json"
127///             }))
128///         )
129///     ])
130///     .await?;
131/// # Ok(())
132/// # }
133/// # }
134/// ```
135///
136/// The registry will automatically:
137///
138/// - Resolve external references
139/// - Cache retrieved schemas
140/// - Handle nested references
141/// - Process JSON Schema anchors
142///
143#[derive(Debug)]
144pub struct Registry {
145    documents: DocumentStore,
146    pub(crate) resources: ResourceMap,
147    anchors: AHashMap<AnchorKey, Anchor>,
148    resolution_cache: SharedUriCache,
149}
150
151impl Clone for Registry {
152    fn clone(&self) -> Self {
153        Self {
154            documents: self.documents.clone(),
155            resources: self.resources.clone(),
156            anchors: self.anchors.clone(),
157            resolution_cache: self.resolution_cache.clone(),
158        }
159    }
160}
161
162/// Configuration options for creating a [`Registry`].
163pub struct RegistryOptions<R> {
164    retriever: R,
165    draft: Draft,
166}
167
168impl<R> RegistryOptions<R> {
169    /// Set specification version under which the resources should be interpreted under.
170    #[must_use]
171    pub fn draft(mut self, draft: Draft) -> Self {
172        self.draft = draft;
173        self
174    }
175}
176
177impl RegistryOptions<Arc<dyn Retrieve>> {
178    /// Create a new [`RegistryOptions`] with default settings.
179    #[must_use]
180    pub fn new() -> Self {
181        Self {
182            retriever: Arc::new(DefaultRetriever),
183            draft: Draft::default(),
184        }
185    }
186    /// Set a custom retriever for the [`Registry`].
187    #[must_use]
188    pub fn retriever(mut self, retriever: impl IntoRetriever) -> Self {
189        self.retriever = retriever.into_retriever();
190        self
191    }
192    /// Set a custom async retriever for the [`Registry`].
193    #[cfg(feature = "retrieve-async")]
194    #[must_use]
195    pub fn async_retriever(
196        self,
197        retriever: impl IntoAsyncRetriever,
198    ) -> RegistryOptions<Arc<dyn crate::AsyncRetrieve>> {
199        RegistryOptions {
200            retriever: retriever.into_retriever(),
201            draft: self.draft,
202        }
203    }
204    /// Create a [`Registry`] from multiple resources using these options.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if:
209    /// - Any URI is invalid
210    /// - Any referenced resources cannot be retrieved
211    pub fn build(
212        self,
213        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
214    ) -> Result<Registry, Error> {
215        Registry::try_from_resources_impl(pairs, &*self.retriever, self.draft)
216    }
217}
218
219#[cfg(feature = "retrieve-async")]
220impl RegistryOptions<Arc<dyn crate::AsyncRetrieve>> {
221    /// Create a [`Registry`] from multiple resources using these options with async retrieval.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if:
226    /// - Any URI is invalid
227    /// - Any referenced resources cannot be retrieved
228    pub async fn build(
229        self,
230        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
231    ) -> Result<Registry, Error> {
232        Registry::try_from_resources_async_impl(pairs, &*self.retriever, self.draft).await
233    }
234}
235
236pub trait IntoRetriever {
237    fn into_retriever(self) -> Arc<dyn Retrieve>;
238}
239
240impl<T: Retrieve + 'static> IntoRetriever for T {
241    fn into_retriever(self) -> Arc<dyn Retrieve> {
242        Arc::new(self)
243    }
244}
245
246impl IntoRetriever for Arc<dyn Retrieve> {
247    fn into_retriever(self) -> Arc<dyn Retrieve> {
248        self
249    }
250}
251
252#[cfg(feature = "retrieve-async")]
253pub trait IntoAsyncRetriever {
254    fn into_retriever(self) -> Arc<dyn crate::AsyncRetrieve>;
255}
256
257#[cfg(feature = "retrieve-async")]
258impl<T: crate::AsyncRetrieve + 'static> IntoAsyncRetriever for T {
259    fn into_retriever(self) -> Arc<dyn crate::AsyncRetrieve> {
260        Arc::new(self)
261    }
262}
263
264#[cfg(feature = "retrieve-async")]
265impl IntoAsyncRetriever for Arc<dyn crate::AsyncRetrieve> {
266    fn into_retriever(self) -> Arc<dyn crate::AsyncRetrieve> {
267        self
268    }
269}
270
271impl Default for RegistryOptions<Arc<dyn Retrieve>> {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277impl Registry {
278    /// Get [`RegistryOptions`] for configuring a new [`Registry`].
279    #[must_use]
280    pub fn options() -> RegistryOptions<Arc<dyn Retrieve>> {
281        RegistryOptions::new()
282    }
283    /// Create a new [`Registry`] with a single resource.
284    ///
285    /// # Arguments
286    ///
287    /// * `uri` - The URI of the resource.
288    /// * `resource` - The resource to add.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if the URI is invalid or if there's an issue processing the resource.
293    pub fn try_new(uri: impl AsRef<str>, resource: Resource) -> Result<Self, Error> {
294        Self::try_new_impl(uri, resource, &DefaultRetriever, Draft::default())
295    }
296    /// Create a new [`Registry`] from an iterator of (URI, Resource) pairs.
297    ///
298    /// # Arguments
299    ///
300    /// * `pairs` - An iterator of (URI, Resource) pairs.
301    ///
302    /// # Errors
303    ///
304    /// Returns an error if any URI is invalid or if there's an issue processing the resources.
305    pub fn try_from_resources(
306        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
307    ) -> Result<Self, Error> {
308        Self::try_from_resources_impl(pairs, &DefaultRetriever, Draft::default())
309    }
310    fn try_new_impl(
311        uri: impl AsRef<str>,
312        resource: Resource,
313        retriever: &dyn Retrieve,
314        draft: Draft,
315    ) -> Result<Self, Error> {
316        Self::try_from_resources_impl([(uri, resource)], retriever, draft)
317    }
318    fn try_from_resources_impl(
319        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
320        retriever: &dyn Retrieve,
321        draft: Draft,
322    ) -> Result<Self, Error> {
323        let mut documents = AHashMap::new();
324        let mut resources = ResourceMap::new();
325        let mut anchors = AHashMap::new();
326        let mut resolution_cache = UriCache::new();
327        let custom_metaschemas = process_resources(
328            pairs,
329            retriever,
330            &mut documents,
331            &mut resources,
332            &mut anchors,
333            &mut resolution_cache,
334            draft,
335        )?;
336
337        // Validate that all custom $schema references are registered
338        validate_custom_metaschemas(&custom_metaschemas, &resources)?;
339
340        Ok(Registry {
341            documents,
342            resources,
343            anchors,
344            resolution_cache: resolution_cache.into_shared(),
345        })
346    }
347    /// Create a new [`Registry`] from an iterator of (URI, Resource) pairs using an async retriever.
348    ///
349    /// # Arguments
350    ///
351    /// * `pairs` - An iterator of (URI, Resource) pairs.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if any URI is invalid or if there's an issue processing the resources.
356    #[cfg(feature = "retrieve-async")]
357    async fn try_from_resources_async_impl(
358        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
359        retriever: &dyn crate::AsyncRetrieve,
360        draft: Draft,
361    ) -> Result<Self, Error> {
362        let mut documents = AHashMap::new();
363        let mut resources = ResourceMap::new();
364        let mut anchors = AHashMap::new();
365        let mut resolution_cache = UriCache::new();
366
367        let custom_metaschemas = process_resources_async(
368            pairs,
369            retriever,
370            &mut documents,
371            &mut resources,
372            &mut anchors,
373            &mut resolution_cache,
374            draft,
375        )
376        .await?;
377
378        // Validate that all custom $schema references are registered
379        validate_custom_metaschemas(&custom_metaschemas, &resources)?;
380
381        Ok(Registry {
382            documents,
383            resources,
384            anchors,
385            resolution_cache: resolution_cache.into_shared(),
386        })
387    }
388    /// Create a new registry with a new resource.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if the URI is invalid or if there's an issue processing the resource.
393    pub fn try_with_resource(
394        self,
395        uri: impl AsRef<str>,
396        resource: Resource,
397    ) -> Result<Registry, Error> {
398        let draft = resource.draft();
399        self.try_with_resources([(uri, resource)], draft)
400    }
401    /// Create a new registry with new resources.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error if any URI is invalid or if there's an issue processing the resources.
406    pub fn try_with_resources(
407        self,
408        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
409        draft: Draft,
410    ) -> Result<Registry, Error> {
411        self.try_with_resources_and_retriever(pairs, &DefaultRetriever, draft)
412    }
413    /// Create a new registry with new resources and using the given retriever.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if any URI is invalid or if there's an issue processing the resources.
418    pub fn try_with_resources_and_retriever(
419        self,
420        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
421        retriever: &dyn Retrieve,
422        draft: Draft,
423    ) -> Result<Registry, Error> {
424        let mut documents = self.documents;
425        let mut resources = self.resources;
426        let mut anchors = self.anchors;
427        let mut resolution_cache = self.resolution_cache.into_local();
428        let custom_metaschemas = process_resources(
429            pairs,
430            retriever,
431            &mut documents,
432            &mut resources,
433            &mut anchors,
434            &mut resolution_cache,
435            draft,
436        )?;
437        validate_custom_metaschemas(&custom_metaschemas, &resources)?;
438        Ok(Registry {
439            documents,
440            resources,
441            anchors,
442            resolution_cache: resolution_cache.into_shared(),
443        })
444    }
445    /// Create a new registry with new resources and using the given non-blocking retriever.
446    ///
447    /// # Errors
448    ///
449    /// Returns an error if any URI is invalid or if there's an issue processing the resources.
450    #[cfg(feature = "retrieve-async")]
451    pub async fn try_with_resources_and_retriever_async(
452        self,
453        pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
454        retriever: &dyn crate::AsyncRetrieve,
455        draft: Draft,
456    ) -> Result<Registry, Error> {
457        let mut documents = self.documents;
458        let mut resources = self.resources;
459        let mut anchors = self.anchors;
460        let mut resolution_cache = self.resolution_cache.into_local();
461        let custom_metaschemas = process_resources_async(
462            pairs,
463            retriever,
464            &mut documents,
465            &mut resources,
466            &mut anchors,
467            &mut resolution_cache,
468            draft,
469        )
470        .await?;
471        validate_custom_metaschemas(&custom_metaschemas, &resources)?;
472        Ok(Registry {
473            documents,
474            resources,
475            anchors,
476            resolution_cache: resolution_cache.into_shared(),
477        })
478    }
479    /// Create a new [`Resolver`] for this registry with the given base URI.
480    ///
481    /// # Errors
482    ///
483    /// Returns an error if the base URI is invalid.
484    pub fn try_resolver(&self, base_uri: &str) -> Result<Resolver<'_>, Error> {
485        let base = uri::from_str(base_uri)?;
486        Ok(self.resolver(base))
487    }
488    /// Create a new [`Resolver`] for this registry with a known valid base URI.
489    #[must_use]
490    pub fn resolver(&self, base_uri: Uri<String>) -> Resolver<'_> {
491        Resolver::new(self, Arc::new(base_uri))
492    }
493    pub(crate) fn anchor<'a>(&self, uri: &'a Uri<String>, name: &'a str) -> Result<&Anchor, Error> {
494        let key = AnchorKeyRef::new(uri, name);
495        if let Some(value) = self.anchors.get(key.borrow_dyn()) {
496            return Ok(value);
497        }
498        let resource = &self.resources[uri];
499        if let Some(id) = resource.id() {
500            let uri = uri::from_str(id)?;
501            let key = AnchorKeyRef::new(&uri, name);
502            if let Some(value) = self.anchors.get(key.borrow_dyn()) {
503                return Ok(value);
504            }
505        }
506        if name.contains('/') {
507            Err(Error::invalid_anchor(name.to_string()))
508        } else {
509            Err(Error::no_such_anchor(name.to_string()))
510        }
511    }
512    /// Resolves a reference URI against a base URI using registry's cache.
513    ///
514    /// # Errors
515    ///
516    /// Returns an error if base has not schema or there is a fragment.
517    pub fn resolve_against(&self, base: &Uri<&str>, uri: &str) -> Result<Arc<Uri<String>>, Error> {
518        self.resolution_cache.resolve_against(base, uri)
519    }
520    /// Returns vocabulary set configured for given draft and contents.
521    ///
522    /// For custom meta-schemas (`Draft::Unknown`), looks up the meta-schema in the registry
523    /// and extracts its `$vocabulary` declaration. If the meta-schema is not registered,
524    /// returns the default Draft 2020-12 vocabularies.
525    #[must_use]
526    pub fn find_vocabularies(&self, draft: Draft, contents: &Value) -> VocabularySet {
527        match draft.detect(contents) {
528            Draft::Unknown => {
529                // Custom/unknown meta-schema - try to look it up in the registry
530                if let Some(specification) = contents
531                    .as_object()
532                    .and_then(|obj| obj.get("$schema"))
533                    .and_then(|s| s.as_str())
534                {
535                    if let Ok(mut uri) = uri::from_str(specification) {
536                        // Remove fragment for lookup (e.g., "http://example.com/schema#" -> "http://example.com/schema")
537                        // Resources are stored without fragments, so we must strip it to find the meta-schema
538                        uri.set_fragment(None);
539                        if let Some(resource) = self.resources.get(&uri) {
540                            // Found the custom meta-schema - extract vocabularies
541                            if let Ok(Some(vocabularies)) = vocabularies::find(resource.contents())
542                            {
543                                return vocabularies;
544                            }
545                        }
546                        // Meta-schema not registered - this will be caught during compilation
547                        // For now, return default vocabularies to allow resource creation
548                    }
549                }
550                // Default to Draft 2020-12 vocabularies for unknown meta-schemas
551                Draft::Unknown.default_vocabularies()
552            }
553            draft => draft.default_vocabularies(),
554        }
555    }
556
557    /// Build a registry with all the given meta-schemas from specs.
558    pub(crate) fn build_from_meta_schemas(schemas: &[(&'static str, &'static Value)]) -> Self {
559        let schemas_count = schemas.len();
560        let pairs = schemas
561            .iter()
562            .map(|(uri, schema)| (uri, ResourceRef::from_contents(schema)));
563
564        let mut documents = DocumentStore::with_capacity(schemas_count);
565        let mut resources = ResourceMap::with_capacity(schemas_count);
566
567        // The actual number of anchors and cache-entries varies across
568        // drafts. We overshoot here to avoid reallocations, using the sum
569        // over all specifications.
570        let mut anchors = AHashMap::with_capacity(8);
571        let mut resolution_cache = UriCache::with_capacity(35);
572
573        process_meta_schemas(
574            pairs,
575            &mut documents,
576            &mut resources,
577            &mut anchors,
578            &mut resolution_cache,
579        )
580        .expect("Failed to process meta schemas");
581
582        Self {
583            documents,
584            resources,
585            anchors,
586            resolution_cache: resolution_cache.into_shared(),
587        }
588    }
589}
590
591fn process_meta_schemas(
592    pairs: impl IntoIterator<Item = (impl AsRef<str>, ResourceRef<'static>)>,
593    documents: &mut DocumentStore,
594    resources: &mut ResourceMap,
595    anchors: &mut AHashMap<AnchorKey, Anchor>,
596    resolution_cache: &mut UriCache,
597) -> Result<(), Error> {
598    let mut queue = VecDeque::with_capacity(32);
599
600    for (uri, resource) in pairs {
601        let uri = uri::from_str(uri.as_ref().trim_end_matches('#'))?;
602        let key = Arc::new(uri);
603        let contents: &'static Value = resource.contents();
604        let wrapped_value = Arc::pin(ValueWrapper::StaticRef(contents));
605        let resource = InnerResourcePtr::new((*wrapped_value).as_ref(), resource.draft());
606        documents.insert(Arc::clone(&key), wrapped_value);
607        resources.insert(Arc::clone(&key), resource.clone());
608        queue.push_back((key, resource));
609    }
610
611    // Process current queue and collect references to external resources
612    while let Some((mut base, resource)) = queue.pop_front() {
613        if let Some(id) = resource.id() {
614            base = resolution_cache.resolve_against(&base.borrow(), id)?;
615            resources.insert(base.clone(), resource.clone());
616        }
617
618        // Look for anchors
619        for anchor in resource.anchors() {
620            anchors.insert(AnchorKey::new(base.clone(), anchor.name()), anchor);
621        }
622
623        // Process subresources
624        for contents in resource.draft().subresources_of(resource.contents()) {
625            let subresource = InnerResourcePtr::new(contents, resource.draft());
626            queue.push_back((base.clone(), subresource));
627        }
628    }
629    Ok(())
630}
631
632#[derive(Hash, Eq, PartialEq)]
633struct ReferenceKey {
634    base_ptr: NonZeroUsize,
635    reference: String,
636}
637
638impl ReferenceKey {
639    fn new(base: &Arc<Uri<String>>, reference: &str) -> Self {
640        Self {
641            base_ptr: NonZeroUsize::new(Arc::as_ptr(base) as usize)
642                .expect("Arc pointer should never be null"),
643            reference: reference.to_owned(),
644        }
645    }
646}
647
648type ReferenceTracker = AHashSet<ReferenceKey>;
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
651enum ReferenceKind {
652    Ref,
653    Schema,
654}
655
656/// An entry in the processing queue.
657/// The optional third element is the document root URI, used when the resource
658/// was extracted from a fragment of a larger document. Local `$ref`s need to be
659/// resolved against the document root, not just the fragment content.
660type QueueEntry = (Arc<Uri<String>>, InnerResourcePtr, Option<Arc<Uri<String>>>);
661
662struct ProcessingState {
663    queue: VecDeque<QueueEntry>,
664    seen: ReferenceTracker,
665    external: AHashSet<(String, Uri<String>, ReferenceKind)>,
666    scratch: String,
667    refers_metaschemas: bool,
668    custom_metaschemas: Vec<Arc<Uri<String>>>,
669    /// Tracks schema pointers we've visited during recursive external resource collection.
670    /// This prevents infinite recursion when schemas reference each other.
671    visited_schemas: AHashSet<usize>,
672}
673
674impl ProcessingState {
675    fn new() -> Self {
676        Self {
677            queue: VecDeque::with_capacity(32),
678            seen: ReferenceTracker::new(),
679            external: AHashSet::new(),
680            scratch: String::new(),
681            refers_metaschemas: false,
682            custom_metaschemas: Vec::new(),
683            visited_schemas: AHashSet::new(),
684        }
685    }
686}
687
688fn process_input_resources(
689    pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
690    documents: &mut DocumentStore,
691    resources: &mut ResourceMap,
692    state: &mut ProcessingState,
693) -> Result<(), Error> {
694    for (uri, resource) in pairs {
695        let uri = uri::from_str(uri.as_ref().trim_end_matches('#'))?;
696        let key = Arc::new(uri);
697        match documents.entry(Arc::clone(&key)) {
698            Entry::Occupied(_) => {}
699            Entry::Vacant(entry) => {
700                let (draft, contents) = resource.into_inner();
701                let wrapped_value = Arc::pin(ValueWrapper::Owned(contents));
702                let resource = InnerResourcePtr::new((*wrapped_value).as_ref(), draft);
703                resources.insert(Arc::clone(&key), resource.clone());
704
705                // Track resources with custom meta-schemas for later validation
706                if draft == Draft::Unknown {
707                    state.custom_metaschemas.push(Arc::clone(&key));
708                }
709
710                state.queue.push_back((key, resource, None));
711                entry.insert(wrapped_value);
712            }
713        }
714    }
715    Ok(())
716}
717
718fn process_queue(
719    state: &mut ProcessingState,
720    resources: &mut ResourceMap,
721    anchors: &mut AHashMap<AnchorKey, Anchor>,
722    resolution_cache: &mut UriCache,
723) -> Result<(), Error> {
724    while let Some((mut base, resource, document_root_uri)) = state.queue.pop_front() {
725        if let Some(id) = resource.id() {
726            base = resolve_id(&base, id, resolution_cache)?;
727            resources.insert(base.clone(), resource.clone());
728        }
729
730        for anchor in resource.anchors() {
731            anchors.insert(AnchorKey::new(base.clone(), anchor.name()), anchor);
732        }
733
734        // Determine the document root for resolving local $refs.
735        // If document_root_uri is set (e.g., for fragment-extracted resources),
736        // look up the full document. Otherwise, this resource IS the document root.
737        let root = document_root_uri
738            .as_ref()
739            .and_then(|uri| resources.get(uri))
740            .map_or_else(|| resource.contents(), InnerResourcePtr::contents);
741
742        // Skip if already visited during local $ref resolution
743        let contents_ptr = std::ptr::from_ref::<Value>(resource.contents()) as usize;
744        if state.visited_schemas.insert(contents_ptr) {
745            collect_external_resources(
746                &base,
747                root,
748                resource.contents(),
749                &mut state.external,
750                &mut state.seen,
751                resolution_cache,
752                &mut state.scratch,
753                &mut state.refers_metaschemas,
754                resource.draft(),
755                &mut state.visited_schemas,
756            )?;
757        }
758
759        // Subresources inherit the document root URI, or use the current base if none set
760        let subresource_root_uri = document_root_uri.or_else(|| Some(base.clone()));
761        for contents in resource.draft().subresources_of(resource.contents()) {
762            let subresource = InnerResourcePtr::new(contents, resource.draft());
763            state
764                .queue
765                .push_back((base.clone(), subresource, subresource_root_uri.clone()));
766        }
767    }
768    Ok(())
769}
770
771fn handle_fragment(
772    uri: &Uri<String>,
773    resource: &InnerResourcePtr,
774    key: &Arc<Uri<String>>,
775    default_draft: Draft,
776    queue: &mut VecDeque<QueueEntry>,
777    document_root_uri: Arc<Uri<String>>,
778) {
779    if let Some(fragment) = uri.fragment() {
780        if let Some(resolved) = pointer(resource.contents(), fragment.as_str()) {
781            let draft = default_draft.detect(resolved);
782            let contents = std::ptr::addr_of!(*resolved);
783            let resource = InnerResourcePtr::new(contents, draft);
784            queue.push_back((Arc::clone(key), resource, Some(document_root_uri)));
785        }
786    }
787}
788
789fn handle_metaschemas(
790    refers_metaschemas: bool,
791    resources: &mut ResourceMap,
792    anchors: &mut AHashMap<AnchorKey, Anchor>,
793    draft_version: Draft,
794) {
795    if refers_metaschemas {
796        let schemas = metas_for_draft(draft_version);
797        let draft_registry = Registry::build_from_meta_schemas(schemas);
798        resources.reserve(draft_registry.resources.len());
799        for (key, resource) in draft_registry.resources {
800            resources.insert(key, resource.clone());
801        }
802        anchors.reserve(draft_registry.anchors.len());
803        for (key, anchor) in draft_registry.anchors {
804            anchors.insert(key, anchor);
805        }
806    }
807}
808
809fn create_resource(
810    retrieved: Value,
811    fragmentless: Uri<String>,
812    default_draft: Draft,
813    documents: &mut DocumentStore,
814    resources: &mut ResourceMap,
815    custom_metaschemas: &mut Vec<Arc<Uri<String>>>,
816) -> (Arc<Uri<String>>, InnerResourcePtr) {
817    let draft = default_draft.detect(&retrieved);
818    let wrapped_value = Arc::pin(ValueWrapper::Owned(retrieved));
819    let resource = InnerResourcePtr::new((*wrapped_value).as_ref(), draft);
820    let key = Arc::new(fragmentless);
821    documents.insert(Arc::clone(&key), wrapped_value);
822    resources.insert(Arc::clone(&key), resource.clone());
823
824    // Track resources with custom meta-schemas for later validation
825    if draft == Draft::Unknown {
826        custom_metaschemas.push(Arc::clone(&key));
827    }
828
829    (key, resource)
830}
831
832fn process_resources(
833    pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
834    retriever: &dyn Retrieve,
835    documents: &mut DocumentStore,
836    resources: &mut ResourceMap,
837    anchors: &mut AHashMap<AnchorKey, Anchor>,
838    resolution_cache: &mut UriCache,
839    default_draft: Draft,
840) -> Result<Vec<Arc<Uri<String>>>, Error> {
841    let mut state = ProcessingState::new();
842    process_input_resources(pairs, documents, resources, &mut state)?;
843
844    loop {
845        if state.queue.is_empty() && state.external.is_empty() {
846            break;
847        }
848
849        process_queue(&mut state, resources, anchors, resolution_cache)?;
850
851        // Retrieve external resources
852        for (original, uri, kind) in state.external.drain() {
853            let mut fragmentless = uri.clone();
854            fragmentless.set_fragment(None);
855            if !resources.contains_key(&fragmentless) {
856                let retrieved = match retriever.retrieve(&fragmentless) {
857                    Ok(retrieved) => retrieved,
858                    Err(error) => {
859                        handle_retrieve_error(&uri, &original, &fragmentless, error, kind)?;
860                        continue;
861                    }
862                };
863
864                let (key, resource) = create_resource(
865                    retrieved,
866                    fragmentless,
867                    default_draft,
868                    documents,
869                    resources,
870                    &mut state.custom_metaschemas,
871                );
872                handle_fragment(
873                    &uri,
874                    &resource,
875                    &key,
876                    default_draft,
877                    &mut state.queue,
878                    Arc::clone(&key),
879                );
880                state.queue.push_back((key, resource, None));
881            }
882        }
883    }
884
885    handle_metaschemas(state.refers_metaschemas, resources, anchors, default_draft);
886
887    Ok(state.custom_metaschemas)
888}
889
890#[cfg(feature = "retrieve-async")]
891async fn process_resources_async(
892    pairs: impl IntoIterator<Item = (impl AsRef<str>, Resource)>,
893    retriever: &dyn crate::AsyncRetrieve,
894    documents: &mut DocumentStore,
895    resources: &mut ResourceMap,
896    anchors: &mut AHashMap<AnchorKey, Anchor>,
897    resolution_cache: &mut UriCache,
898    default_draft: Draft,
899) -> Result<Vec<Arc<Uri<String>>>, Error> {
900    type ExternalRefsByBase = AHashMap<Uri<String>, Vec<(String, Uri<String>, ReferenceKind)>>;
901
902    let mut state = ProcessingState::new();
903    process_input_resources(pairs, documents, resources, &mut state)?;
904
905    loop {
906        if state.queue.is_empty() && state.external.is_empty() {
907            break;
908        }
909
910        process_queue(&mut state, resources, anchors, resolution_cache)?;
911
912        if !state.external.is_empty() {
913            // Group external refs by fragmentless URI to avoid fetching the same resource multiple times.
914            // Multiple refs may point to the same base URL with different fragments (e.g., #/$defs/foo and #/$defs/bar).
915            // We need to fetch each unique base URL only once, then handle all fragment refs against it.
916            let mut grouped = ExternalRefsByBase::new();
917            for (original, uri, kind) in state.external.drain() {
918                let mut fragmentless = uri.clone();
919                fragmentless.set_fragment(None);
920                if !resources.contains_key(&fragmentless) {
921                    grouped
922                        .entry(fragmentless)
923                        .or_default()
924                        .push((original, uri, kind));
925                }
926            }
927
928            // Fetch each unique fragmentless URI once
929            let entries: Vec<_> = grouped.into_iter().collect();
930            let results = {
931                let futures = entries
932                    .iter()
933                    .map(|(fragmentless, _)| retriever.retrieve(fragmentless));
934                futures::future::join_all(futures).await
935            };
936
937            for ((fragmentless, refs), result) in entries.into_iter().zip(results) {
938                let retrieved = match result {
939                    Ok(retrieved) => retrieved,
940                    Err(error) => {
941                        // Report error for the first ref that caused this fetch
942                        if let Some((original, uri, kind)) = refs.into_iter().next() {
943                            handle_retrieve_error(&uri, &original, &fragmentless, error, kind)?;
944                        }
945                        continue;
946                    }
947                };
948
949                let (key, resource) = create_resource(
950                    retrieved,
951                    fragmentless,
952                    default_draft,
953                    documents,
954                    resources,
955                    &mut state.custom_metaschemas,
956                );
957
958                // Handle all fragment refs that pointed to this base URL
959                for (_, uri, _) in &refs {
960                    handle_fragment(
961                        uri,
962                        &resource,
963                        &key,
964                        default_draft,
965                        &mut state.queue,
966                        Arc::clone(&key),
967                    );
968                }
969
970                state.queue.push_back((key, resource, None));
971            }
972        }
973    }
974
975    handle_metaschemas(state.refers_metaschemas, resources, anchors, default_draft);
976
977    Ok(state.custom_metaschemas)
978}
979
980fn handle_retrieve_error(
981    uri: &Uri<String>,
982    original: &str,
983    fragmentless: &Uri<String>,
984    error: Box<dyn std::error::Error + Send + Sync>,
985    kind: ReferenceKind,
986) -> Result<(), Error> {
987    match kind {
988        ReferenceKind::Schema => {
989            // $schema fetch failures are non-fatal during resource processing
990            // Unregistered custom meta-schemas will be caught in validate_custom_metaschemas()
991            Ok(())
992        }
993        ReferenceKind::Ref => {
994            // $ref fetch failures are fatal - they're required for validation
995            if uri.scheme().as_str() == "json-schema" {
996                Err(Error::unretrievable(
997                    original,
998                    "No base URI is available".into(),
999                ))
1000            } else {
1001                Err(Error::unretrievable(fragmentless.as_str(), error))
1002            }
1003        }
1004    }
1005}
1006
1007fn validate_custom_metaschemas(
1008    custom_metaschemas: &[Arc<Uri<String>>],
1009    resources: &ResourceMap,
1010) -> Result<(), Error> {
1011    // Only validate resources with Draft::Unknown
1012    for uri in custom_metaschemas {
1013        if let Some(resource) = resources.get(uri) {
1014            // Extract the $schema value from this resource
1015            if let Some(schema_uri) = resource
1016                .contents()
1017                .as_object()
1018                .and_then(|obj| obj.get("$schema"))
1019                .and_then(|s| s.as_str())
1020            {
1021                // Check if this meta-schema is registered
1022                match uri::from_str(schema_uri) {
1023                    Ok(mut meta_uri) => {
1024                        // Remove fragment for lookup (e.g., "http://example.com/schema#" -> "http://example.com/schema")
1025                        meta_uri.set_fragment(None);
1026                        if !resources.contains_key(&meta_uri) {
1027                            return Err(Error::unknown_specification(schema_uri));
1028                        }
1029                    }
1030                    Err(_) => {
1031                        return Err(Error::unknown_specification(schema_uri));
1032                    }
1033                }
1034            }
1035        }
1036    }
1037    Ok(())
1038}
1039
1040fn collect_external_resources(
1041    base: &Arc<Uri<String>>,
1042    root: &Value,
1043    contents: &Value,
1044    collected: &mut AHashSet<(String, Uri<String>, ReferenceKind)>,
1045    seen: &mut ReferenceTracker,
1046    resolution_cache: &mut UriCache,
1047    scratch: &mut String,
1048    refers_metaschemas: &mut bool,
1049    draft: Draft,
1050    visited: &mut AHashSet<usize>,
1051) -> Result<(), Error> {
1052    // URN schemes are not supported for external resolution
1053    if base.scheme().as_str() == "urn" {
1054        return Ok(());
1055    }
1056
1057    macro_rules! on_reference {
1058        ($reference:expr, $key:literal) => {
1059            // Skip well-known schema references
1060            if $reference.starts_with("https://json-schema.org/draft/")
1061                || $reference.starts_with("http://json-schema.org/draft-")
1062                || base.as_str().starts_with("https://json-schema.org/draft/")
1063            {
1064                if $key == "$ref" {
1065                    *refers_metaschemas = true;
1066                }
1067            } else if $reference != "#" {
1068                if mark_reference(seen, base, $reference) {
1069                    // Handle local references separately as they may have nested references to external resources
1070                    if $reference.starts_with('#') {
1071                        // Use the root document for pointer resolution since local refs are always
1072                        // relative to the document root, not the current subschema.
1073                        // Also track $id changes along the path to get the correct base URI.
1074                        if let Some((referenced, resolved_base)) = pointer_with_base(
1075                            root,
1076                            $reference.trim_start_matches('#'),
1077                            base,
1078                            resolution_cache,
1079                            draft,
1080                        )? {
1081                            // Recursively collect from the referenced schema and all its subresources
1082                            collect_external_resources_recursive(
1083                                &resolved_base,
1084                                root,
1085                                referenced,
1086                                collected,
1087                                seen,
1088                                resolution_cache,
1089                                scratch,
1090                                refers_metaschemas,
1091                                draft,
1092                                visited,
1093                            )?;
1094                        }
1095                    } else {
1096                        let resolved = if base.has_fragment() {
1097                            let mut base_without_fragment = base.as_ref().clone();
1098                            base_without_fragment.set_fragment(None);
1099
1100                            let (path, fragment) = match $reference.split_once('#') {
1101                                Some((path, fragment)) => (path, Some(fragment)),
1102                                None => ($reference, None),
1103                            };
1104
1105                            let mut resolved = (*resolution_cache
1106                                .resolve_against(&base_without_fragment.borrow(), path)?)
1107                            .clone();
1108                            // Add the fragment back if present
1109                            if let Some(fragment) = fragment {
1110                                // It is cheaper to check if it is properly encoded than allocate given that
1111                                // the majority of inputs do not need to be additionally encoded
1112                                if let Some(encoded) = uri::EncodedString::new(fragment) {
1113                                    resolved = resolved.with_fragment(Some(encoded));
1114                                } else {
1115                                    uri::encode_to(fragment, scratch);
1116                                    resolved = resolved.with_fragment(Some(
1117                                        uri::EncodedString::new_or_panic(scratch),
1118                                    ));
1119                                    scratch.clear();
1120                                }
1121                            }
1122                            resolved
1123                        } else {
1124                            (*resolution_cache
1125                                .resolve_against(&base.borrow(), $reference)?)
1126                            .clone()
1127                        };
1128
1129                        let kind = if $key == "$schema" {
1130                            ReferenceKind::Schema
1131                        } else {
1132                            ReferenceKind::Ref
1133                        };
1134                        collected.insert(($reference.to_string(), resolved, kind));
1135                    }
1136                }
1137            }
1138        };
1139    }
1140
1141    if let Some(object) = contents.as_object() {
1142        if object.len() < 3 {
1143            for (key, value) in object {
1144                if key == "$ref" {
1145                    if let Some(reference) = value.as_str() {
1146                        on_reference!(reference, "$ref");
1147                    }
1148                } else if key == "$schema" {
1149                    if let Some(reference) = value.as_str() {
1150                        on_reference!(reference, "$schema");
1151                    }
1152                }
1153            }
1154        } else {
1155            if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
1156                on_reference!(reference, "$ref");
1157            }
1158            if let Some(reference) = object.get("$schema").and_then(Value::as_str) {
1159                on_reference!(reference, "$schema");
1160            }
1161        }
1162    }
1163    Ok(())
1164}
1165
1166/// Recursively collect external resources from a schema and all its subresources.
1167///
1168/// The `visited` set tracks schema pointers we've already processed to avoid infinite
1169/// recursion when schemas reference each other (directly or through subresources).
1170fn collect_external_resources_recursive(
1171    base: &Arc<Uri<String>>,
1172    root: &Value,
1173    contents: &Value,
1174    collected: &mut AHashSet<(String, Uri<String>, ReferenceKind)>,
1175    seen: &mut ReferenceTracker,
1176    resolution_cache: &mut UriCache,
1177    scratch: &mut String,
1178    refers_metaschemas: &mut bool,
1179    draft: Draft,
1180    visited: &mut AHashSet<usize>,
1181) -> Result<(), Error> {
1182    // Track by pointer address to avoid processing the same schema twice
1183    let ptr = std::ptr::from_ref::<Value>(contents) as usize;
1184    if !visited.insert(ptr) {
1185        return Ok(());
1186    }
1187
1188    let current_base = match draft.id_of(contents) {
1189        Some(id) => resolve_id(base, id, resolution_cache)?,
1190        None => Arc::clone(base),
1191    };
1192
1193    // First, collect from the current schema
1194    collect_external_resources(
1195        &current_base,
1196        root,
1197        contents,
1198        collected,
1199        seen,
1200        resolution_cache,
1201        scratch,
1202        refers_metaschemas,
1203        draft,
1204        visited,
1205    )?;
1206
1207    // Then recursively process all subresources
1208    for subresource in draft.subresources_of(contents) {
1209        collect_external_resources_recursive(
1210            &current_base,
1211            root,
1212            subresource,
1213            collected,
1214            seen,
1215            resolution_cache,
1216            scratch,
1217            refers_metaschemas,
1218            draft,
1219            visited,
1220        )?;
1221    }
1222    Ok(())
1223}
1224
1225fn mark_reference(seen: &mut ReferenceTracker, base: &Arc<Uri<String>>, reference: &str) -> bool {
1226    seen.insert(ReferenceKey::new(base, reference))
1227}
1228
1229/// Resolve an `$id` against a base URI, handling anchor-style IDs and empty fragments.
1230///
1231/// Anchor-style `$id` values (starting with `#`) don't change the base URI.
1232/// Empty fragments are stripped from the resolved URI.
1233fn resolve_id(
1234    base: &Arc<Uri<String>>,
1235    id: &str,
1236    resolution_cache: &mut UriCache,
1237) -> Result<Arc<Uri<String>>, Error> {
1238    if id.starts_with('#') {
1239        return Ok(Arc::clone(base));
1240    }
1241    let mut resolved = (*resolution_cache.resolve_against(&base.borrow(), id)?).clone();
1242    if resolved.fragment().is_some_and(EStr::is_empty) {
1243        resolved.set_fragment(None);
1244    }
1245    Ok(Arc::new(resolved))
1246}
1247
1248/// Look up a value by a JSON Pointer.
1249///
1250/// **NOTE**: A slightly faster version of pointer resolution based on `Value::pointer` from `serde_json`.
1251pub fn pointer<'a>(document: &'a Value, pointer: &str) -> Option<&'a Value> {
1252    if pointer.is_empty() {
1253        return Some(document);
1254    }
1255    if !pointer.starts_with('/') {
1256        return None;
1257    }
1258    pointer.split('/').skip(1).map(unescape_segment).try_fold(
1259        document,
1260        |target, token| match target {
1261            Value::Object(map) => map.get(&*token),
1262            Value::Array(list) => parse_index(&token).and_then(|x| list.get(x)),
1263            _ => None,
1264        },
1265    )
1266}
1267
1268/// Look up a value by a JSON Pointer, tracking `$id` changes along the path.
1269///
1270/// Returns both the resolved value and the accumulated base URI after processing
1271/// any `$id` declarations encountered along the path. Note that anchor-style `$id`
1272/// values (starting with `#`) don't change the base URI.
1273#[allow(clippy::type_complexity)]
1274fn pointer_with_base<'a>(
1275    document: &'a Value,
1276    pointer: &str,
1277    base: &Arc<Uri<String>>,
1278    resolution_cache: &mut UriCache,
1279    draft: Draft,
1280) -> Result<Option<(&'a Value, Arc<Uri<String>>)>, Error> {
1281    if pointer.is_empty() {
1282        return Ok(Some((document, Arc::clone(base))));
1283    }
1284    if !pointer.starts_with('/') {
1285        return Ok(None);
1286    }
1287
1288    let mut current = document;
1289    let mut current_base = Arc::clone(base);
1290
1291    for token in pointer.split('/').skip(1).map(unescape_segment) {
1292        // Check for $id in the current value before traversing deeper
1293        if let Some(id) = draft.id_of(current) {
1294            current_base = resolve_id(&current_base, id, resolution_cache)?;
1295        }
1296
1297        current = match current {
1298            Value::Object(map) => match map.get(&*token) {
1299                Some(v) => v,
1300                None => return Ok(None),
1301            },
1302            Value::Array(list) => match parse_index(&token).and_then(|x| list.get(x)) {
1303                Some(v) => v,
1304                None => return Ok(None),
1305            },
1306            _ => return Ok(None),
1307        };
1308    }
1309
1310    // Note: We don't check $id in the final value here because
1311    // `collect_external_resources_recursive` will handle it
1312    Ok(Some((current, current_base)))
1313}
1314
1315// Taken from `serde_json`.
1316#[must_use]
1317pub fn parse_index(s: &str) -> Option<usize> {
1318    if s.starts_with('+') || (s.starts_with('0') && s.len() != 1) {
1319        return None;
1320    }
1321    s.parse().ok()
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use std::error::Error as _;
1327
1328    use ahash::AHashMap;
1329    use fluent_uri::Uri;
1330    use serde_json::{json, Value};
1331    use test_case::test_case;
1332
1333    use crate::{uri::from_str, Draft, Registry, Resource, Retrieve};
1334
1335    use super::{pointer, RegistryOptions, SPECIFICATIONS};
1336
1337    #[test]
1338    fn test_empty_pointer() {
1339        let document = json!({});
1340        assert_eq!(pointer(&document, ""), Some(&document));
1341    }
1342
1343    #[test]
1344    fn test_invalid_uri_on_registry_creation() {
1345        let schema = Draft::Draft202012.create_resource(json!({}));
1346        let result = Registry::try_new(":/example.com", schema);
1347        let error = result.expect_err("Should fail");
1348
1349        assert_eq!(
1350            error.to_string(),
1351            "Invalid URI reference ':/example.com': unexpected character at index 0"
1352        );
1353        let source_error = error.source().expect("Should have a source");
1354        let inner_source = source_error.source().expect("Should have a source");
1355        assert_eq!(inner_source.to_string(), "unexpected character at index 0");
1356    }
1357
1358    #[test]
1359    fn test_lookup_unresolvable_url() {
1360        // Create a registry with a single resource
1361        let schema = Draft::Draft202012.create_resource(json!({
1362            "type": "object",
1363            "properties": {
1364                "foo": { "type": "string" }
1365            }
1366        }));
1367        let registry =
1368            Registry::try_new("http://example.com/schema1", schema).expect("Invalid resources");
1369
1370        // Attempt to create a resolver for a URL not in the registry
1371        let resolver = registry
1372            .try_resolver("http://example.com/non_existent_schema")
1373            .expect("Invalid base URI");
1374
1375        let result = resolver.lookup("");
1376
1377        assert_eq!(
1378            result.unwrap_err().to_string(),
1379            "Resource 'http://example.com/non_existent_schema' is not present in a registry and retrieving it failed: Retrieving external resources is not supported once the registry is populated"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_relative_uri_without_base() {
1385        let schema = Draft::Draft202012.create_resource(json!({"$ref": "./virtualNetwork.json"}));
1386        let error = Registry::try_new("json-schema:///", schema).expect_err("Should fail");
1387        assert_eq!(error.to_string(), "Resource './virtualNetwork.json' is not present in a registry and retrieving it failed: No base URI is available");
1388    }
1389
1390    #[test]
1391    fn test_try_with_resources_requires_registered_custom_meta_schema() {
1392        let base_registry = Registry::try_new(
1393            "http://example.com/root",
1394            Resource::from_contents(json!({"type": "object"})),
1395        )
1396        .expect("Base registry should be created");
1397
1398        let custom_schema = Resource::from_contents(json!({
1399            "$id": "http://example.com/custom",
1400            "$schema": "http://example.com/meta/custom",
1401            "type": "string"
1402        }));
1403
1404        let error = base_registry
1405            .try_with_resources(
1406                [("http://example.com/custom", custom_schema)],
1407                Draft::default(),
1408            )
1409            .expect_err("Extending registry must fail when the custom $schema is not registered");
1410
1411        let error_msg = error.to_string();
1412        assert_eq!(
1413            error_msg,
1414            "Unknown meta-schema: 'http://example.com/meta/custom'. Custom meta-schemas must be registered in the registry before use"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_try_with_resources_accepts_registered_custom_meta_schema_fragment() {
1420        let meta_schema = Resource::from_contents(json!({
1421            "$id": "http://example.com/meta/custom#",
1422            "$schema": "https://json-schema.org/draft/2020-12/schema",
1423            "type": "object"
1424        }));
1425
1426        let registry = Registry::try_new("http://example.com/meta/custom#", meta_schema)
1427            .expect("Meta-schema should be registered successfully");
1428
1429        let schema = Resource::from_contents(json!({
1430            "$id": "http://example.com/schemas/my-schema",
1431            "$schema": "http://example.com/meta/custom#",
1432            "type": "string"
1433        }));
1434
1435        registry
1436            .clone()
1437            .try_with_resources(
1438                [("http://example.com/schemas/my-schema", schema)],
1439                Draft::default(),
1440            )
1441            .expect("Schema should accept registered meta-schema URI with trailing '#'");
1442    }
1443
1444    #[test]
1445    fn test_chained_custom_meta_schemas() {
1446        // Meta-schema B (uses standard Draft 2020-12)
1447        let meta_schema_b = json!({
1448            "$id": "json-schema:///meta/level-b",
1449            "$schema": "https://json-schema.org/draft/2020-12/schema",
1450            "$vocabulary": {
1451                "https://json-schema.org/draft/2020-12/vocab/core": true,
1452                "https://json-schema.org/draft/2020-12/vocab/validation": true,
1453            },
1454            "type": "object",
1455            "properties": {
1456                "customProperty": {"type": "string"}
1457            }
1458        });
1459
1460        // Meta-schema A (uses Meta-schema B)
1461        let meta_schema_a = json!({
1462            "$id": "json-schema:///meta/level-a",
1463            "$schema": "json-schema:///meta/level-b",
1464            "customProperty": "level-a-meta",
1465            "type": "object"
1466        });
1467
1468        // Schema (uses Meta-schema A)
1469        let schema = json!({
1470            "$id": "json-schema:///schemas/my-schema",
1471            "$schema": "json-schema:///meta/level-a",
1472            "customProperty": "my-schema",
1473            "type": "string"
1474        });
1475
1476        // Register all meta-schemas and schema in a chained manner
1477        // All resources are provided upfront, so no external retrieval should occur
1478        Registry::try_from_resources([
1479            (
1480                "json-schema:///meta/level-b",
1481                Resource::from_contents(meta_schema_b),
1482            ),
1483            (
1484                "json-schema:///meta/level-a",
1485                Resource::from_contents(meta_schema_a),
1486            ),
1487            (
1488                "json-schema:///schemas/my-schema",
1489                Resource::from_contents(schema),
1490            ),
1491        ])
1492        .expect("Chained custom meta-schemas should be accepted when all are registered");
1493    }
1494
1495    struct TestRetriever {
1496        schemas: AHashMap<String, Value>,
1497    }
1498
1499    impl TestRetriever {
1500        fn new(schemas: AHashMap<String, Value>) -> Self {
1501            TestRetriever { schemas }
1502        }
1503    }
1504
1505    impl Retrieve for TestRetriever {
1506        fn retrieve(
1507            &self,
1508            uri: &Uri<String>,
1509        ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
1510            if let Some(value) = self.schemas.get(uri.as_str()) {
1511                Ok(value.clone())
1512            } else {
1513                Err(format!("Failed to find {uri}").into())
1514            }
1515        }
1516    }
1517
1518    fn create_test_retriever(schemas: &[(&str, Value)]) -> TestRetriever {
1519        TestRetriever::new(
1520            schemas
1521                .iter()
1522                .map(|&(k, ref v)| (k.to_string(), v.clone()))
1523                .collect(),
1524        )
1525    }
1526
1527    struct TestCase {
1528        input_resources: Vec<(&'static str, Value)>,
1529        remote_resources: Vec<(&'static str, Value)>,
1530        expected_resolved_uris: Vec<&'static str>,
1531    }
1532
1533    #[test_case(
1534        TestCase {
1535            input_resources: vec![
1536                ("http://example.com/schema1", json!({"$ref": "http://example.com/schema2"})),
1537            ],
1538            remote_resources: vec![
1539                ("http://example.com/schema2", json!({"type": "object"})),
1540            ],
1541            expected_resolved_uris: vec!["http://example.com/schema1", "http://example.com/schema2"],
1542        }
1543    ;"External ref at top")]
1544    #[test_case(
1545        TestCase {
1546            input_resources: vec![
1547                ("http://example.com/schema1", json!({
1548                    "$defs": {
1549                        "subschema": {"type": "string"}
1550                    },
1551                    "$ref": "#/$defs/subschema"
1552                })),
1553            ],
1554            remote_resources: vec![],
1555            expected_resolved_uris: vec!["http://example.com/schema1"],
1556        }
1557    ;"Internal ref at top")]
1558    #[test_case(
1559        TestCase {
1560            input_resources: vec![
1561                ("http://example.com/schema1", json!({"$ref": "http://example.com/schema2"})),
1562                ("http://example.com/schema2", json!({"type": "object"})),
1563            ],
1564            remote_resources: vec![],
1565            expected_resolved_uris: vec!["http://example.com/schema1", "http://example.com/schema2"],
1566        }
1567    ;"Ref to later resource")]
1568    #[test_case(
1569    TestCase {
1570            input_resources: vec![
1571                ("http://example.com/schema1", json!({
1572                    "type": "object",
1573                    "properties": {
1574                        "prop1": {"$ref": "http://example.com/schema2"}
1575                    }
1576                })),
1577            ],
1578            remote_resources: vec![
1579                ("http://example.com/schema2", json!({"type": "string"})),
1580            ],
1581            expected_resolved_uris: vec!["http://example.com/schema1", "http://example.com/schema2"],
1582        }
1583    ;"External ref in subresource")]
1584    #[test_case(
1585        TestCase {
1586            input_resources: vec![
1587                ("http://example.com/schema1", json!({
1588                    "type": "object",
1589                    "properties": {
1590                        "prop1": {"$ref": "#/$defs/subschema"}
1591                    },
1592                    "$defs": {
1593                        "subschema": {"type": "string"}
1594                    }
1595                })),
1596            ],
1597            remote_resources: vec![],
1598            expected_resolved_uris: vec!["http://example.com/schema1"],
1599        }
1600    ;"Internal ref in subresource")]
1601    #[test_case(
1602        TestCase {
1603            input_resources: vec![
1604                ("file:///schemas/main.json", json!({"$ref": "file:///schemas/external.json"})),
1605            ],
1606            remote_resources: vec![
1607                ("file:///schemas/external.json", json!({"type": "object"})),
1608            ],
1609            expected_resolved_uris: vec!["file:///schemas/main.json", "file:///schemas/external.json"],
1610        }
1611    ;"File scheme: external ref at top")]
1612    #[test_case(
1613        TestCase {
1614            input_resources: vec![
1615                ("file:///schemas/main.json", json!({"$ref": "subfolder/schema.json"})),
1616            ],
1617            remote_resources: vec![
1618                ("file:///schemas/subfolder/schema.json", json!({"type": "string"})),
1619            ],
1620            expected_resolved_uris: vec!["file:///schemas/main.json", "file:///schemas/subfolder/schema.json"],
1621        }
1622    ;"File scheme: relative path ref")]
1623    #[test_case(
1624        TestCase {
1625            input_resources: vec![
1626                ("file:///schemas/main.json", json!({
1627                    "type": "object",
1628                    "properties": {
1629                        "local": {"$ref": "local.json"},
1630                        "remote": {"$ref": "http://example.com/schema"}
1631                    }
1632                })),
1633            ],
1634            remote_resources: vec![
1635                ("file:///schemas/local.json", json!({"type": "string"})),
1636                ("http://example.com/schema", json!({"type": "number"})),
1637            ],
1638            expected_resolved_uris: vec![
1639                "file:///schemas/main.json",
1640                "file:///schemas/local.json",
1641                "http://example.com/schema"
1642            ],
1643        }
1644    ;"File scheme: mixing with http scheme")]
1645    #[test_case(
1646        TestCase {
1647            input_resources: vec![
1648                ("file:///C:/schemas/main.json", json!({"$ref": "/D:/other_schemas/schema.json"})),
1649            ],
1650            remote_resources: vec![
1651                ("file:///D:/other_schemas/schema.json", json!({"type": "boolean"})),
1652            ],
1653            expected_resolved_uris: vec![
1654                "file:///C:/schemas/main.json",
1655                "file:///D:/other_schemas/schema.json"
1656            ],
1657        }
1658    ;"File scheme: absolute path in Windows style")]
1659    #[test_case(
1660        TestCase {
1661            input_resources: vec![
1662                ("http://example.com/schema1", json!({"$ref": "http://example.com/schema2"})),
1663            ],
1664            remote_resources: vec![
1665                ("http://example.com/schema2", json!({"$ref": "http://example.com/schema3"})),
1666                ("http://example.com/schema3", json!({"$ref": "http://example.com/schema4"})),
1667                ("http://example.com/schema4", json!({"$ref": "http://example.com/schema5"})),
1668                ("http://example.com/schema5", json!({"type": "object"})),
1669            ],
1670            expected_resolved_uris: vec![
1671                "http://example.com/schema1",
1672                "http://example.com/schema2",
1673                "http://example.com/schema3",
1674                "http://example.com/schema4",
1675                "http://example.com/schema5",
1676            ],
1677        }
1678    ;"Four levels of external references")]
1679    #[test_case(
1680        TestCase {
1681            input_resources: vec![
1682                ("http://example.com/schema1", json!({"$ref": "http://example.com/schema2"})),
1683            ],
1684            remote_resources: vec![
1685                ("http://example.com/schema2", json!({"$ref": "http://example.com/schema3"})),
1686                ("http://example.com/schema3", json!({"$ref": "http://example.com/schema4"})),
1687                ("http://example.com/schema4", json!({"$ref": "http://example.com/schema5"})),
1688                ("http://example.com/schema5", json!({"$ref": "http://example.com/schema6"})),
1689                ("http://example.com/schema6", json!({"$ref": "http://example.com/schema1"})),
1690            ],
1691            expected_resolved_uris: vec![
1692                "http://example.com/schema1",
1693                "http://example.com/schema2",
1694                "http://example.com/schema3",
1695                "http://example.com/schema4",
1696                "http://example.com/schema5",
1697                "http://example.com/schema6",
1698            ],
1699        }
1700    ;"Five levels of external references with circular reference")]
1701    fn test_references_processing(test_case: TestCase) {
1702        let retriever = create_test_retriever(&test_case.remote_resources);
1703
1704        let input_pairs = test_case
1705            .input_resources
1706            .clone()
1707            .into_iter()
1708            .map(|(uri, value)| (uri, Resource::from_contents(value)));
1709
1710        let registry = Registry::options()
1711            .retriever(retriever)
1712            .build(input_pairs)
1713            .expect("Invalid resources");
1714        // Verify that all expected URIs are resolved and present in resources
1715        for uri in test_case.expected_resolved_uris {
1716            let resolver = registry.try_resolver("").expect("Invalid base URI");
1717            assert!(resolver.lookup(uri).is_ok());
1718        }
1719    }
1720
1721    #[test]
1722    fn test_default_retriever_with_remote_refs() {
1723        let result = Registry::try_from_resources([(
1724            "http://example.com/schema1",
1725            Resource::from_contents(json!({"$ref": "http://example.com/schema2"})),
1726        )]);
1727        let error = result.expect_err("Should fail");
1728        assert_eq!(error.to_string(), "Resource 'http://example.com/schema2' is not present in a registry and retrieving it failed: Default retriever does not fetch resources");
1729        assert!(error.source().is_some());
1730    }
1731
1732    #[test]
1733    fn test_options() {
1734        let _registry = RegistryOptions::default()
1735            .build([("", Resource::from_contents(json!({})))])
1736            .expect("Invalid resources");
1737    }
1738
1739    #[test]
1740    fn test_registry_with_duplicate_input_uris() {
1741        let input_resources = vec![
1742            (
1743                "http://example.com/schema",
1744                json!({
1745                    "type": "object",
1746                    "properties": {
1747                        "foo": { "type": "string" }
1748                    }
1749                }),
1750            ),
1751            (
1752                "http://example.com/schema",
1753                json!({
1754                    "type": "object",
1755                    "properties": {
1756                        "bar": { "type": "number" }
1757                    }
1758                }),
1759            ),
1760        ];
1761
1762        let result = Registry::try_from_resources(
1763            input_resources
1764                .into_iter()
1765                .map(|(uri, value)| (uri, Draft::Draft202012.create_resource(value))),
1766        );
1767
1768        assert!(
1769            result.is_ok(),
1770            "Failed to create registry with duplicate input URIs"
1771        );
1772        let registry = result.unwrap();
1773
1774        let resource = registry
1775            .resources
1776            .get(&from_str("http://example.com/schema").expect("Invalid URI"))
1777            .unwrap();
1778        let properties = resource
1779            .contents()
1780            .get("properties")
1781            .and_then(|v| v.as_object())
1782            .unwrap();
1783
1784        assert!(
1785            !properties.contains_key("bar"),
1786            "Registry should contain the earliest added schema"
1787        );
1788        assert!(
1789            properties.contains_key("foo"),
1790            "Registry should contain the overwritten schema"
1791        );
1792    }
1793
1794    #[test]
1795    fn test_resolver_debug() {
1796        let registry = SPECIFICATIONS
1797            .clone()
1798            .try_with_resource("http://example.com", Resource::from_contents(json!({})))
1799            .expect("Invalid resource");
1800        let resolver = registry
1801            .try_resolver("http://127.0.0.1/schema")
1802            .expect("Invalid base URI");
1803        assert_eq!(
1804            format!("{resolver:?}"),
1805            "Resolver { base_uri: \"http://127.0.0.1/schema\", scopes: \"[]\" }"
1806        );
1807    }
1808
1809    #[test]
1810    fn test_try_with_resource() {
1811        let registry = SPECIFICATIONS
1812            .clone()
1813            .try_with_resource("http://example.com", Resource::from_contents(json!({})))
1814            .expect("Invalid resource");
1815        let resolver = registry.try_resolver("").expect("Invalid base URI");
1816        let resolved = resolver
1817            .lookup("http://json-schema.org/draft-06/schema#/definitions/schemaArray")
1818            .expect("Lookup failed");
1819        assert_eq!(
1820            resolved.contents(),
1821            &json!({
1822                "type": "array",
1823                "minItems": 1,
1824                "items": { "$ref": "#" }
1825            })
1826        );
1827    }
1828
1829    #[test]
1830    fn test_invalid_reference() {
1831        let resource = Draft::Draft202012.create_resource(json!({"$schema": "$##"}));
1832        let _ = Registry::try_new("http://#/", resource);
1833    }
1834}
1835
1836#[cfg(all(test, feature = "retrieve-async"))]
1837mod async_tests {
1838    use crate::{uri, DefaultRetriever, Draft, Registry, Resource, Uri};
1839    use ahash::AHashMap;
1840    use serde_json::{json, Value};
1841    use std::{
1842        error::Error,
1843        sync::atomic::{AtomicUsize, Ordering},
1844    };
1845
1846    struct TestAsyncRetriever {
1847        schemas: AHashMap<String, Value>,
1848    }
1849
1850    impl TestAsyncRetriever {
1851        fn with_schema(uri: impl Into<String>, schema: Value) -> Self {
1852            TestAsyncRetriever {
1853                schemas: { AHashMap::from_iter([(uri.into(), schema)]) },
1854            }
1855        }
1856    }
1857
1858    #[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
1859    #[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
1860    impl crate::AsyncRetrieve for TestAsyncRetriever {
1861        async fn retrieve(
1862            &self,
1863            uri: &Uri<String>,
1864        ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
1865            self.schemas
1866                .get(uri.as_str())
1867                .cloned()
1868                .ok_or_else(|| "Schema not found".into())
1869        }
1870    }
1871
1872    #[tokio::test]
1873    async fn test_default_async_retriever_with_remote_refs() {
1874        let result = Registry::options()
1875            .async_retriever(DefaultRetriever)
1876            .build([(
1877                "http://example.com/schema1",
1878                Resource::from_contents(json!({"$ref": "http://example.com/schema2"})),
1879            )])
1880            .await;
1881
1882        let error = result.expect_err("Should fail");
1883        assert_eq!(error.to_string(), "Resource 'http://example.com/schema2' is not present in a registry and retrieving it failed: Default retriever does not fetch resources");
1884        assert!(error.source().is_some());
1885    }
1886
1887    #[tokio::test]
1888    async fn test_async_options() {
1889        let _registry = Registry::options()
1890            .async_retriever(DefaultRetriever)
1891            .build([("", Draft::default().create_resource(json!({})))])
1892            .await
1893            .expect("Invalid resources");
1894    }
1895
1896    #[tokio::test]
1897    async fn test_async_registry_with_duplicate_input_uris() {
1898        let input_resources = vec![
1899            (
1900                "http://example.com/schema",
1901                json!({
1902                    "type": "object",
1903                    "properties": {
1904                        "foo": { "type": "string" }
1905                    }
1906                }),
1907            ),
1908            (
1909                "http://example.com/schema",
1910                json!({
1911                    "type": "object",
1912                    "properties": {
1913                        "bar": { "type": "number" }
1914                    }
1915                }),
1916            ),
1917        ];
1918
1919        let result = Registry::options()
1920            .async_retriever(DefaultRetriever)
1921            .build(
1922                input_resources
1923                    .into_iter()
1924                    .map(|(uri, value)| (uri, Draft::Draft202012.create_resource(value))),
1925            )
1926            .await;
1927
1928        assert!(
1929            result.is_ok(),
1930            "Failed to create registry with duplicate input URIs"
1931        );
1932        let registry = result.unwrap();
1933
1934        let resource = registry
1935            .resources
1936            .get(&uri::from_str("http://example.com/schema").expect("Invalid URI"))
1937            .unwrap();
1938        let properties = resource
1939            .contents()
1940            .get("properties")
1941            .and_then(|v| v.as_object())
1942            .unwrap();
1943
1944        assert!(
1945            !properties.contains_key("bar"),
1946            "Registry should contain the earliest added schema"
1947        );
1948        assert!(
1949            properties.contains_key("foo"),
1950            "Registry should contain the overwritten schema"
1951        );
1952    }
1953
1954    #[tokio::test]
1955    async fn test_async_try_with_resource() {
1956        let retriever = TestAsyncRetriever::with_schema(
1957            "http://example.com/schema2",
1958            json!({"type": "object"}),
1959        );
1960
1961        let registry = Registry::options()
1962            .async_retriever(retriever)
1963            .build([(
1964                "http://example.com",
1965                Resource::from_contents(json!({"$ref": "http://example.com/schema2"})),
1966            )])
1967            .await
1968            .expect("Invalid resource");
1969
1970        let resolver = registry.try_resolver("").expect("Invalid base URI");
1971        let resolved = resolver
1972            .lookup("http://example.com/schema2")
1973            .expect("Lookup failed");
1974        assert_eq!(resolved.contents(), &json!({"type": "object"}));
1975    }
1976
1977    #[tokio::test]
1978    async fn test_async_registry_with_multiple_refs() {
1979        let retriever = TestAsyncRetriever {
1980            schemas: AHashMap::from_iter([
1981                (
1982                    "http://example.com/schema2".to_string(),
1983                    json!({"type": "object"}),
1984                ),
1985                (
1986                    "http://example.com/schema3".to_string(),
1987                    json!({"type": "string"}),
1988                ),
1989            ]),
1990        };
1991
1992        let registry = Registry::options()
1993            .async_retriever(retriever)
1994            .build([(
1995                "http://example.com/schema1",
1996                Resource::from_contents(json!({
1997                    "type": "object",
1998                    "properties": {
1999                        "obj": {"$ref": "http://example.com/schema2"},
2000                        "str": {"$ref": "http://example.com/schema3"}
2001                    }
2002                })),
2003            )])
2004            .await
2005            .expect("Invalid resource");
2006
2007        let resolver = registry.try_resolver("").expect("Invalid base URI");
2008
2009        // Check both references are resolved correctly
2010        let resolved2 = resolver
2011            .lookup("http://example.com/schema2")
2012            .expect("Lookup failed");
2013        assert_eq!(resolved2.contents(), &json!({"type": "object"}));
2014
2015        let resolved3 = resolver
2016            .lookup("http://example.com/schema3")
2017            .expect("Lookup failed");
2018        assert_eq!(resolved3.contents(), &json!({"type": "string"}));
2019    }
2020
2021    #[tokio::test]
2022    async fn test_async_registry_with_nested_refs() {
2023        let retriever = TestAsyncRetriever {
2024            schemas: AHashMap::from_iter([
2025                (
2026                    "http://example.com/address".to_string(),
2027                    json!({
2028                        "type": "object",
2029                        "properties": {
2030                            "street": {"type": "string"},
2031                            "city": {"$ref": "http://example.com/city"}
2032                        }
2033                    }),
2034                ),
2035                (
2036                    "http://example.com/city".to_string(),
2037                    json!({
2038                        "type": "string",
2039                        "minLength": 1
2040                    }),
2041                ),
2042            ]),
2043        };
2044
2045        let registry = Registry::options()
2046            .async_retriever(retriever)
2047            .build([(
2048                "http://example.com/person",
2049                Resource::from_contents(json!({
2050                    "type": "object",
2051                    "properties": {
2052                        "name": {"type": "string"},
2053                        "address": {"$ref": "http://example.com/address"}
2054                    }
2055                })),
2056            )])
2057            .await
2058            .expect("Invalid resource");
2059
2060        let resolver = registry.try_resolver("").expect("Invalid base URI");
2061
2062        // Verify nested reference resolution
2063        let resolved = resolver
2064            .lookup("http://example.com/city")
2065            .expect("Lookup failed");
2066        assert_eq!(
2067            resolved.contents(),
2068            &json!({"type": "string", "minLength": 1})
2069        );
2070    }
2071
2072    // Multiple refs to the same external schema with different fragments were fetched multiple times in async mode.
2073    #[tokio::test]
2074    async fn test_async_registry_with_duplicate_fragment_refs() {
2075        static FETCH_COUNT: AtomicUsize = AtomicUsize::new(0);
2076
2077        struct CountingRetriever {
2078            inner: TestAsyncRetriever,
2079        }
2080
2081        #[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
2082        #[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
2083        impl crate::AsyncRetrieve for CountingRetriever {
2084            async fn retrieve(
2085                &self,
2086                uri: &Uri<String>,
2087            ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2088                FETCH_COUNT.fetch_add(1, Ordering::SeqCst);
2089                self.inner.retrieve(uri).await
2090            }
2091        }
2092
2093        FETCH_COUNT.store(0, Ordering::SeqCst);
2094
2095        let retriever = CountingRetriever {
2096            inner: TestAsyncRetriever::with_schema(
2097                "http://example.com/external",
2098                json!({
2099                    "$defs": {
2100                        "foo": {
2101                            "type": "object",
2102                            "properties": {
2103                                "nested": { "type": "string" }
2104                            }
2105                        },
2106                        "bar": {
2107                            "type": "object",
2108                            "properties": {
2109                                "value": { "type": "integer" }
2110                            }
2111                        }
2112                    }
2113                }),
2114            ),
2115        };
2116
2117        // Schema references the same external URL with different fragments
2118        let registry = Registry::options()
2119            .async_retriever(retriever)
2120            .build([(
2121                "http://example.com/main",
2122                Resource::from_contents(json!({
2123                    "type": "object",
2124                    "properties": {
2125                        "name": { "$ref": "http://example.com/external#/$defs/foo" },
2126                        "age": { "$ref": "http://example.com/external#/$defs/bar" }
2127                    }
2128                })),
2129            )])
2130            .await
2131            .expect("Invalid resource");
2132
2133        // Should only fetch the external schema once
2134        let fetches = FETCH_COUNT.load(Ordering::SeqCst);
2135        assert_eq!(
2136            fetches, 1,
2137            "External schema should be fetched only once, but was fetched {fetches} times"
2138        );
2139
2140        let resolver = registry
2141            .try_resolver("http://example.com/main")
2142            .expect("Invalid base URI");
2143
2144        // Verify both fragment references resolve correctly
2145        let foo = resolver
2146            .lookup("http://example.com/external#/$defs/foo")
2147            .expect("Lookup failed");
2148        assert_eq!(
2149            foo.contents(),
2150            &json!({
2151                "type": "object",
2152                "properties": {
2153                    "nested": { "type": "string" }
2154                }
2155            })
2156        );
2157
2158        let bar = resolver
2159            .lookup("http://example.com/external#/$defs/bar")
2160            .expect("Lookup failed");
2161        assert_eq!(
2162            bar.contents(),
2163            &json!({
2164                "type": "object",
2165                "properties": {
2166                    "value": { "type": "integer" }
2167                }
2168            })
2169        );
2170    }
2171}