Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Breaking Changes

- Registry construction now uses an explicit prepare step, and `with_registry` now borrows the prepared registry. `ValidationOptions::with_resource` and `ValidationOptions::with_resources` were removed in favor of building a `Registry` first. See the [Migration Guide](MIGRATION.md) for the details.

### Performance

- Avoid registry clones and document clones during validator construction. This improves real-world schema compilation by roughly 10-20% in internal benchmarks.

## [0.45.1] - 2026-04-06

### Fixed
Expand Down
69 changes: 69 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,74 @@
# Migration Guide

## Upgrading from 0.45.x to 0.46.0

Registry construction is now explicit: add shared schemas first, then call
`prepare()` to build a reusable registry. Validators no longer take ownership of
that registry; pass it by reference with `with_registry(&registry)`.
`ValidationOptions::with_resource` and `ValidationOptions::with_resources` were
removed in favor of building a `Registry` first. For cases with multiple shared
schemas, `extend([...])` is the batch form of `add(...)`.

```rust
// Old (0.45.x)
use jsonschema::{Registry, Resource};

// Inline shared schema
let validator = jsonschema::options()
.with_resource(
"https://example.com/schema",
Resource::from_contents(shared_schema),
)
.build(&schema)?;

// Multiple shared schemas
let validator = jsonschema::options()
.with_resources([
(
"https://example.com/schema-1",
Resource::from_contents(schema_1),
),
(
"https://example.com/schema-2",
Resource::from_contents(schema_2),
),
].into_iter())
.build(&schema)?;

// Prebuilt registry
let registry = Registry::try_from_resources([
(
"https://example.com/schema",
Resource::from_contents(shared_schema),
),
])?;
let validator = jsonschema::options()
.with_registry(registry)
.build(&schema)?;

// New (0.46.0)
use jsonschema::Registry;

// Shared registry + borrowed validator build
let registry = Registry::new()
.add("https://example.com/schema", shared_schema)?
.prepare()?;
let validator = jsonschema::options()
.with_registry(&registry)
.build(&schema)?;

// Multiple shared schemas
let registry = Registry::new()
.extend([
("https://example.com/schema-1", schema_1),
("https://example.com/schema-2", schema_2),
])?
.prepare()?;
let validator = jsonschema::options()
.with_registry(&registry)
.build(&schema)?;
```

## Upgrading from 0.38.x to 0.39.0

### Custom keyword API simplified
Expand Down
26 changes: 20 additions & 6 deletions crates/jsonschema-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,10 @@ fn path_to_uri(path: &std::path::Path) -> String {
result
}

fn options_for_schema(
fn options_for_schema<'a>(
schema_path: &Path,
http_options: Option<&jsonschema::HttpOptions>,
) -> Result<jsonschema::ValidationOptions, Box<dyn std::error::Error>> {
) -> Result<jsonschema::ValidationOptions<'a>, Box<dyn std::error::Error>> {
let base_uri = path_to_uri(schema_path);
let base_uri = referencing::uri::from_str(&base_uri)?;
let mut options = jsonschema::options().with_base_uri(base_uri);
Expand Down Expand Up @@ -695,16 +695,30 @@ fn run_bundle(args: BundleArgs) -> ExitCode {
Err(error) => return fail_with_error(error),
};

let mut registry = if let Some(http_opts) = http_options.as_ref() {
let retriever = match jsonschema::HttpRetriever::new(http_opts) {
Ok(retriever) => retriever,
Err(error) => return fail_with_error(error),
};
jsonschema::Registry::new().retriever(retriever)
} else {
jsonschema::Registry::new()
};
for (uri, path) in &resources {
let resource_json = match read_json(path) {
Ok(value) => value,
Err(error) => return fail_with_error(error),
};
opts = opts.with_resource(
uri.as_str(),
referencing::Resource::from_contents(resource_json),
);
registry = match registry.add(uri, resource_json) {
Ok(registry) => registry,
Err(error) => return fail_with_error(error),
};
}
let registry = match registry.prepare() {
Ok(registry) => registry,
Err(error) => return fail_with_error(error),
};
opts = opts.with_registry(&registry);

match opts.bundle(&schema_json) {
Ok(bundled) => {
Expand Down
24 changes: 12 additions & 12 deletions crates/jsonschema-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -832,19 +832,19 @@ impl jsonschema::Keyword for CustomKeyword {
}
}

fn make_options(
fn make_options<'a>(
draft: Option<u8>,
formats: Option<&Bound<'_, PyDict>>,
formats: Option<&Bound<'a, PyDict>>,
validate_formats: Option<bool>,
ignore_unknown_formats: Option<bool>,
retriever: Option<&Bound<'_, PyAny>>,
registry: Option<&registry::Registry>,
retriever: Option<&Bound<'a, PyAny>>,
registry: Option<&'a registry::Registry>,
base_uri: Option<String>,
pattern_options: Option<&Bound<'_, PyAny>>,
email_options: Option<&Bound<'_, PyAny>>,
http_options: Option<&Bound<'_, PyAny>>,
keywords: Option<&Bound<'_, PyDict>>,
) -> PyResult<jsonschema::ValidationOptions> {
pattern_options: Option<&Bound<'a, PyAny>>,
email_options: Option<&Bound<'a, PyAny>>,
http_options: Option<&Bound<'a, PyAny>>,
keywords: Option<&Bound<'a, PyDict>>,
) -> PyResult<jsonschema::ValidationOptions<'a>> {
let mut options = jsonschema::options();
if let Some(raw_draft_version) = draft {
options = options.with_draft(get_draft(raw_draft_version)?);
Expand Down Expand Up @@ -890,7 +890,7 @@ fn make_options(
options = options.with_retriever(Retriever { func });
}
if let Some(registry) = registry {
options = options.with_registry(registry.inner.clone());
options = options.with_registry(&registry.inner);
}
if let Some(base_uri) = base_uri {
options = options.with_base_uri(base_uri);
Expand Down Expand Up @@ -2021,7 +2021,7 @@ mod meta {
let schema = crate::ser::to_value(schema)?;
let result = if let Some(registry) = registry {
jsonschema::meta::options()
.with_registry(registry.inner.clone())
.with_registry(&registry.inner)
.validate(&schema)
} else {
jsonschema::meta::validate(&schema)
Expand Down Expand Up @@ -2070,7 +2070,7 @@ mod meta {
let schema = crate::ser::to_value(schema)?;
let result = if let Some(registry) = registry {
jsonschema::meta::options()
.with_registry(registry.inner.clone())
.with_registry(&registry.inner)
.validate(&schema)
} else {
jsonschema::meta::validate(&schema)
Expand Down
24 changes: 11 additions & 13 deletions crates/jsonschema-py/src/registry.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
use jsonschema::Resource;
use pyo3::{exceptions::PyValueError, prelude::*};

use crate::{get_draft, retriever::into_retriever, to_value, Retriever};

/// A registry of JSON Schema resources, each identified by their canonical URIs.
#[pyclass]
pub(crate) struct Registry {
pub(crate) inner: jsonschema::Registry,
pub(crate) inner: jsonschema::Registry<'static>,
}

#[pymethods]
Expand All @@ -19,30 +18,29 @@ impl Registry {
draft: Option<u8>,
retriever: Option<&Bound<'_, PyAny>>,
) -> PyResult<Self> {
let mut options = jsonschema::Registry::options();
let mut builder = jsonschema::Registry::new();

if let Some(draft) = draft {
options = options.draft(get_draft(draft)?);
builder = builder.draft(get_draft(draft)?);
}

if let Some(retriever) = retriever {
let func = into_retriever(retriever)?;
options = options.retriever(Retriever { func });
builder = builder.retriever(Retriever { func });
}

let pairs = resources.try_iter()?.map(|item| {
for item in resources.try_iter()? {
let pair = item?.unbind();
let (key, value) = pair.extract::<(Bound<PyAny>, Bound<PyAny>)>(py)?;
let uri = key.extract::<String>()?;
let schema = to_value(&value)?;
let resource = Resource::from_contents(schema);
Ok((uri, resource))
});

let pairs: Result<Vec<_>, PyErr> = pairs.collect();
builder = builder
.add(uri, schema)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
}

let registry = options
.build(pairs?)
let registry = builder
.prepare()
.map_err(|e| PyValueError::new_err(e.to_string()))?;

Ok(Registry { inner: registry })
Expand Down
47 changes: 47 additions & 0 deletions crates/jsonschema-py/tests-py/test_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,53 @@ def test_bundle_validates_identically():
assert not validator.is_valid({"age": 30})


def test_bundle_with_registry_and_explicit_draft4_legacy_id_root():
root = {
"id": "urn:root",
"type": "object",
"properties": {"value": {"$ref": "urn:string"}},
"required": ["value"],
}
registry = jsonschema_rs.Registry(
resources=[("urn:string", {"type": "string"})],
draft=jsonschema_rs.Draft4,
)

bundled = jsonschema_rs.bundle(root, registry=registry, draft=jsonschema_rs.Draft4)

assert bundled["properties"]["value"]["$ref"] == "urn:string"
assert "urn:string" in bundled["definitions"]


def test_bundle_uses_call_retriever_when_inline_root_adds_external_ref():
def retrieve(uri: str):
if uri == "urn:external":
return {"type": "string"}
raise KeyError(f"Schema not found: {uri}")

root = {
"type": "object",
"properties": {"value": {"$ref": "urn:external"}},
"required": ["value"],
}
registry = jsonschema_rs.Registry(resources=[("urn:seed", {"type": "integer"})])

bundled = jsonschema_rs.bundle(root, registry=registry, retriever=retrieve)

assert bundled["properties"]["value"]["$ref"] == "urn:external"
assert "urn:external" in bundled["$defs"]


def test_bundle_with_registry_accepts_equivalent_base_uri_with_empty_fragment():
root = {"$id": "urn:root", "$ref": "urn:shared"}
registry = jsonschema_rs.Registry(resources=[("urn:shared", {"type": "integer"})])

bundled = jsonschema_rs.bundle(root, registry=registry, base_uri="urn:root#")

assert bundled["$ref"] == "urn:shared"
assert bundled["$defs"]["urn:shared"]["type"] == "integer"


def test_bundle_unresolvable_raises():
with pytest.raises(jsonschema_rs.ReferencingError):
jsonschema_rs.bundle({"$ref": "https://example.com/missing.json"})
Expand Down
44 changes: 43 additions & 1 deletion crates/jsonschema-py/tests-py/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ def test_top_level_functions_with_registry(function):
assert list(function(schema, VALID_PERSON, registry=registry)) == []
assert list(function(schema, INVALID_PERSON, registry=registry)) != []


def test_validator_for_with_registry():
registry = Registry(NESTED_RESOURCES)
schema = {"$ref": "https://example.com/person.json"}
Expand All @@ -96,6 +95,21 @@ def test_validator_for_with_registry():
assert not validator.is_valid(INVALID_PERSON)


def test_validator_for_with_registry_and_explicit_draft4_legacy_id_root():
registry = Registry([("urn:string", {"type": "string"})], draft=Draft4)
schema = {
"id": "urn:root",
"type": "object",
"properties": {"value": {"$ref": "urn:string"}},
"required": ["value"],
}

validator = Draft4Validator(schema, registry=registry)

assert validator.is_valid({"value": "ok"})
assert not validator.is_valid({"value": 42})


def test_registry_with_retriever_and_validation():
def retrieve(uri: str):
if uri == "https://example.com/dynamic.json":
Expand All @@ -118,6 +132,34 @@ def retrieve(uri: str):
assert not dynamic_validator.is_valid("test")


def test_validator_for_uses_call_retriever_when_inline_root_adds_external_ref():
def retrieve(uri: str):
if uri == "urn:external":
return {"type": "string"}
raise KeyError(f"Schema not found: {uri}")

registry = Registry([("urn:seed", {"type": "integer"})])
schema = {
"type": "object",
"properties": {"value": {"$ref": "urn:external"}},
"required": ["value"],
}

validator = validator_for(schema, registry=registry, retriever=retrieve)
assert validator.is_valid({"value": "ok"})
assert not validator.is_valid({"value": 42})


def test_validator_for_with_registry_accepts_equivalent_base_uri_with_empty_fragment():
registry = Registry([("urn:shared", {"type": "integer"})])
schema = {"$id": "urn:root", "$ref": "urn:shared"}

validator = validator_for(schema, registry=registry, base_uri="urn:root#")

assert validator.is_valid(1)
assert not validator.is_valid("x")


def test_registry_error_propagation():
registry = Registry(NESTED_RESOURCES)

Expand Down
17 changes: 17 additions & 0 deletions crates/jsonschema-rb/spec/bundle_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@
expect(validator.valid?({ "age" => 30 })).to be false
end

it "bundles inline legacy-id root with registry and explicit draft4" do
root = {
"id" => "urn:root",
"type" => "object",
"properties" => { "value" => { "$ref" => "urn:string" } },
"required" => ["value"]
}
registry = JSONSchema::Registry.new(
[["urn:string", { "type" => "string" }]],
draft: :draft4
)

bundled = JSONSchema.bundle(root, registry: registry, draft: :draft4)
expect(bundled.dig("properties", "value", "$ref")).to eq("urn:string")
expect(bundled.dig("definitions", "urn:string")).not_to be_nil
end

it "raises when a $ref cannot be resolved" do
expect do
JSONSchema.bundle({ "$ref" => "https://example.com/missing.json" })
Expand Down
Loading
Loading