Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add meta validators #660

Merged
merged 1 commit into from
Dec 29, 2024
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Implement `IntoIterator` for `Location` to iterate over `LocationSegment`.
- Implement `FromIter` for `Location` to build a `Location` from an iterator of `LocationSegment`.
- `ValidationError::to_owned` method for converting errors into owned versions.
- Meta-schema validation support. [#442](https://github.com/Stranger6667/jsonschema/issues/442)

## [0.27.1] - 2024-12-24

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ See more usage examples in the [documentation](https://docs.rs/jsonschema).
- 🔧 Custom keywords and format validators
- 🌐 Remote reference fetching (network/file)
- 🎨 `Basic` output style as per JSON Schema spec
- ✨ Meta-schema validation for schema documents
- 🔗 Bindings for [Python](https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-py)
- 🚀 WebAssembly support
- 💻 Command Line Interface
Expand Down
4 changes: 4 additions & 0 deletions crates/jsonschema-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Meta-schema validation support. [#442](https://github.com/Stranger6667/jsonschema/issues/442)

## [0.27.1] - 2024-12-24

### Added
Expand Down
33 changes: 33 additions & 0 deletions crates/jsonschema-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ assert validator.is_valid(instance)
- 📚 Full support for popular JSON Schema drafts
- 🌐 Remote reference fetching (network/file)
- 🔧 Custom format validators
- ✨ Meta-schema validation for schema documents

### Supported drafts

Expand Down Expand Up @@ -157,6 +158,38 @@ On instance:
"unknown"'''
```

## Meta-Schema Validation

JSON Schema documents can be validated against their meta-schemas to ensure they are valid schemas. `jsonschema-rs` provides this functionality through the `meta` module:

```python
import jsonschema_rs

# Valid schema
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0}
},
"required": ["name"]
}

# Validate schema (draft is auto-detected)
assert jsonschema_rs.meta.is_valid(schema)
jsonschema_rs.meta.validate(schema) # No error raised

# Invalid schema
invalid_schema = {
"minimum": "not_a_number" # "minimum" must be a number
}

try:
jsonschema_rs.meta.validate(invalid_schema)
except jsonschema_rs.ValidationError as exc:
assert 'is not of type "number"' in str(exc)
```

## External References

By default, `jsonschema-rs` resolves HTTP references and file references from the local file system. You can implement a custom retriever to handle external references. Here's an example that uses a static map of schemas:
Expand Down
44 changes: 44 additions & 0 deletions crates/jsonschema-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,45 @@ mod build {
include!(concat!(env!("OUT_DIR"), "/built.rs"));
}

/// Meta-schema validation
mod meta {
use pyo3::prelude::*;
/// is_valid(schema)
///
/// Validate a JSON Schema document against its meta-schema. Draft version is detected automatically.
///
/// >>> jsonschema_rs.meta.is_valid({"type": "string"})
/// True
/// >>> jsonschema_rs.meta.is_valid({"type": "invalid_type"})
/// False
///
#[pyfunction]
#[pyo3(signature = (schema))]
pub(crate) fn is_valid(schema: &Bound<'_, PyAny>) -> PyResult<bool> {
let schema = crate::ser::to_value(schema)?;
Ok(jsonschema::meta::is_valid(&schema))
}

/// validate(schema)
///
/// Validate a JSON Schema document against its meta-schema and raise ValidationError if invalid.
/// Draft version is detected automatically.
///
/// >>> jsonschema_rs.meta.validate({"type": "string"})
/// >>> jsonschema_rs.meta.validate({"type": "invalid_type"})
/// ...
///
#[pyfunction]
#[pyo3(signature = (schema))]
pub(crate) fn validate(py: Python<'_>, schema: &Bound<'_, PyAny>) -> PyResult<()> {
let schema = crate::ser::to_value(schema)?;
match jsonschema::meta::validate(&schema) {
Ok(()) => Ok(()),
Err(error) => Err(crate::into_py_err(py, error, None)?),
}
}
}

/// JSON Schema validation for Python written in Rust.
#[pymodule]
fn jsonschema_rs(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
Expand All @@ -1107,6 +1146,11 @@ fn jsonschema_rs(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add("Draft201909", DRAFT201909)?;
module.add("Draft202012", DRAFT202012)?;

let meta = PyModule::new(py, "meta")?;
meta.add_function(wrap_pyfunction!(meta::is_valid, &meta)?)?;
meta.add_function(wrap_pyfunction!(meta::validate, &meta)?)?;
module.add_submodule(&meta)?;

// Add build metadata to ease triaging incoming issues
#[allow(deprecated)]
module.add("__build__", pyo3_built::pyo3_built!(py, build))?;
Expand Down
59 changes: 59 additions & 0 deletions crates/jsonschema-py/tests-py/test_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import pytest
from jsonschema_rs import meta, ValidationError


@pytest.mark.parametrize(
"schema",
[
{"type": "string"},
{"type": "number", "minimum": 0},
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
# Boolean schemas are valid
True,
False,
],
)
def test_valid_schemas(schema):
assert meta.is_valid(schema)
meta.validate(schema) # Should not raise


@pytest.mark.parametrize(
["schema", "expected"],
[
({"type": "invalid_type"}, "is not valid"),
({"type": "number", "minimum": "0"}, 'is not of type "number"'),
({"type": "object", "required": "name"}, 'is not of type "array"'),
],
)
def test_invalid_schemas(schema, expected):
assert not meta.is_valid(schema)
with pytest.raises(ValidationError, match=expected):
meta.validate(schema)


def test_validation_error_details():
schema = {"type": "invalid_type"}

with pytest.raises(ValidationError) as exc_info:
meta.validate(schema)

error = exc_info.value
assert hasattr(error, "message")
assert hasattr(error, "instance_path")
assert hasattr(error, "schema_path")
assert "invalid_type" in str(error)


@pytest.mark.parametrize(
"invalid_input",
[
None,
lambda: None,
object(),
{1, 2, 3},
],
)
def test_type_errors(invalid_input):
with pytest.raises((ValueError, ValidationError)):
meta.validate(invalid_input)
55 changes: 11 additions & 44 deletions crates/jsonschema/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use crate::{
ValidationError, Validator,
};
use ahash::{AHashMap, AHashSet};
use once_cell::sync::Lazy;
use referencing::{
uri, Draft, List, Registry, Resolved, Resolver, Resource, ResourceRef, Uri, Vocabulary,
VocabularySet, SPECIFICATIONS,
Expand Down Expand Up @@ -243,45 +242,6 @@ impl<'a> Context<'a> {
}
}

const EXPECT_MESSAGE: &str = "Invalid meta-schema";
static META_SCHEMA_VALIDATORS: Lazy<AHashMap<Draft, Validator>> = Lazy::new(|| {
let mut validators = AHashMap::with_capacity(5);
let mut options = crate::options();
options.without_schema_validation();
validators.insert(
Draft::Draft4,
options
.build(&referencing::meta::DRAFT4)
.expect(EXPECT_MESSAGE),
);
validators.insert(
Draft::Draft6,
options
.build(&referencing::meta::DRAFT6)
.expect(EXPECT_MESSAGE),
);
validators.insert(
Draft::Draft7,
options
.build(&referencing::meta::DRAFT7)
.expect(EXPECT_MESSAGE),
);
validators.insert(
Draft::Draft201909,
options
.build(&referencing::meta::DRAFT201909)
.expect(EXPECT_MESSAGE),
);
validators.insert(
Draft::Draft202012,
options
.without_schema_validation()
.build(&referencing::meta::DRAFT202012)
.expect(EXPECT_MESSAGE),
);
validators
});

pub(crate) fn build_validator(
mut config: ValidationOptions,
schema: &Value,
Expand Down Expand Up @@ -322,10 +282,17 @@ pub(crate) fn build_validator(

// Validate the schema itself
if config.validate_schema {
if let Err(error) = META_SCHEMA_VALIDATORS
.get(&draft)
.expect("Existing draft")
.validate(schema)
if let Err(error) = {
match draft {
Draft::Draft4 => &crate::draft4::meta::VALIDATOR,
Draft::Draft6 => &crate::draft6::meta::VALIDATOR,
Draft::Draft7 => &crate::draft7::meta::VALIDATOR,
Draft::Draft201909 => &crate::draft201909::meta::VALIDATOR,
Draft::Draft202012 => &crate::draft202012::meta::VALIDATOR,
_ => unreachable!("Unknown draft"),
}
}
.validate(schema)
{
return Err(error.to_owned());
}
Expand Down
Loading