use alloc::boxed::Box;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde_json::Value;
use crate::draft::Draft;
use crate::resolver_trait::{JsonResolver, NoopResolver};
use super::anchor::Anchor;
use super::resolver::Resolver;
use super::resource::{Resource, ResourceRef};
use super::spec;
use super::uri;
fn normalize_uri(raw: &str) -> String {
uri::normalize(raw)
}
pub struct Registry {
resources: BTreeMap<String, Resource>,
anchors: BTreeMap<(String, String), AnchorEntry>,
}
#[derive(Debug, Clone)]
pub(crate) struct AnchorEntry {
pub(crate) is_dynamic: bool,
}
pub struct RegistryBuilder {
pending: Vec<(String, Value)>,
resolver: Box<dyn JsonResolver>,
default_draft: Draft,
}
impl RegistryBuilder {
#[must_use]
pub fn new() -> Self {
Self {
pending: Vec::new(),
resolver: Box::new(NoopResolver),
default_draft: Draft::DEFAULT,
}
}
#[must_use]
pub fn with_resolver(mut self, resolver: impl JsonResolver + 'static) -> Self {
self.resolver = Box::new(resolver);
self
}
#[must_use]
pub fn with_draft(mut self, draft: Draft) -> Self {
self.default_draft = draft;
self
}
#[must_use]
pub fn add_resource(mut self, uri: impl Into<String>, schema: Value) -> Self {
let mut u: String = uri.into();
if u.ends_with('#') {
u.pop();
}
self.pending.push((u, schema));
self
}
pub fn build(self) -> Result<Registry, crate::error::ValidationError> {
let mut resources: BTreeMap<String, Resource> = BTreeMap::new();
let mut anchors: BTreeMap<(String, String), AnchorEntry> = BTreeMap::new();
let mut external_refs: Vec<String> = Vec::new();
let mut known_uris: alloc::collections::BTreeSet<String> =
alloc::collections::BTreeSet::new();
for (uri, schema) in &self.pending {
let norm = normalize_uri(uri);
let draft = Draft::detect(schema).unwrap_or(self.default_draft);
known_uris.insert(norm.clone());
resources.insert(norm, Resource::with_draft(schema.clone(), draft));
}
let mut queue: alloc::collections::VecDeque<(String, String)> =
alloc::collections::VecDeque::new();
for (uri, _schema) in &self.pending {
let norm = normalize_uri(uri);
queue.push_back((norm.clone(), norm));
}
while let Some((base_uri, resource_uri)) = queue.pop_front() {
let Some(resource) = resources.get(&resource_uri) else {
continue;
};
let draft = resource.draft();
let contents = resource.contents().clone();
crawl_schema(
&base_uri,
&contents,
draft,
&mut resources,
&mut anchors,
&mut external_refs,
&mut known_uris,
);
}
let mut iteration = 0;
while !external_refs.is_empty() && iteration < 100 {
iteration += 1;
let refs: Vec<String> = std::mem::take(&mut external_refs);
for ext_uri in refs {
let fragmentless = ext_uri.split('#').next().unwrap_or(&ext_uri).to_string();
let norm = normalize_uri(&fragmentless);
if known_uris.contains(&norm) {
continue;
}
let resolved_value = self.resolver.resolve(&fragmentless).map_err(|e| {
use foundation_errstacks::IntoErrorTrace;
crate::error::ValidationErrorKind::Schema {
reason: alloc::format!(
"failed to resolve external reference: {fragmentless}"
),
}
.into_error_trace()
})?;
let draft = Draft::detect(&resolved_value).unwrap_or(self.default_draft);
known_uris.insert(norm.clone());
resources.insert(
norm.clone(),
Resource::with_draft(resolved_value.clone(), draft),
);
let mut sub_queue = alloc::collections::VecDeque::new();
sub_queue.push_back((norm.clone(), norm.clone()));
while let Some((base, res_uri)) = sub_queue.pop_front() {
let Some(resource) = resources.get(&res_uri) else {
continue;
};
let d = resource.draft();
let c = resource.contents().clone();
crawl_schema(
&base,
&c,
d,
&mut resources,
&mut anchors,
&mut external_refs,
&mut known_uris,
);
}
}
}
Ok(Registry { resources, anchors })
}
}
impl Default for RegistryBuilder {
fn default() -> Self {
Self::new()
}
}
#[allow(clippy::too_many_arguments)]
fn crawl_schema(
base_uri: &str,
contents: &Value,
draft: Draft,
resources: &mut BTreeMap<String, Resource>,
anchors: &mut BTreeMap<(String, String), AnchorEntry>,
external_refs: &mut Vec<String>,
known_uris: &mut alloc::collections::BTreeSet<String>,
) {
let Some(object) = contents.as_object() else {
return;
};
let analysis = spec::analyze_object(draft, object);
let mut effective_base = base_uri.to_string();
if let Some(id) = analysis.id {
let resolved_id = normalize_uri(&resolve_id(base_uri, id));
if !known_uris.contains(&resolved_id) {
known_uris.insert(resolved_id.clone());
resources.insert(
resolved_id.clone(),
Resource::with_draft(contents.clone(), draft),
);
}
effective_base = resolved_id;
}
for anchor in spec::anchors_of(draft, contents) {
let entry = AnchorEntry {
is_dynamic: matches!(anchor, Anchor::Dynamic { .. }),
};
anchors.insert(
(normalize_uri(&effective_base), anchor.name().to_string()),
entry,
);
}
if let Some(dollar_ref) = analysis.dollar_ref {
if !dollar_ref.starts_with('#') {
let resolved = resolve_id(&effective_base, dollar_ref);
let fragmentless = resolved.split('#').next().unwrap_or(&resolved).to_string();
if !known_uris.contains(&fragmentless) {
external_refs.push(resolved);
}
}
}
for child in spec::subresources_of(draft, contents) {
let child_draft = Draft::detect(child).unwrap_or(draft);
crawl_schema(
&effective_base,
child,
child_draft,
resources,
anchors,
external_refs,
known_uris,
);
}
}
fn resolve_id(base: &str, id: &str) -> String {
if id.starts_with('#') {
return base.to_string();
}
let normalized = id.strip_suffix('#').unwrap_or(id);
match uri::resolve_against(base, normalized) {
Ok(resolved) => {
let s = resolved.as_str().to_string();
s.split('#').next().unwrap_or(&s).to_string()
}
Err(_) => normalized.to_string(),
}
}
impl Registry {
#[must_use]
pub fn builder() -> RegistryBuilder {
RegistryBuilder::new()
}
#[must_use]
pub fn resolver(&self, base_uri: &str) -> Resolver<'_> {
Resolver::new(self, base_uri.to_string())
}
#[must_use]
pub fn get_resource(&self, uri: &str) -> Option<ResourceRef<'_>> {
let norm = normalize_uri(uri);
self.resources
.get(&norm)
.map(|r| ResourceRef::new(r.contents(), r.draft()))
}
#[must_use]
pub fn get_anchor<'a>(&'a self, base_uri: &str, name: &str) -> Option<Anchor<'a>> {
let norm = normalize_uri(base_uri);
let entry = self.anchors.get(&(norm.clone(), name.to_string()))?;
let resource = self.get_resource(&norm)?;
let contents = self.find_anchor_contents(&norm, name)?;
let resource_ref = ResourceRef::new(contents, resource.draft());
if entry.is_dynamic {
Some(Anchor::Dynamic {
name: self.get_anchor_name(base_uri, name)?,
resource: resource_ref,
})
} else {
Some(Anchor::Default {
name: self.get_anchor_name(base_uri, name)?,
resource: resource_ref,
})
}
}
fn find_anchor_contents(&self, base_uri: &str, anchor_name: &str) -> Option<&Value> {
let resource = self.resources.get(base_uri)?;
find_anchor_in_value(resource.contents(), resource.draft(), anchor_name)
}
fn get_anchor_name(&self, base_uri: &str, name: &str) -> Option<&str> {
let key = (base_uri.to_string(), name.to_string());
self.anchors
.get_key_value(&key)
.map(|((_, n), _)| n.as_str())
}
#[must_use]
pub fn contains_resource(&self, uri: &str) -> bool {
self.resources.contains_key(&normalize_uri(uri))
}
#[allow(clippy::unused_self)]
pub(crate) fn resolve_uri(
&self,
base: &str,
reference: &str,
) -> Result<String, super::uri::UriError> {
let resolved = uri::resolve_against(base, reference)?;
Ok(normalize_uri(resolved.without_fragment()))
}
}
fn find_anchor_in_value<'a>(
value: &'a Value,
draft: Draft,
anchor_name: &str,
) -> Option<&'a Value> {
for anchor in spec::anchors_of(draft, value) {
if anchor.name() == anchor_name {
return Some(value);
}
}
for child in spec::subresources_of(draft, value) {
let child_draft = Draft::detect(child).unwrap_or(draft);
if let Some(found) = find_anchor_in_value(child, child_draft, anchor_name) {
return Some(found);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolver_trait::MapResolver;
use serde_json::json;
#[test]
fn build_empty_registry() {
let registry = Registry::builder().build().unwrap();
assert!(!registry.contains_resource("anything"));
}
#[test]
fn build_single_resource() {
let registry = Registry::builder()
.add_resource("http://example.com", json!({"type": "string"}))
.build()
.unwrap();
assert!(registry.contains_resource("http://example.com"));
let r = registry.get_resource("http://example.com").unwrap();
assert_eq!(r.contents(), &json!({"type": "string"}));
}
#[test]
fn build_discovers_subresource() {
let registry = Registry::builder()
.add_resource(
"http://example.com/root",
json!({
"$defs": {
"inner": {
"$id": "http://example.com/inner",
"type": "integer"
}
}
}),
)
.build()
.unwrap();
assert!(registry.contains_resource("http://example.com/root"));
assert!(registry.contains_resource("http://example.com/inner"));
}
#[test]
fn build_discovers_anchors() {
let registry = Registry::builder()
.add_resource(
"http://example.com",
json!({
"$defs": {
"foo": {
"$anchor": "myAnchor",
"type": "string"
}
}
}),
)
.build()
.unwrap();
assert!(registry
.anchors
.contains_key(&("http://example.com".into(), "myAnchor".into())));
}
#[test]
fn build_resolves_external_refs() {
let mut resolver = MapResolver::new();
resolver.insert("http://example.com/external", json!({"type": "number"}));
let registry = Registry::builder()
.with_resolver(resolver)
.add_resource(
"http://example.com/root",
json!({"$ref": "http://example.com/external"}),
)
.build()
.unwrap();
assert!(registry.contains_resource("http://example.com/external"));
}
#[test]
fn build_with_explicit_draft() {
let registry = Registry::builder()
.with_draft(Draft::Draft4)
.add_resource("urn:test", json!({}))
.build()
.unwrap();
let r = registry.get_resource("urn:test").unwrap();
assert_eq!(r.draft(), Draft::Draft4);
}
#[test]
fn build_strips_trailing_hash() {
let registry = Registry::builder()
.add_resource("http://example.com/schema#", json!({"type": "string"}))
.build()
.unwrap();
assert!(registry.contains_resource("http://example.com/schema"));
}
#[test]
fn resolve_id_absolute() {
assert_eq!(
resolve_id("http://example.com/base", "http://other.com/schema"),
"http://other.com/schema"
);
}
#[test]
fn resolve_id_relative() {
assert_eq!(
resolve_id("http://example.com/schemas/base.json", "inner"),
"http://example.com/schemas/inner"
);
}
#[test]
fn resolve_id_fragment_returns_base() {
assert_eq!(
resolve_id("http://example.com/base", "#anchor"),
"http://example.com/base"
);
}
}