diff --git a/CHANGELOG.md b/CHANGELOG.md index 575eb7c1c..4bde27717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- `jsonschema`: New `Validator::evaluate()` API exposes JSON Schema Output v1 (flag/list/hierarchical) reports along with iterator helpers for annotations and errors. +- `jsonschema-cli`: Structured `--output flag|list|hierarchical` modes now stream newline-delimited JSON records with schema/instance metadata plus JSON Schema Output v1 payloads (default `text` output remains human-readable). + +### Removed + +- `jsonschema`: The legacy `Validator::apply()`, `Output`, and `BasicOutput` types have been removed in favor of the richer `evaluate()` API. + ## [0.35.0] - 2025-11-16 ### Added diff --git a/MIGRATION.md b/MIGRATION.md index 643ea44ac..fd6ab3feb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,100 @@ # Migration Guide +## Upgrading from 0.35.x to 0.36.0 + +### Removal of `Validator::apply`, `Output`, and `BasicOutput` + +The legacy `apply()` API and its `BasicOutput`/`OutputUnit` structures have been removed in favor of +the richer [`Validator::evaluate`](https://docs.rs/jsonschema/latest/jsonschema/struct.Validator.html#method.evaluate) +interface that exposes the JSON Schema Output v1 formats (flag/list/hierarchical) directly. + +```rust +use serde_json::json; + +// Old (0.35.x) +let output = validator.apply(&instance).basic(); +match output { + BasicOutput::Valid(units) => println!("valid: {units:?}"), + BasicOutput::Invalid(errors) => println!("errors: {errors:?}"), +} + +// New (0.36.0) +let evaluation = validator.evaluate(&instance); +if evaluation.flag().valid { + println!("valid"); +} +let list = serde_json::to_value(evaluation.list())?; +let hierarchical = serde_json::to_value(evaluation.hierarchical())?; +``` + +Because `evaluate()` materializes every evaluation step so it can provide the structured outputs, it +always walks the full schema tree. If you only need a boolean result, continue to prefer +[`is_valid`](https://docs.rs/jsonschema/latest/jsonschema/fn.is_valid.html) or +[`validate`](https://docs.rs/jsonschema/latest/jsonschema/fn.validate.html). + +The serialized JSON now matches the [JSON Schema Output v1 specification](https://github.com/json-schema-org/json-schema-spec/blob/main/specs/output/jsonschema-validation-output-machines.md) +and its companion [schema](https://github.com/json-schema-org/json-schema-spec/blob/main/specs/output/schema.json). +For example, evaluating an array against a schema with `prefixItems` and `items` produces list output like: + +```json +{ + "valid": false, + "details": [ + {"valid": false, "evaluationPath": "", "schemaLocation": "", "instanceLocation": ""}, + { + "valid": false, + "evaluationPath": "/items", + "instanceLocation": "", + "schemaLocation": "/items", + "droppedAnnotations": true + }, + { + "valid": false, + "evaluationPath": "/items", + "instanceLocation": "/1", + "schemaLocation": "/items" + }, + { + "valid": false, + "evaluationPath": "/items/type", + "instanceLocation": "/1", + "schemaLocation": "/items/type", + "errors": {"type": "\"oops\" is not of type \"integer\""} + }, + { + "valid": true, + "evaluationPath": "/prefixItems", + "instanceLocation": "", + "schemaLocation": "/prefixItems", + "annotations": 0 + }, + { + "valid": true, + "evaluationPath": "/prefixItems/0", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0" + }, + { + "valid": true, + "evaluationPath": "/prefixItems/0/type", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0/type" + }, + { + "valid": true, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type" + } + ] +} +``` + +If you need to inspect annotations or errors programmatically without serializing to JSON, use the +new [`evaluation.iter_annotations()`](https://docs.rs/jsonschema/latest/jsonschema/struct.Evaluation.html#method.iter_annotations) +and [`evaluation.iter_errors()`](https://docs.rs/jsonschema/latest/jsonschema/struct.Evaluation.html#method.iter_errors) +helpers. + ## Upgrading from 0.34.x to 0.35.0 ### Custom meta-schemas require explicit registration diff --git a/README.md b/README.md index 11e0c4e60..5aa8ab61d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ fn main() -> Result<(), Box> { // Boolean result assert!(validator.is_valid(&instance)); + // Structured output (JSON Schema Output v1) + let evaluation = validator.evaluate(&instance); + for annotation in evaluation.iter_annotations() { + eprintln!( + "Annotation at {}: {:?}", + annotation.schema_location, + annotation.annotations.value() + ); + } + Ok(()) } ``` @@ -53,7 +63,7 @@ See more usage examples in the [documentation](https://docs.rs/jsonschema). - πŸ“š Full support for popular JSON Schema drafts - πŸ”§ Custom keywords and format validators - 🌐 Blocking & non-blocking remote reference fetching (network/file) -- 🎨 `Basic` output style as per JSON Schema spec +- 🎨 Structured Output v1 reports (flag/list/hierarchical) - ✨ Meta-schema validation for schema documents, including custom metaschemas - πŸ”— Bindings for [Python](https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-py) - πŸš€ WebAssembly support diff --git a/codecov.yaml b/codecov.yaml index d3d4eb3cf..1ad16bcc8 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -6,9 +6,14 @@ coverage: project: off patch: off -# Ignore test/benchmark infrastructure from coverage +# Ignore test/benchmark infrastructure & dev-only suite helpers from coverage ignore: - "crates/benchmark/" - "crates/benchmark-suite/" - "crates/jsonschema-testsuite/" + - "crates/jsonschema-testsuite-codegen/" + - "crates/jsonschema-testsuite-internal/" - "crates/jsonschema-referencing-testsuite/" + - "crates/jsonschema-referencing-testsuite-codegen/" + - "crates/jsonschema-referencing-testsuite-internal/" + - "crates/testsuite-common/" diff --git a/crates/jsonschema-cli/README.md b/crates/jsonschema-cli/README.md index 3de7819d4..fa584604d 100644 --- a/crates/jsonschema-cli/README.md +++ b/crates/jsonschema-cli/README.md @@ -19,9 +19,10 @@ jsonschema [OPTIONS] **NOTE**: It only supports valid JSON as input. -### Options: +### Options - `-i, --instance `: JSON instance(s) to validate (can be used multiple times) +- `--output `: Select output style (default: `text`). `text` prints the human-friendly summary, while the structured modes emit newline-delimited JSON (`ndjson`) records with `schema`, `instance`, and JSON Schema Output v1 payloads. - `-v, --version`: Show version information - `--help`: Display help information @@ -37,6 +38,13 @@ Validate multiple instances: jsonschema schema.json -i instance1.json -i instance2.json ``` +Emit JSON Schema Output v1 (`list`) for multiple instances: +``` +jsonschema schema.json -i instance1.json -i instance2.json --output list +{"output":"list","schema":"schema.json","instance":"instance1.json","payload":{"valid":true,...}} +{"output":"list","schema":"schema.json","instance":"instance2.json","payload":{"valid":false,...}} +``` + ## Features - Validate one or more JSON instances against a single schema @@ -45,17 +53,18 @@ jsonschema schema.json -i instance1.json -i instance2.json ## Output -For each instance, the tool will output: +For each instance: -- ` - VALID` if the instance is valid -- ` - INVALID` followed by a list of errors if invalid +- `text` (default): prints ` - VALID` or ` - INVALID. Errors:` followed by numbered error messages. +- `flag|list|hierarchical`: emit newline-delimited JSON objects shaped as: -Example output: -``` -instance1.json - VALID -instance2.json - INVALID. Errors: -1. "name" is a required property -2. "age" must be a number +```json +{ + "output": "list", + "schema": "schema.json", + "instance": "instance.json", + "payload": { "... JSON Schema Output v1 data ..." } +} ``` ## Exit Codes diff --git a/crates/jsonschema-cli/src/main.rs b/crates/jsonschema-cli/src/main.rs index 35f2c31be..e3eaa4653 100644 --- a/crates/jsonschema-cli/src/main.rs +++ b/crates/jsonschema-cli/src/main.rs @@ -8,6 +8,7 @@ use std::{ use clap::{ArgAction, Parser, ValueEnum}; use percent_encoding::{percent_encode, AsciiSet, CONTROLS}; +use serde_json::json; #[derive(Parser)] #[command(name = "jsonschema")] @@ -47,11 +48,39 @@ struct Cli { )] no_assert_format: Option, + /// Select the output format (text, flag, list, hierarchical). All modes emit newline-delimited JSON records. + #[arg( + long = "output", + value_enum, + default_value_t = Output::Text, + help = "Select output style: text (default), flag, list, hierarchical" + )] + output: Output, + /// Show program's version number and exit. #[arg(short = 'v', long = "version")] version: bool, } +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum Output { + Text, + Flag, + List, + Hierarchical, +} + +impl Output { + fn as_str(self) -> &'static str { + match self { + Output::Text => "text", + Output::Flag => "flag", + Output::List => "list", + Output::Hierarchical => "hierarchical", + } + } +} + #[derive(ValueEnum, Clone, Copy, Debug)] enum Draft { #[clap(name = "4")] @@ -153,6 +182,7 @@ fn validate_instances( schema_path: &Path, draft: Option, assert_format: Option, + output: Output, ) -> Result> { let mut success = true; @@ -168,19 +198,48 @@ fn validate_instances( } match options.build(&schema_json) { Ok(validator) => { - for instance in instances { - let instance_json = read_json(instance)??; - let mut errors = validator.iter_errors(&instance_json); - let filename = instance.to_string_lossy(); - if let Some(first) = errors.next() { - success = false; - println!("{filename} - INVALID. Errors:"); - println!("1. {first}"); - for (i, error) in errors.enumerate() { - println!("{}. {error}", i + 2); + if matches!(output, Output::Text) { + for instance in instances { + let instance_json = read_json(instance)??; + let mut errors = validator.iter_errors(&instance_json); + let filename = instance.to_string_lossy(); + if let Some(first) = errors.next() { + success = false; + println!("{filename} - INVALID. Errors:"); + println!("1. {first}"); + for (i, error) in errors.enumerate() { + println!("{}. {error}", i + 2); + } + } else { + println!("{filename} - VALID"); + } + } + } else { + let schema_display = schema_path.to_string_lossy().to_string(); + let output_format = output.as_str(); + for instance in instances { + let instance_json = read_json(instance)??; + let evaluation = validator.evaluate(&instance_json); + let flag_output = evaluation.flag(); + let payload = match output { + Output::Text => unreachable!("handled above"), + Output::Flag => serde_json::to_value(flag_output)?, + Output::List => serde_json::to_value(evaluation.list())?, + Output::Hierarchical => serde_json::to_value(evaluation.hierarchical())?, + }; + + let instance_display = instance.to_string_lossy(); + let record = json!({ + "output": output_format, + "schema": &schema_display, + "instance": instance_display, + "payload": payload, + }); + println!("{}", serde_json::to_string(&record)?); + + if !flag_output.valid { + success = false; } - } else { - println!("{filename} - VALID"); } } } @@ -206,7 +265,13 @@ fn main() -> ExitCode { // - Some(false) if --no-assert-format // - None if neither (use builder’s default) let assert_format = config.assert_format.or(config.no_assert_format); - return match validate_instances(&instances, &schema, config.draft, assert_format) { + return match validate_instances( + &instances, + &schema, + config.draft, + assert_format, + config.output, + ) { Ok(true) => ExitCode::SUCCESS, Ok(false) => ExitCode::FAILURE, Err(error) => { diff --git a/crates/jsonschema-cli/tests/cli.rs b/crates/jsonschema-cli/tests/cli.rs index ad12f4081..d8e943ca5 100644 --- a/crates/jsonschema-cli/tests/cli.rs +++ b/crates/jsonschema-cli/tests/cli.rs @@ -1,6 +1,7 @@ use assert_cmd::{cargo::cargo_bin_cmd, Command}; use insta::assert_snapshot; -use std::fs; +use serde_json::Value; +use std::{collections::HashMap, fs}; use tempfile::tempdir; fn cli() -> Command { @@ -21,6 +22,14 @@ fn sanitize_output(output: String, file_names: &[&str]) -> String { sanitized } +fn parse_ndjson(output: &str) -> Vec { + output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + #[test] fn test_version() { let mut cmd = cli(); @@ -395,3 +404,261 @@ fn test_format_enforcement_via_cli_flag() { ); assert_snapshot!("format_enforcement_enabled", out); } + +#[test] +fn test_output_flag_ndjson() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#, + ); + let valid = create_temp_file(&dir, "valid.json", r#"{"name": "John"}"#); + let invalid = create_temp_file(&dir, "invalid.json", r#"{"name": 123}"#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&valid) + .arg("--instance") + .arg(&invalid) + .arg("--output") + .arg("flag"); + let output = cmd.output().unwrap(); + assert!( + !output.status.success(), + "flag output should fail when an instance is invalid" + ); + let records = parse_ndjson(&String::from_utf8_lossy(&output.stdout)); + assert_eq!(records.len(), 2); + for record in &records { + assert_eq!(record["output"], "flag"); + assert_eq!(record["schema"], schema); + } + let mut by_instance = HashMap::new(); + for record in records { + let instance = record["instance"].as_str().unwrap(); + let valid = record["payload"]["valid"].as_bool().unwrap(); + by_instance.insert(instance.to_string(), valid); + } + assert_eq!(by_instance.get(&valid), Some(&true)); + assert_eq!(by_instance.get(&invalid), Some(&false)); +} + +#[test] +fn test_output_list_ndjson() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "object", "properties": {"age": {"type": "number"}}}"#, + ); + let valid = create_temp_file(&dir, "valid.json", r#"{"age": 42}"#); + let invalid = create_temp_file(&dir, "invalid.json", r#"{"age": "old"}"#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&valid) + .arg("--instance") + .arg(&invalid) + .arg("--output") + .arg("list"); + let output = cmd.output().unwrap(); + assert!( + !output.status.success(), + "list output should fail when an instance is invalid" + ); + let records = parse_ndjson(&String::from_utf8_lossy(&output.stdout)); + assert_eq!(records.len(), 2); + for record in records { + assert_eq!(record["output"], "list"); + assert_eq!(record["schema"], schema); + assert!( + record["payload"]["details"].is_array(), + "list payload must contain details array" + ); + } +} + +#[test] +fn test_output_text_valid() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#, + ); + let valid = create_temp_file(&dir, "valid.json", r#"{"name": "Alice"}"#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&valid) + .arg("--output") + .arg("text"); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let sanitized = sanitize_output( + String::from_utf8_lossy(&output.stdout).to_string(), + &[&valid], + ); + assert_snapshot!(sanitized); +} + +#[test] +fn test_output_text_single_error() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "object", "properties": {"age": {"type": "number"}}}"#, + ); + let invalid = create_temp_file(&dir, "invalid.json", r#"{"age": "not a number"}"#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&invalid) + .arg("--output") + .arg("text"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + let sanitized = sanitize_output( + String::from_utf8_lossy(&output.stdout).to_string(), + &[&invalid], + ); + assert_snapshot!(sanitized); +} + +#[test] +fn test_output_text_multiple_errors() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "number"}, + "email": {"type": "string"} + }, + "required": ["name", "age", "email"] + }"#, + ); + let invalid = create_temp_file( + &dir, + "invalid.json", + r#"{"name": 123, "age": "not a number"}"#, + ); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&invalid) + .arg("--output") + .arg("text"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + let out = String::from_utf8_lossy(&output.stdout); + let sanitized = sanitize_output(out.to_string(), &[&invalid]); + + // Verify error numbering: "1. ", "2. ", "3. " + assert!(sanitized.contains("1. ")); + assert!(sanitized.contains("2. ")); + assert!(sanitized.contains("3. ")); + assert_snapshot!(sanitized); +} + +#[test] +fn test_output_hierarchical_valid() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#, + ); + let valid = create_temp_file(&dir, "valid.json", r#"{"name": "Bob"}"#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&valid) + .arg("--output") + .arg("hierarchical"); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let records = parse_ndjson(&String::from_utf8_lossy(&output.stdout)); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record["output"], "hierarchical"); + assert_eq!(record["schema"], schema); + assert_eq!(record["instance"], valid); + assert_eq!(record["payload"]["valid"], true); +} + +#[test] +fn test_output_hierarchical_invalid() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "age": {"type": "number", "minimum": 0} + } + }"#, + ); + let invalid = create_temp_file(&dir, "invalid.json", r#"{"age": "invalid"}"#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&invalid) + .arg("--output") + .arg("hierarchical"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + let records = parse_ndjson(&String::from_utf8_lossy(&output.stdout)); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record["output"], "hierarchical"); + assert_eq!(record["schema"], schema); + assert_eq!(record["instance"], invalid); + assert_eq!(record["payload"]["valid"], false); +} + +#[test] +fn test_output_hierarchical_multiple_instances() { + let dir = tempdir().unwrap(); + let schema = create_temp_file(&dir, "schema.json", r#"{"type": "string", "minLength": 3}"#); + let valid = create_temp_file(&dir, "valid.json", r#""hello""#); + let invalid = create_temp_file(&dir, "invalid.json", r#""no""#); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&valid) + .arg("--instance") + .arg(&invalid) + .arg("--output") + .arg("hierarchical"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + let records = parse_ndjson(&String::from_utf8_lossy(&output.stdout)); + assert_eq!(records.len(), 2); + + let mut results = HashMap::new(); + for record in &records { + assert_eq!(record["output"], "hierarchical"); + assert_eq!(record["schema"], schema); + let instance = record["instance"].as_str().unwrap(); + let valid = record["payload"]["valid"].as_bool().unwrap(); + results.insert(instance.to_string(), valid); + } + + assert_eq!(results.get(&valid), Some(&true)); + assert_eq!(results.get(&invalid), Some(&false)); +} diff --git a/crates/jsonschema-cli/tests/snapshots/cli__output_text_multiple_errors.snap b/crates/jsonschema-cli/tests/snapshots/cli__output_text_multiple_errors.snap new file mode 100644 index 000000000..d50b22c9b --- /dev/null +++ b/crates/jsonschema-cli/tests/snapshots/cli__output_text_multiple_errors.snap @@ -0,0 +1,8 @@ +--- +source: crates/jsonschema-cli/tests/cli.rs +expression: sanitized +--- +{FILE_1} - INVALID. Errors: +1. "not a number" is not of type "number" +2. 123 is not of type "string" +3. "email" is a required property diff --git a/crates/jsonschema-cli/tests/snapshots/cli__output_text_single_error.snap b/crates/jsonschema-cli/tests/snapshots/cli__output_text_single_error.snap new file mode 100644 index 000000000..de492b787 --- /dev/null +++ b/crates/jsonschema-cli/tests/snapshots/cli__output_text_single_error.snap @@ -0,0 +1,6 @@ +--- +source: crates/jsonschema-cli/tests/cli.rs +expression: sanitized +--- +{FILE_1} - INVALID. Errors: +1. "not a number" is not of type "number" diff --git a/crates/jsonschema-cli/tests/snapshots/cli__output_text_valid.snap b/crates/jsonschema-cli/tests/snapshots/cli__output_text_valid.snap new file mode 100644 index 000000000..0c7118ac5 --- /dev/null +++ b/crates/jsonschema-cli/tests/snapshots/cli__output_text_valid.snap @@ -0,0 +1,5 @@ +--- +source: crates/jsonschema-cli/tests/cli.rs +expression: sanitized +--- +{FILE_1} - VALID diff --git a/crates/jsonschema-py/CHANGELOG.md b/crates/jsonschema-py/CHANGELOG.md index edbfc16fb..cb4a25e37 100644 --- a/crates/jsonschema-py/CHANGELOG.md +++ b/crates/jsonschema-py/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `jsonschema_rs.evaluate()`, `Validator.evaluate()`, and the `Evaluation` type for retrieving JSON Schema Output v1 (flag/list/hierarchical) formats along with annotations and errors. + ## [0.35.0] - 2025-11-16 ### Added diff --git a/crates/jsonschema-py/README.md b/crates/jsonschema-py/README.md index 42398c364..93e1427d3 100644 --- a/crates/jsonschema-py/README.md +++ b/crates/jsonschema-py/README.md @@ -155,6 +155,148 @@ On instance: "unknown"''' ``` +### Structured Output with `evaluate` + +When you need more than a boolean result, use the `evaluate` API to access the JSON Schema Output v1 formats: + +```python +import jsonschema_rs + +schema = { + "type": "array", + "prefixItems": [{"type": "string"}], + "items": {"type": "integer"}, +} +evaluation = jsonschema_rs.evaluate(schema, ["hello", "oops"]) + +assert evaluation.flag() == {"valid": False} +assert evaluation.list() == { + "valid": False, + "details": [ + { + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "valid": False, + }, + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "", + "schemaLocation": "/items", + "droppedAnnotations": True, + }, + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "/1", + "schemaLocation": "/items", + }, + { + "valid": False, + "evaluationPath": "/items/type", + "instanceLocation": "/1", + "schemaLocation": "/items/type", + "errors": {"type": '"oops" is not of type "integer"'}, + }, + { + "valid": True, + "evaluationPath": "/prefixItems", + "instanceLocation": "", + "schemaLocation": "/prefixItems", + "annotations": 0, + }, + { + "valid": True, + "evaluationPath": "/prefixItems/0", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0", + }, + { + "valid": True, + "evaluationPath": "/prefixItems/0/type", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0/type", + }, + { + "valid": True, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type", + }, + ], +} + +hierarchical = evaluation.hierarchical() +assert hierarchical == { + "valid": False, + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "details": [ + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "", + "schemaLocation": "/items", + "droppedAnnotations": True, + "details": [ + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "/1", + "schemaLocation": "/items", + "details": [ + { + "valid": False, + "evaluationPath": "/items/type", + "instanceLocation": "/1", + "schemaLocation": "/items/type", + "errors": {"type": '"oops" is not of type "integer"'}, + } + ], + } + ], + }, + { + "valid": True, + "evaluationPath": "/prefixItems", + "instanceLocation": "", + "schemaLocation": "/prefixItems", + "annotations": 0, + "details": [ + { + "valid": True, + "evaluationPath": "/prefixItems/0", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0", + "details": [ + { + "valid": True, + "evaluationPath": "/prefixItems/0/type", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0/type", + } + ], + } + ], + }, + { + "valid": True, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type", + }, + ], +} + +for error in evaluation.errors(): + print(error["instanceLocation"], error["error"]) + +for annotation in evaluation.annotations(): + print(annotation["schemaLocation"], annotation["annotations"]) +``` + ### Arbitrary-Precision Numbers The Python bindings always include the `arbitrary-precision` support from the Rust validator, so numeric diff --git a/crates/jsonschema-py/python/jsonschema_rs/__init__.py b/crates/jsonschema-py/python/jsonschema_rs/__init__.py index f5001f899..ec770b969 100644 --- a/crates/jsonschema-py/python/jsonschema_rs/__init__.py +++ b/crates/jsonschema-py/python/jsonschema_rs/__init__.py @@ -11,10 +11,12 @@ Draft201909Validator, Draft202012, Draft202012Validator, + Evaluation, FancyRegexOptions, RegexOptions, Registry, ValidationErrorKind, + evaluate, is_valid, iter_errors, meta, @@ -77,9 +79,11 @@ def __repr__(self) -> str: "ReferencingError", "ValidationError", "ValidationErrorKind", + "Evaluation", "is_valid", "validate", "iter_errors", + "evaluate", "validator_for", "Draft4", "Draft6", diff --git a/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi b/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi index b919c4824..6546c77d7 100644 --- a/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi +++ b/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi @@ -1,11 +1,31 @@ from collections.abc import Iterator -from typing import Any, Callable, Protocol, TypeAlias, TypeVar, Union +from typing import Any, Callable, List, Protocol, TypeAlias, TypeVar, TypedDict, Union _SchemaT = TypeVar("_SchemaT", bool, dict[str, Any]) _FormatFunc = TypeVar("_FormatFunc", bound=Callable[[str], bool]) JSONType: TypeAlias = dict[str, Any] | list | str | int | float | bool | None JSONPrimitive: TypeAlias = str | int | float | bool | None +class EvaluationAnnotation(TypedDict): + schemaLocation: str + absoluteKeywordLocation: str | None + instanceLocation: str + annotations: JSONType + +class EvaluationErrorEntry(TypedDict): + schemaLocation: str + absoluteKeywordLocation: str | None + instanceLocation: str + error: str + +class Evaluation: + valid: bool + def flag(self) -> JSONType: ... + def list(self) -> JSONType: ... + def hierarchical(self) -> JSONType: ... + def annotations(self) -> List[EvaluationAnnotation]: ... + def errors(self) -> List[EvaluationErrorEntry]: ... + class FancyRegexOptions: def __init__( self, backtrack_limit: int | None = None, size_limit: int | None = None, dfa_size_limit: int | None = None @@ -56,10 +76,23 @@ def iter_errors( validate_formats: bool | None = None, ignore_unknown_formats: bool = True, retriever: RetrieverProtocol | None = None, + registry: Registry | None = None, mask: str | None = None, base_uri: str | None = None, pattern_options: PatternOptionsType | None = None, ) -> Iterator[ValidationError]: ... +def evaluate( + schema: _SchemaT, + instance: Any, + draft: int | None = None, + formats: dict[str, _FormatFunc] | None = None, + validate_formats: bool | None = None, + ignore_unknown_formats: bool = True, + retriever: RetrieverProtocol | None = None, + registry: Registry | None = None, + base_uri: str | None = None, + pattern_options: PatternOptionsType | None = None, +) -> Evaluation: ... class ReferencingError: message: str @@ -195,6 +228,7 @@ class Draft4Validator: def is_valid(self, instance: Any) -> bool: ... def validate(self, instance: Any) -> None: ... def iter_errors(self, instance: Any) -> Iterator[ValidationError]: ... + def evaluate(self, instance: Any) -> Evaluation: ... class Draft6Validator: def __init__( @@ -212,6 +246,7 @@ class Draft6Validator: def is_valid(self, instance: Any) -> bool: ... def validate(self, instance: Any) -> None: ... def iter_errors(self, instance: Any) -> Iterator[ValidationError]: ... + def evaluate(self, instance: Any) -> Evaluation: ... class Draft7Validator: def __init__( @@ -229,6 +264,7 @@ class Draft7Validator: def is_valid(self, instance: Any) -> bool: ... def validate(self, instance: Any) -> None: ... def iter_errors(self, instance: Any) -> Iterator[ValidationError]: ... + def evaluate(self, instance: Any) -> Evaluation: ... class Draft201909Validator: def __init__( @@ -246,6 +282,7 @@ class Draft201909Validator: def is_valid(self, instance: Any) -> bool: ... def validate(self, instance: Any) -> None: ... def iter_errors(self, instance: Any) -> Iterator[ValidationError]: ... + def evaluate(self, instance: Any) -> Evaluation: ... class Draft202012Validator: def __init__( @@ -263,6 +300,7 @@ class Draft202012Validator: def is_valid(self, instance: Any) -> bool: ... def validate(self, instance: Any) -> None: ... def iter_errors(self, instance: Any) -> Iterator[ValidationError]: ... + def evaluate(self, instance: Any) -> Evaluation: ... def validator_for( schema: _SchemaT, diff --git a/crates/jsonschema-py/src/lib.rs b/crates/jsonschema-py/src/lib.rs index 880e0793b..b6c4e138b 100644 --- a/crates/jsonschema-py/src/lib.rs +++ b/crates/jsonschema-py/src/lib.rs @@ -23,6 +23,7 @@ use pyo3::{ use regex::{FancyRegexOptions, RegexOptions}; use retriever::{into_retriever, Retriever}; use ser::to_value; +use serde::Serialize; #[macro_use] extern crate pyo3_built; @@ -103,6 +104,113 @@ fn value_to_python(py: Python<'_>, value: &serde_json::Value) -> PyResult(py: Python<'_>, output: &T) -> PyResult> +where + T: Serialize + ?Sized, +{ + let json_value = serde_json::to_value(output).map_err(|err| { + PyValueError::new_err(format!("Failed to serialize evaluation output: {err}")) + })?; + value_to_python(py, &json_value) +} + +fn annotation_entry_to_py( + py: Python<'_>, + entry: jsonschema::AnnotationEntry<'_>, +) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("schemaLocation", entry.schema_location)?; + if let Some(uri) = entry.absolute_keyword_location { + dict.set_item("absoluteKeywordLocation", uri.as_str())?; + } else { + dict.set_item("absoluteKeywordLocation", py.None())?; + } + dict.set_item("instanceLocation", entry.instance_location.as_str())?; + dict.set_item( + "annotations", + value_to_python(py, entry.annotations.value())?, + )?; + Ok(dict.into()) +} + +fn error_entry_to_py(py: Python<'_>, entry: jsonschema::ErrorEntry<'_>) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("schemaLocation", entry.schema_location)?; + if let Some(uri) = entry.absolute_keyword_location { + dict.set_item("absoluteKeywordLocation", uri.as_str())?; + } else { + dict.set_item("absoluteKeywordLocation", py.None())?; + } + dict.set_item("instanceLocation", entry.instance_location.as_str())?; + dict.set_item("error", entry.error.to_string())?; + Ok(dict.into()) +} + +#[pyclass(module = "jsonschema_rs", name = "Evaluation")] +struct PyEvaluation { + inner: jsonschema::Evaluation, +} + +impl PyEvaluation { + fn new(evaluation: jsonschema::Evaluation) -> Self { + PyEvaluation { inner: evaluation } + } +} + +#[pymethods] +impl PyEvaluation { + /// Whether the evaluated instance is valid. + #[getter] + fn valid(&self) -> bool { + self.inner.flag().valid + } + + /// Return the flag output representation as a Python object. + #[pyo3(text_signature = "()")] + fn flag(&self, py: Python<'_>) -> PyResult> { + let flag = self.inner.flag(); + evaluation_output_to_python(py, &flag) + } + + /// Return the list output representation as a Python object. + #[pyo3(text_signature = "()")] + fn list(&self, py: Python<'_>) -> PyResult> { + let list_output = self.inner.list(); + evaluation_output_to_python(py, &list_output) + } + + /// Return the hierarchical output representation as a Python object. + #[pyo3(text_signature = "()")] + fn hierarchical(&self, py: Python<'_>) -> PyResult> { + let hierarchical_output = self.inner.hierarchical(); + evaluation_output_to_python(py, &hierarchical_output) + } + + /// Return collected annotations for all evaluated nodes. + #[pyo3(text_signature = "()")] + fn annotations(&self, py: Python<'_>) -> PyResult> { + let entries = PyList::empty(py); + for entry in self.inner.iter_annotations() { + entries.append(annotation_entry_to_py(py, entry)?)?; + } + Ok(entries.into()) + } + + /// Return collected errors for all evaluated nodes. + #[pyo3(text_signature = "()")] + fn errors(&self, py: Python<'_>) -> PyResult> { + let entries = PyList::empty(py); + for entry in self.inner.iter_errors() { + entries.append(error_entry_to_py(py, entry)?)?; + } + Ok(entries.into()) + } + + fn __repr__(&self) -> String { + format!("", self.inner.flag().valid) + } +} + struct ValidationErrorArgs { message: String, verbose_message: String, @@ -833,6 +941,109 @@ fn iter_errors( } } +/// evaluate(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, registry=None, mask=None, base_uri=None, pattern_options=None) +/// +/// Evaluate an instance against a schema and return structured output formats. +/// +/// ```text +/// >>> schema = {"type": "array", "prefixItems": [{"type": "string"}], "items": {"type": "integer"}} +/// >>> evaluation = evaluate(schema, ["hello", "oops"]) +/// >>> evaluation.list() +/// { +/// 'valid': False, +/// 'details': [ +/// { +/// 'evaluationPath': '', +/// 'instanceLocation': '', +/// 'schemaLocation': '', +/// 'valid': False +/// }, +/// { +/// 'valid': False, +/// 'evaluationPath': '/0', +/// 'instanceLocation': '', +/// 'schemaLocation': '/items', +/// 'droppedAnnotations': True +/// }, +/// { +/// 'valid': False, +/// 'evaluationPath': '/0/0', +/// 'instanceLocation': '/1', +/// 'schemaLocation': '/items' +/// }, +/// { +/// 'valid': False, +/// 'evaluationPath': '/0/0/0', +/// 'instanceLocation': '/1', +/// 'schemaLocation': '/items/type', +/// 'errors': {'type': '"oops" is not of type "integer"'} +/// }, +/// { +/// 'valid': True, +/// 'evaluationPath': '/1', +/// 'instanceLocation': '', +/// 'schemaLocation': '/prefixItems', +/// 'annotations': 0 +/// }, +/// { +/// 'valid': True, +/// 'evaluationPath': '/1/0', +/// 'instanceLocation': '/0', +/// 'schemaLocation': '/prefixItems/0' +/// }, +/// { +/// 'valid': True, +/// 'evaluationPath': '/1/0/0', +/// 'instanceLocation': '/0', +/// 'schemaLocation': '/prefixItems/0/type' +/// }, +/// { +/// 'valid': True, +/// 'evaluationPath': '/2', +/// 'instanceLocation': '', +/// 'schemaLocation': '/type' +/// } +/// ] +/// } +/// ``` +/// +#[pyfunction] +#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, registry=None, base_uri=None, pattern_options=None))] +#[allow(clippy::needless_pass_by_value)] +fn evaluate( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + instance: &Bound<'_, PyAny>, + draft: Option, + formats: Option<&Bound<'_, PyDict>>, + validate_formats: Option, + ignore_unknown_formats: Option, + retriever: Option<&Bound<'_, PyAny>>, + registry: Option<®istry::Registry>, + base_uri: Option, + pattern_options: Option<&Bound<'_, PyAny>>, +) -> PyResult { + let options = make_options( + draft, + formats, + validate_formats, + ignore_unknown_formats, + retriever, + registry, + base_uri, + pattern_options, + )?; + let schema = ser::to_value(schema)?; + let instance = ser::to_value(instance)?; + let validator = match options.build(&schema) { + Ok(validator) => validator, + Err(error) => return Err(into_py_err(py, error, None)?), + }; + let evaluation = panic::catch_unwind(AssertUnwindSafe(|| validator.evaluate(&instance))) + .map_err(handle_format_checked_panic)?; + Ok(PyEvaluation::new(evaluation)) +} + #[allow(clippy::needless_pass_by_value)] fn handle_format_checked_panic(err: Box) -> PyErr { LAST_FORMAT_ERROR.with(|last| { @@ -1002,6 +1213,53 @@ impl Validator { ) -> PyResult { iter_on_error(py, &self.validator, instance, self.mask.as_deref()) } + /// evaluate(instance) + /// + /// Evaluate the instance and return structured JSON Schema outputs. + /// + /// ```text + /// >>> validator = validator_for({"prefixItems": [{"type": "string"}], "items": {"type": "integer"}}) + /// >>> validator.evaluate(["hello", "oops"]).list() + /// { + /// 'valid': False, + /// 'details': [ + /// { + /// 'evaluationPath': '', + /// 'instanceLocation': '', + /// 'schemaLocation': '', + /// 'valid': False + /// }, + /// { + /// 'valid': False, + /// 'evaluationPath': '/0', + /// 'instanceLocation': '', + /// 'schemaLocation': '/items', + /// 'droppedAnnotations': True + /// }, + /// { + /// 'valid': False, + /// 'evaluationPath': '/0/0', + /// 'instanceLocation': '/1', + /// 'schemaLocation': '/items' + /// }, + /// { + /// 'valid': False, + /// 'evaluationPath': '/0/0/0', + /// 'instanceLocation': '/1', + /// 'schemaLocation': '/items/type', + /// 'errors': {'type': '"oops" is not of type "integer"'} + /// } + /// ] + /// } + /// ``` + #[pyo3(text_signature = "(instance)")] + fn evaluate(&self, instance: &Bound<'_, PyAny>) -> PyResult { + let instance = ser::to_value(instance)?; + let evaluation = + panic::catch_unwind(AssertUnwindSafe(|| self.validator.evaluate(&instance))) + .map_err(handle_format_checked_panic)?; + Ok(PyEvaluation::new(evaluation)) + } fn __repr__(&self) -> &'static str { match self.validator.draft() { Draft::Draft4 => "", @@ -1358,12 +1616,14 @@ fn jsonschema_rs(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_wrapped(wrap_pyfunction!(is_valid))?; module.add_wrapped(wrap_pyfunction!(validate))?; module.add_wrapped(wrap_pyfunction!(iter_errors))?; + module.add_wrapped(wrap_pyfunction!(evaluate))?; module.add_wrapped(wrap_pyfunction!(validator_for))?; module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; + module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; diff --git a/crates/jsonschema-py/tests-py/test_evaluate.py b/crates/jsonschema-py/tests-py/test_evaluate.py new file mode 100644 index 000000000..715b1dca7 --- /dev/null +++ b/crates/jsonschema-py/tests-py/test_evaluate.py @@ -0,0 +1,303 @@ +import jsonschema_rs + + +def test_evaluate_produces_expected_outputs_for_valid_instance(): + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "number", "minimum": 0}}, + "required": ["name"], + } + instance = {"name": "Alice", "age": 1} + + evaluation = jsonschema_rs.evaluate(schema, instance) + + assert evaluation.flag() == {"valid": True} + + assert evaluation.list() == { + "valid": True, + "details": [ + { + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "valid": True, + }, + { + "valid": True, + "evaluationPath": "/properties", + "instanceLocation": "", + "schemaLocation": "/properties", + "annotations": ["age", "name"], + }, + { + "valid": True, + "evaluationPath": "/properties/age", + "instanceLocation": "/age", + "schemaLocation": "/properties/age", + }, + { + "valid": True, + "evaluationPath": "/properties/age/minimum", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/minimum", + }, + { + "valid": True, + "evaluationPath": "/properties/age/type", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/type", + }, + { + "valid": True, + "evaluationPath": "/properties/name", + "instanceLocation": "/name", + "schemaLocation": "/properties/name", + }, + { + "valid": True, + "evaluationPath": "/properties/name/type", + "instanceLocation": "/name", + "schemaLocation": "/properties/name/type", + }, + { + "valid": True, + "evaluationPath": "/required", + "instanceLocation": "", + "schemaLocation": "/required", + }, + { + "valid": True, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type", + }, + ], + } + + assert evaluation.hierarchical() == { + "valid": True, + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "details": [ + { + "valid": True, + "evaluationPath": "/properties", + "instanceLocation": "", + "schemaLocation": "/properties", + "annotations": ["age", "name"], + "details": [ + { + "valid": True, + "evaluationPath": "/properties/age", + "instanceLocation": "/age", + "schemaLocation": "/properties/age", + "details": [ + { + "valid": True, + "evaluationPath": "/properties/age/minimum", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/minimum", + }, + { + "valid": True, + "evaluationPath": "/properties/age/type", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/type", + }, + ], + }, + { + "valid": True, + "evaluationPath": "/properties/name", + "instanceLocation": "/name", + "schemaLocation": "/properties/name", + "details": [ + { + "valid": True, + "evaluationPath": "/properties/name/type", + "instanceLocation": "/name", + "schemaLocation": "/properties/name/type", + } + ], + }, + ], + }, + { + "valid": True, + "evaluationPath": "/required", + "instanceLocation": "", + "schemaLocation": "/required", + }, + { + "valid": True, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type", + }, + ], +} + + assert evaluation.annotations() == [ + { + "schemaLocation": "/properties", + "absoluteKeywordLocation": None, + "instanceLocation": "", + "annotations": ["age", "name"], + } + ] + assert evaluation.errors() == [] + + +def test_validator_evaluate_annotations_and_errors(): + schema = { + "type": "array", + "prefixItems": [{"type": "string"}], + "items": {"type": "integer"}, + } + validator = jsonschema_rs.validator_for(schema) + + valid_eval = validator.evaluate(["hello", 1]) + assert valid_eval.annotations() == [ + { + "schemaLocation": "/items", + "absoluteKeywordLocation": None, + "instanceLocation": "", + "annotations": True, + }, + { + "schemaLocation": "/prefixItems", + "absoluteKeywordLocation": None, + "instanceLocation": "", + "annotations": 0, + }, + ] + assert valid_eval.errors() == [] + + invalid_eval = validator.evaluate(["hello", "oops"]) + assert invalid_eval.flag() == {"valid": False} + assert invalid_eval.errors() == [ + { + "schemaLocation": "/items/type", + "absoluteKeywordLocation": None, + "instanceLocation": "/1", + "error": '"oops" is not of type "integer"', + } + ] + assert invalid_eval.list() == { + "valid": False, + "details": [ + { + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "valid": False, + }, + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "", + "schemaLocation": "/items", + "droppedAnnotations": True, + }, + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "/1", + "schemaLocation": "/items", + }, + { + "valid": False, + "evaluationPath": "/items/type", + "instanceLocation": "/1", + "schemaLocation": "/items/type", + "errors": {"type": '"oops" is not of type "integer"'}, + }, + { + "valid": True, + "evaluationPath": "/prefixItems", + "instanceLocation": "", + "schemaLocation": "/prefixItems", + "annotations": 0, + }, + { + "valid": True, + "evaluationPath": "/prefixItems/0", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0", + }, + { + "valid": True, + "evaluationPath": "/prefixItems/0/type", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0/type", + }, + { + "valid": True, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type", + }, + ], + } + assert invalid_eval.hierarchical() == { + "valid": False, + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "details": [ + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "", + "schemaLocation": "/items", + "droppedAnnotations": True, + "details": [ + { + "valid": False, + "evaluationPath": "/items", + "instanceLocation": "/1", + "schemaLocation": "/items", + "details": [ + { + "valid": False, + "evaluationPath": "/items/type", + "instanceLocation": "/1", + "schemaLocation": "/items/type", + "errors": {"type": '"oops" is not of type "integer"'}, + } + ], + } + ], + }, + { + "valid": True, + "evaluationPath": "/prefixItems", + "instanceLocation": "", + "schemaLocation": "/prefixItems", + "annotations": 0, + "details": [ + { + "valid": True, + "evaluationPath": "/prefixItems/0", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0", + "details": [ + { + "valid": True, + "evaluationPath": "/prefixItems/0/type", + "instanceLocation": "/0", + "schemaLocation": "/prefixItems/0/type", + } + ], + } + ], + }, + { + "valid": True, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type", + }, + ], + } diff --git a/crates/jsonschema-testsuite-codegen/src/lib.rs b/crates/jsonschema-testsuite-codegen/src/lib.rs index 1a3862e3a..2c448e62a 100644 --- a/crates/jsonschema-testsuite-codegen/src/lib.rs +++ b/crates/jsonschema-testsuite-codegen/src/lib.rs @@ -1,11 +1,13 @@ -use std::collections::HashSet; - use proc_macro::TokenStream; use quote::{format_ident, quote}; +use std::collections::HashSet; use syn::{parse_macro_input, ItemFn}; + mod generator; mod idents; mod loader; +mod output_generator; +mod output_loader; mod remotes; /// A procedural macro that generates tests from @@ -18,12 +20,7 @@ pub fn suite(args: TokenStream, input: TokenStream) -> TokenStream { let remotes = match remotes::generate(&config.path) { Ok(remotes) => remotes, - Err(e) => { - let err = e.to_string(); - return TokenStream::from(quote! { - compile_error!(#err); - }); - } + Err(e) => return compile_error_ts(e.to_string()), }; let mut output = quote! { @@ -49,7 +46,7 @@ pub fn suite(args: TokenStream, input: TokenStream) -> TokenStream { match REMOTE_MAP.get(uri.as_str()) { Some(contents) => Ok(serde_json::from_str(contents) .expect("Failed to parse remote schema")), - None => Err(format!("Unknown remote: {}", uri).into()), + None => Err(format!("Unknown remote: {uri}").into()), } } } @@ -66,12 +63,7 @@ pub fn suite(args: TokenStream, input: TokenStream) -> TokenStream { for draft in &config.drafts { let suite_tree = match loader::load_suite(&config.path, draft) { Ok(tree) => tree, - Err(e) => { - let err = e.to_string(); - return TokenStream::from(quote! { - compile_error!(#err); - }); - } + Err(e) => return compile_error_ts(e.to_string()), }; let modules = generator::generate_modules(&suite_tree, &mut functions, &config.xfail, draft); @@ -93,3 +85,73 @@ pub fn suite(args: TokenStream, input: TokenStream) -> TokenStream { } output.into() } + +/// A procedural macro that generates tests for the structured output test suite. +#[proc_macro_attribute] +pub fn output_suite(args: TokenStream, input: TokenStream) -> TokenStream { + let config = parse_macro_input!(args as testsuite::SuiteConfig); + let test_func = parse_macro_input!(input as ItemFn); + let test_func_ident = &test_func.sig.ident; + + let mut output = quote! { + #test_func + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use wasm_bindgen_test::wasm_bindgen_test; + }; + + let mut functions = HashSet::new(); + for version in &config.drafts { + let suite_tree = match output_loader::load_cases(&config.path, version) { + Ok(tree) => tree, + Err(e) => return compile_error_ts(e.to_string()), + }; + let docs = match output_loader::load_output_schema(&config.path, version) { + Ok(docs) => docs, + Err(e) => return compile_error_ts(e.to_string()), + }; + let schema_literal = docs.schema; + let remote_entries = docs.uris.iter().map(|uri| { + quote! { + testsuite::OutputRemote { uri: #uri, contents: OUTPUT_SCHEMA_JSON } + } + }); + let module_ident = format_ident!("{}", version.replace('-', "_")); + let remotes_ident = format_ident!("OUTPUT_REMOTES"); + let modules = output_generator::generate_modules( + &suite_tree, + &mut functions, + &config.xfail, + version, + &remotes_ident, + ); + output = quote! { + #output + + mod #module_ident { + use testsuite::OutputTest; + use super::#test_func_ident; + + const OUTPUT_SCHEMA_JSON: &str = #schema_literal; + const #remotes_ident: &[testsuite::OutputRemote] = &[ + #(#remote_entries),* + ]; + + #[inline] + fn inner_test(test: OutputTest) { + #test_func_ident(test); + } + + #modules + } + }; + } + + output.into() +} + +fn compile_error_ts(err: impl quote::ToTokens) -> TokenStream { + TokenStream::from(quote! { + compile_error!(#err); + }) +} diff --git a/crates/jsonschema-testsuite-codegen/src/output_generator.rs b/crates/jsonschema-testsuite-codegen/src/output_generator.rs new file mode 100644 index 000000000..13ce4b44d --- /dev/null +++ b/crates/jsonschema-testsuite-codegen/src/output_generator.rs @@ -0,0 +1,141 @@ +use crate::{idents, output_loader}; +use heck::ToSnakeCase; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use std::collections::HashSet; + +pub(crate) fn generate_modules( + tree: &output_loader::OutputCaseTree, + functions: &mut HashSet, + xfail: &[String], + version: &str, + remotes_ident: &Ident, +) -> TokenStream { + let root_path = vec![version.to_string()]; + generate_nested_structure(tree, functions, &root_path, xfail, version, remotes_ident) +} + +fn generate_nested_structure( + tree: &output_loader::OutputCaseTree, + functions: &mut HashSet, + current_path: &[String], + xfail: &[String], + version: &str, + remotes_ident: &Ident, +) -> TokenStream { + let modules = tree.iter().map(|(name, node)| { + let module_name = testsuite::sanitize_name(name.to_snake_case()); + let module_ident = format_ident!("{}", module_name); + let mut new_path = current_path.to_owned(); + new_path.push(module_name.clone()); + + match node { + output_loader::OutputCaseNode::Submodule(subtree) => { + let submodules = generate_nested_structure( + subtree, + functions, + &new_path, + xfail, + version, + remotes_ident, + ); + quote! { + mod #module_ident { + use super::*; + + #submodules + } + } + } + output_loader::OutputCaseNode::TestFile(file) => { + let mut modules = HashSet::with_capacity(file.cases.len()); + let file_display = format!("{version}/content/{}", file.relative_path); + let case_modules = file.cases.iter().map(|case| { + let base_module_name = + testsuite::sanitize_name(case.description.to_snake_case()); + let module_name = idents::get_unique(&base_module_name, &mut modules); + let module_ident = format_ident!("{}", module_name); + let mut case_path = new_path.clone(); + case_path.push(module_name.clone()); + + let schema = + serde_json::to_string(&case.schema).expect("Can't serialize JSON"); + let case_description = &case.description; + + let test_functions = case.tests.iter().map(|test| { + let base_test_name = + testsuite::sanitize_name(test.description.to_snake_case()); + let test_name = idents::get_unique(&base_test_name, functions); + let test_ident = format_ident!("test_{}", test_name); + case_path.push(test_name.clone()); + + let full_test_path = case_path.join("::"); + let should_ignore = xfail.iter().any(|x| full_test_path.starts_with(x)); + let ignore_attr = if should_ignore { + quote! { #[ignore] } + } else { + quote! {} + }; + case_path.pop().expect("Empty path"); + + let test_description = &test.description; + let data = + serde_json::to_string(&test.data).expect("Can't serialize JSON"); + let outputs = test.output.iter().map(|(format_name, schema)| { + let schema_json = + serde_json::to_string(schema).expect("Can't serialize JSON"); + quote! { + testsuite::OutputFormat { + format: #format_name, + schema: serde_json::from_str(#schema_json) + .expect("Failed to load JSON"), + } + } + }); + + quote! { + #ignore_attr + #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), test)] + #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test::wasm_bindgen_test)] + fn #test_ident() { + let test = testsuite::OutputTest { + version: #version, + file: #file_display, + schema: serde_json::from_str(#schema) + .expect("Failed to load JSON"), + case: #case_description, + description: #test_description, + data: serde_json::from_str(#data) + .expect("Failed to load JSON"), + outputs: vec![#(#outputs),*], + remotes: #remotes_ident, + }; + inner_test(test); + } + } + }); + + quote! { + mod #module_ident { + use super::*; + + #(#test_functions)* + } + } + }); + + quote! { + mod #module_ident { + use super::*; + + #(#case_modules)* + } + } + } + } + }); + + quote! { + #(#modules)* + } +} diff --git a/crates/jsonschema-testsuite-codegen/src/output_loader.rs b/crates/jsonschema-testsuite-codegen/src/output_loader.rs new file mode 100644 index 000000000..f3d1e872d --- /dev/null +++ b/crates/jsonschema-testsuite-codegen/src/output_loader.rs @@ -0,0 +1,139 @@ +use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path}; + +use serde_json::Value; +use testsuite_internal::OutputCase; +use walkdir::WalkDir; + +pub(crate) type OutputCaseTree = BTreeMap; + +#[derive(Debug)] +pub(crate) enum OutputCaseNode { + Submodule(OutputCaseTree), + TestFile(OutputTestFile), +} + +#[derive(Debug)] +pub(crate) struct OutputTestFile { + pub relative_path: String, + pub cases: Vec, +} + +#[derive(Debug)] +pub(crate) struct OutputSchemaDocuments { + pub schema: String, + pub uris: Vec, +} + +pub(crate) fn load_cases( + suite_path: &str, + version: &str, +) -> Result> { + let content_root = Path::new(suite_path).join(version).join("content"); + if !content_root.exists() { + return Err(format!("Path does not exist: {}", content_root.display()).into()); + } + let mut root = OutputCaseTree::new(); + + for entry in WalkDir::new(&content_root) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|ext| ext == "json") { + let relative_path = path.strip_prefix(&content_root)?; + let file = File::open(path)?; + let reader = BufReader::new(file); + let cases: Vec = serde_json::from_reader(reader)?; + insert_into_module_tree(&mut root, relative_path, cases)?; + } + } + + Ok(root) +} + +pub(crate) fn load_output_schema( + suite_path: &str, + version: &str, +) -> Result> { + let schema_path = Path::new(suite_path) + .join(version) + .join("output-schema.json"); + let contents = std::fs::read_to_string(&schema_path)?; + let mut parsed: Value = serde_json::from_str(&contents)?; + let mut uris = Vec::new(); + if let Some(id) = parsed.get("$id").and_then(Value::as_str) { + uris.push(id.to_string()); + } + if let Some(short) = short_reference(version) { + uris.push(short.to_string()); + } + normalize_output_schema(&mut parsed, version); + let schema = serde_json::to_string(&parsed)?; + if uris.is_empty() { + return Err( + format!("Output schema for {version} is missing both $id and short reference").into(), + ); + } + Ok(OutputSchemaDocuments { schema, uris }) +} + +fn insert_into_module_tree( + tree: &mut OutputCaseTree, + path: &Path, + cases: Vec, +) -> Result<(), Box> { + let mut current = tree; + + for component in path.parent().unwrap_or(Path::new("")).components() { + let key = component.as_os_str().to_string_lossy().into_owned(); + current = current + .entry(key) + .or_insert_with(|| OutputCaseNode::Submodule(OutputCaseTree::new())) + .submodule_mut()?; + } + + let file_stem = path + .file_stem() + .expect("Invalid filename") + .to_string_lossy() + .into_owned(); + let relative_path = normalize_path(path); + current.insert( + file_stem, + OutputCaseNode::TestFile(OutputTestFile { + relative_path, + cases, + }), + ); + + Ok(()) +} + +impl OutputCaseNode { + fn submodule_mut(&mut self) -> Result<&mut OutputCaseTree, Box> { + match self { + OutputCaseNode::Submodule(tree) => Ok(tree), + OutputCaseNode::TestFile(_) => Err("Expected a sub-module, found a test file".into()), + } + } +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn normalize_output_schema(value: &mut Value, version: &str) { + if version == "v1" || version.starts_with("v1-") { + if let Value::Object(map) = value { + map.remove("$schema"); + } + } +} + +fn short_reference(version: &str) -> Option<&'static str> { + if version == "v1" || version.starts_with("v1-") { + Some("/v1/output/schema") + } else { + None + } +} diff --git a/crates/jsonschema-testsuite-internal/src/lib.rs b/crates/jsonschema-testsuite-internal/src/lib.rs index ed3de7bbd..c12e98697 100644 --- a/crates/jsonschema-testsuite-internal/src/lib.rs +++ b/crates/jsonschema-testsuite-internal/src/lib.rs @@ -1,4 +1,5 @@ use serde_json::Value; +use std::collections::BTreeMap; /// An individual test case, containing multiple tests of a single schema's behavior. #[derive(Debug, serde::Deserialize)] @@ -18,9 +19,9 @@ pub struct InnerTest { pub description: String, /// Any additional comments about the test. pub comment: Option, - /// The instance which should be validated against the schema in schema. + /// Instance validated against the surrounding `schema`. pub data: Value, - /// Whether the validation process of this instance should consider the instance valid or not. + /// Whether the instance is expected to be valid. pub valid: bool, } @@ -32,8 +33,60 @@ pub struct Test { pub is_optional: bool, /// The test description, briefly explaining which behavior it exercises. pub description: &'static str, - /// The instance which should be validated against the schema in schema. + /// Instance validated against the surrounding `schema`. pub data: Value, - /// Whether the validation process of this instance should consider the instance valid or not. + /// Whether the instance is expected to be valid. pub valid: bool, } + +/// A test case used by the output test-suite. +#[derive(Debug, serde::Deserialize)] +pub struct OutputCase { + /// The test case description. + pub description: String, + /// A valid JSON Schema. + pub schema: Value, + /// A set of related tests all using the same schema. + pub tests: Vec, +} + +/// A single output test. +#[derive(Debug, serde::Deserialize)] +pub struct OutputInnerTest { + /// The test description. + pub description: String, + /// The instance which should be validated against the schema in schema. + pub data: Value, + /// Expected output schemas keyed by format name. + #[serde(default)] + pub output: BTreeMap, +} + +/// A single output format expectation. +#[derive(Debug)] +pub struct OutputFormat { + /// Output format name (e.g. `flag`, `list`, `hierarchical`). + pub format: &'static str, + /// Schema describing the shape of the expected output. + pub schema: Value, +} + +/// A fully materialized test entry used by generated output tests. +#[derive(Debug)] +pub struct OutputTest { + pub version: &'static str, + pub file: &'static str, + pub schema: Value, + pub case: &'static str, + pub description: &'static str, + pub data: Value, + pub outputs: Vec, + pub remotes: &'static [OutputRemote], +} + +/// Remote schema contents used by the output test-suite. +#[derive(Debug)] +pub struct OutputRemote { + pub uri: &'static str, + pub contents: &'static str, +} diff --git a/crates/jsonschema-testsuite/src/lib.rs b/crates/jsonschema-testsuite/src/lib.rs index 560528db5..a81b590a5 100644 --- a/crates/jsonschema-testsuite/src/lib.rs +++ b/crates/jsonschema-testsuite/src/lib.rs @@ -1,2 +1,2 @@ -pub use codegen::suite; -pub use internal::Test; +pub use codegen::{output_suite, suite}; +pub use internal::{OutputFormat, OutputRemote, OutputTest, Test}; diff --git a/crates/jsonschema/benches/jsonschema.rs b/crates/jsonschema/benches/jsonschema.rs index 72c17f69f..104901bfc 100644 --- a/crates/jsonschema/benches/jsonschema.rs +++ b/crates/jsonschema/benches/jsonschema.rs @@ -1,5 +1,7 @@ #[cfg(not(target_arch = "wasm32"))] mod bench { + use std::hint::black_box; + pub(crate) use benchmark::Benchmark; pub(crate) use codspeed_criterion_compat::{criterion_group, BenchmarkId, Criterion}; pub(crate) use serde_json::Value; @@ -36,11 +38,15 @@ mod bench { ); } - pub(crate) fn bench_apply(c: &mut Criterion, name: &str, schema: &Value, instance: &Value) { + pub(crate) fn bench_evaluate(c: &mut Criterion, name: &str, schema: &Value, instance: &Value) { let validator = jsonschema::validator_for(schema).expect("Valid schema"); - c.bench_with_input(BenchmarkId::new("apply", name), instance, |b, instance| { - b.iter_with_large_drop(|| validator.apply(instance).basic()); - }); + c.bench_with_input( + BenchmarkId::new("evaluate", name), + instance, + |b, instance| { + b.iter_with_large_drop(|| black_box(validator.evaluate(instance))); + }, + ); } pub(crate) fn run_benchmarks(c: &mut Criterion) { @@ -51,7 +57,7 @@ mod bench { let name = format!("{}/{}", name, instance.name); bench_is_valid(c, &name, schema, &instance.data); bench_validate(c, &name, schema, &instance.data); - bench_apply(c, &name, schema, &instance.data); + bench_evaluate(c, &name, schema, &instance.data); } }); } diff --git a/crates/jsonschema/benches/unevaluated_items.rs b/crates/jsonschema/benches/unevaluated_items.rs index f3ecd886c..aecd1b7c3 100644 --- a/crates/jsonschema/benches/unevaluated_items.rs +++ b/crates/jsonschema/benches/unevaluated_items.rs @@ -125,15 +125,19 @@ mod bench { ); } - fn bench_apply( + fn bench_evaluate( c: &mut Criterion, name: &str, validator: &jsonschema::Validator, instance: &Value, ) { - c.bench_with_input(BenchmarkId::new("apply", name), instance, |b, instance| { - b.iter_with_large_drop(|| validator.apply(instance).basic()); - }); + c.bench_with_input( + BenchmarkId::new("evaluate", name), + instance, + |b, instance| { + b.iter_with_large_drop(|| black_box(validator.evaluate(instance))); + }, + ); } fn bench_iter_errors( @@ -164,7 +168,7 @@ mod bench { bench_is_valid(c, "unevaluated_items", &validator, &invalid); bench_validate(c, "unevaluated_items", &validator, &invalid); - bench_apply(c, "unevaluated_items", &validator, &invalid); + bench_evaluate(c, "unevaluated_items", &validator, &invalid); bench_iter_errors(c, "unevaluated_items", &validator, &invalid); } diff --git a/crates/jsonschema/benches/unevaluated_properties.rs b/crates/jsonschema/benches/unevaluated_properties.rs index e128928a3..feb9dac19 100644 --- a/crates/jsonschema/benches/unevaluated_properties.rs +++ b/crates/jsonschema/benches/unevaluated_properties.rs @@ -109,15 +109,19 @@ mod bench { ); } - fn bench_apply( + fn bench_evaluate( c: &mut Criterion, name: &str, validator: &jsonschema::Validator, instance: &Value, ) { - c.bench_with_input(BenchmarkId::new("apply", name), instance, |b, instance| { - b.iter_with_large_drop(|| validator.apply(instance).basic()); - }); + c.bench_with_input( + BenchmarkId::new("evaluate", name), + instance, + |b, instance| { + b.iter_with_large_drop(|| black_box(validator.evaluate(instance))); + }, + ); } fn bench_iter_errors( @@ -148,7 +152,7 @@ mod bench { bench_is_valid(c, "unevaluated_properties", &validator, &invalid); bench_validate(c, "unevaluated_properties", &validator, &invalid); - bench_apply(c, "unevaluated_properties", &validator, &invalid); + bench_evaluate(c, "unevaluated_properties", &validator, &invalid); bench_iter_errors(c, "unevaluated_properties", &validator, &invalid); } diff --git a/crates/jsonschema/src/error.rs b/crates/jsonschema/src/error.rs index 5b1b9027f..e59613944 100644 --- a/crates/jsonschema/src/error.rs +++ b/crates/jsonschema/src/error.rs @@ -187,6 +187,49 @@ pub enum ValidationErrorKind { Referencing(referencing::Error), } +impl ValidationErrorKind { + pub(crate) fn keyword(&self) -> &'static str { + match self { + ValidationErrorKind::AdditionalItems { .. } => "additionalItems", + ValidationErrorKind::AdditionalProperties { .. } => "additionalProperties", + ValidationErrorKind::AnyOf { .. } => "anyOf", + ValidationErrorKind::BacktrackLimitExceeded { .. } + | ValidationErrorKind::Pattern { .. } => "pattern", + ValidationErrorKind::Constant { .. } => "const", + ValidationErrorKind::Contains => "contains", + ValidationErrorKind::ContentEncoding { .. } | ValidationErrorKind::FromUtf8 { .. } => { + "contentEncoding" + } + ValidationErrorKind::ContentMediaType { .. } => "contentMediaType", + ValidationErrorKind::Custom { .. } => "custom", + ValidationErrorKind::Enum { .. } => "enum", + ValidationErrorKind::ExclusiveMaximum { .. } => "exclusiveMaximum", + ValidationErrorKind::ExclusiveMinimum { .. } => "exclusiveMinimum", + ValidationErrorKind::FalseSchema => "falseSchema", + ValidationErrorKind::Format { .. } => "format", + ValidationErrorKind::MaxItems { .. } => "maxItems", + ValidationErrorKind::Maximum { .. } => "maximum", + ValidationErrorKind::MaxLength { .. } => "maxLength", + ValidationErrorKind::MaxProperties { .. } => "maxProperties", + ValidationErrorKind::MinItems { .. } => "minItems", + ValidationErrorKind::Minimum { .. } => "minimum", + ValidationErrorKind::MinLength { .. } => "minLength", + ValidationErrorKind::MinProperties { .. } => "minProperties", + ValidationErrorKind::MultipleOf { .. } => "multipleOf", + ValidationErrorKind::Not { .. } => "not", + ValidationErrorKind::OneOfMultipleValid { .. } + | ValidationErrorKind::OneOfNotValid { .. } => "oneOf", + ValidationErrorKind::PropertyNames { .. } => "propertyNames", + ValidationErrorKind::Required { .. } => "required", + ValidationErrorKind::Type { .. } => "type", + ValidationErrorKind::UnevaluatedItems { .. } => "unevaluatedItems", + ValidationErrorKind::UnevaluatedProperties { .. } => "unevaluatedProperties", + ValidationErrorKind::UniqueItems => "uniqueItems", + ValidationErrorKind::Referencing(_) => "$ref", + } + } +} + #[derive(Debug)] #[allow(missing_docs)] pub enum TypeKind { diff --git a/crates/jsonschema/src/evaluation.rs b/crates/jsonschema/src/evaluation.rs new file mode 100644 index 000000000..78752e3e8 --- /dev/null +++ b/crates/jsonschema/src/evaluation.rs @@ -0,0 +1,1681 @@ +use crate::{paths::Location, ValidationError}; +use ahash::AHashMap; +use referencing::Uri; +use serde::{ + ser::{SerializeMap, SerializeSeq, SerializeStruct}, + Serialize, +}; +use std::{fmt, sync::Arc}; + +/// Annotations associated with an output unit. +#[derive(Debug, Clone, PartialEq)] +pub struct Annotations(serde_json::Value); + +impl Annotations { + /// Create a new `Annotations` instance. + #[must_use] + pub(crate) fn new(v: serde_json::Value) -> Self { + Annotations(v) + } + + /// Returns the inner [`serde_json::Value`] of the annotation. + #[inline] + #[must_use] + pub fn into_inner(self) -> serde_json::Value { + self.0 + } + + /// The `serde_json::Value` of the annotation. + #[must_use] + pub fn value(&self) -> &serde_json::Value { + &self.0 + } +} + +impl serde::Serialize for Annotations { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +/// Description of a validation error used within evaluation outputs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorDescription { + keyword: &'static str, + message: String, +} + +impl ErrorDescription { + #[inline] + #[must_use] + pub(crate) fn new(keyword: &'static str, message: String) -> Self { + Self { keyword, message } + } + + /// Create an `ErrorDescription` from a `ValidationError`. + #[inline] + #[must_use] + pub(crate) fn from_validation_error(e: &ValidationError<'_>) -> Self { + ErrorDescription { + keyword: e.kind.keyword(), + message: e.to_string(), + } + } + + /// Returns the keyword associated with this error. + #[inline] + #[must_use] + pub fn keyword(&self) -> &'static str { + self.keyword + } + + /// Returns the inner [`String`] of the error description. + #[inline] + #[must_use] + pub fn into_inner(self) -> String { + self.message + } + + /// Returns the message of the error description. + #[inline] + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for ErrorDescription { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +#[derive(Debug, PartialEq)] +pub(crate) struct EvaluationNode { + pub(crate) keyword_location: Location, + pub(crate) absolute_keyword_location: Option>>, + pub(crate) schema_location: Arc, + pub(crate) instance_location: Location, + pub(crate) valid: bool, + pub(crate) annotations: Option, + pub(crate) dropped_annotations: Option, + pub(crate) errors: Vec, + pub(crate) children: Vec, +} + +impl EvaluationNode { + pub(crate) fn valid( + keyword_location: Location, + absolute_keyword_location: Option>>, + schema_location: impl Into>, + instance_location: Location, + annotations: Option, + children: Vec, + ) -> Self { + let schema_location = schema_location.into(); + EvaluationNode { + keyword_location, + absolute_keyword_location, + schema_location, + instance_location, + valid: true, + annotations, + dropped_annotations: None, + errors: Vec::new(), + children, + } + } + + pub(crate) fn invalid( + keyword_location: Location, + absolute_keyword_location: Option>>, + schema_location: impl Into>, + instance_location: Location, + annotations: Option, + errors: Vec, + children: Vec, + ) -> Self { + let schema_location = schema_location.into(); + EvaluationNode { + keyword_location, + absolute_keyword_location, + schema_location, + instance_location, + valid: false, + annotations: None, + dropped_annotations: annotations, + errors, + children, + } + } +} + +/// Result of evaluating a JSON instance against a schema. +/// +/// This type provides access to structured output formats as defined in the +/// [JSON Schema specification](https://json-schema.org/draft/2020-12/json-schema-core#name-output-structure). +/// +/// # Output Formats +/// +/// The evaluation result can be accessed in three standard formats: +/// +/// - **Flag**: Simple boolean validity indicator via [`flag()`](Self::flag) +/// - **List**: Flat list of all evaluation units via [`list()`](Self::list) +/// - **Hierarchical**: Nested tree structure via [`hierarchical()`](Self::hierarchical) +/// +/// All formats are serializable to JSON using `serde_json`. +/// +/// # Examples +/// +/// ```rust +/// # fn main() -> Result<(), Box> { +/// use serde_json::json; +/// +/// let schema = json!({"type": "string", "minLength": 3}); +/// let validator = jsonschema::validator_for(&schema)?; +/// +/// // Evaluate an instance +/// let instance = json!("ab"); +/// let evaluation = validator.evaluate(&instance); +/// +/// // Check validity with flag format +/// let flag = evaluation.flag(); +/// assert!(!flag.valid); +/// +/// // Get structured output as JSON +/// let list_output = serde_json::to_value(evaluation.list())?; +/// println!("{}", serde_json::to_string_pretty(&list_output)?); +/// +/// // Iterate over errors +/// for error in evaluation.iter_errors() { +/// println!("Error at {}: {}", error.instance_location, error.error); +/// } +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct Evaluation { + root: EvaluationNode, +} + +impl Evaluation { + pub(crate) fn new(root: EvaluationNode) -> Self { + Evaluation { root } + } + /// Returns the flag output format. + /// + /// This is the simplest output format, containing only a boolean indicating + /// whether the instance is valid according to the schema. + /// + /// # Examples + /// + /// ```rust + /// # fn main() -> Result<(), Box> { + /// use serde_json::json; + /// + /// let schema = json!({"type": "number"}); + /// let validator = jsonschema::validator_for(&schema)?; + /// + /// let evaluation = validator.evaluate(&json!(42)); + /// let flag = evaluation.flag(); + /// assert!(flag.valid); + /// + /// let evaluation = validator.evaluate(&json!("not a number")); + /// let flag = evaluation.flag(); + /// assert!(!flag.valid); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn flag(&self) -> FlagOutput { + FlagOutput { + valid: self.root.valid, + } + } + /// Returns the list output format. + /// + /// This format provides a flat list of all evaluation units, where each unit + /// contains information about a specific validation step including its location, + /// validity, annotations, and errors. + /// + /// # Examples + /// + /// ```rust + /// # fn main() -> Result<(), Box> { + /// use serde_json::json; + /// + /// let schema = json!({ + /// "type": "array", + /// "prefixItems": [{"type": "string"}], + /// "items": {"type": "integer"} + /// }); + /// let validator = jsonschema::validator_for(&schema)?; + /// let evaluation = validator.evaluate(&json!(["hello", "oops"])); + /// + /// assert_eq!( + /// serde_json::to_value(evaluation.list())?, + /// json!({ + /// "valid": false, + /// "details": [ + /// {"evaluationPath": "", "instanceLocation": "", "schemaLocation": "", "valid": false}, + /// { + /// "valid": false, + /// "evaluationPath": "/items", + /// "instanceLocation": "", + /// "schemaLocation": "/items", + /// "droppedAnnotations": true + /// }, + /// { + /// "valid": false, + /// "evaluationPath": "/items", + /// "instanceLocation": "/1", + /// "schemaLocation": "/items" + /// }, + /// { + /// "valid": false, + /// "evaluationPath": "/items/type", + /// "instanceLocation": "/1", + /// "schemaLocation": "/items/type", + /// "errors": {"type": "\"oops\" is not of type \"integer\""} + /// }, + /// { + /// "valid": true, + /// "evaluationPath": "/prefixItems", + /// "instanceLocation": "", + /// "schemaLocation": "/prefixItems", + /// "annotations": 0 + /// }, + /// { + /// "valid": true, + /// "evaluationPath": "/prefixItems/0", + /// "instanceLocation": "/0", + /// "schemaLocation": "/prefixItems/0" + /// }, + /// { + /// "valid": true, + /// "evaluationPath": "/prefixItems/0/type", + /// "instanceLocation": "/0", + /// "schemaLocation": "/prefixItems/0/type" + /// }, + /// { + /// "valid": true, + /// "evaluationPath": "/type", + /// "instanceLocation": "", + /// "schemaLocation": "/type" + /// } + /// ] + /// }) + /// ); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn list(&self) -> ListOutput<'_> { + ListOutput { root: &self.root } + } + /// Returns the hierarchical output format. + /// + /// This format represents the evaluation as a tree structure that mirrors the + /// schema's logical structure. Each node contains its validation result along + /// with nested child nodes representing sub-schema evaluations. + /// + /// # Examples + /// + /// ```rust + /// # fn main() -> Result<(), Box> { + /// use serde_json::json; + /// + /// let schema = json!({ + /// "type": "array", + /// "prefixItems": [{"type": "string"}], + /// "items": {"type": "integer"} + /// }); + /// let validator = jsonschema::validator_for(&schema)?; + /// let evaluation = validator.evaluate(&json!(["hello", "oops"])); + /// + /// assert_eq!( + /// serde_json::to_value(evaluation.hierarchical())?, + /// json!({ + /// "valid": false, + /// "evaluationPath": "", + /// "schemaLocation": "", + /// "instanceLocation": "", + /// "details": [ + /// { + /// "valid": false, + /// "evaluationPath": "/items", + /// "instanceLocation": "", + /// "schemaLocation": "/items", + /// "droppedAnnotations": true, + /// "details": [ + /// { + /// "valid": false, + /// "evaluationPath": "/items", + /// "instanceLocation": "/1", + /// "schemaLocation": "/items", + /// "details": [ + /// { + /// "valid": false, + /// "evaluationPath": "/items/type", + /// "instanceLocation": "/1", + /// "schemaLocation": "/items/type", + /// "errors": {"type": "\"oops\" is not of type \"integer\""} + /// } + /// ] + /// } + /// ] + /// }, + /// { + /// "valid": true, + /// "evaluationPath": "/prefixItems", + /// "instanceLocation": "", + /// "schemaLocation": "/prefixItems", + /// "annotations": 0, + /// "details": [ + /// { + /// "valid": true, + /// "evaluationPath": "/prefixItems/0", + /// "instanceLocation": "/0", + /// "schemaLocation": "/prefixItems/0", + /// "details": [ + /// { + /// "valid": true, + /// "evaluationPath": "/prefixItems/0/type", + /// "instanceLocation": "/0", + /// "schemaLocation": "/prefixItems/0/type" + /// } + /// ] + /// } + /// ] + /// }, + /// { + /// "valid": true, + /// "evaluationPath": "/type", + /// "instanceLocation": "", + /// "schemaLocation": "/type" + /// } + /// ] + /// }) + /// ); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn hierarchical(&self) -> HierarchicalOutput<'_> { + HierarchicalOutput { root: &self.root } + } + /// Returns an iterator over all annotations produced during evaluation. + /// + /// Annotations are metadata emitted by keywords during successful validation. + /// They can be used to collect information about which parts of a schema + /// matched the instance. + /// + /// # Examples + /// + /// ```rust + /// # fn main() -> Result<(), Box> { + /// use serde_json::json; + /// + /// let schema = json!({ + /// "type": "object", + /// "properties": {"name": {"type": "string"}, "age": {"type": "number", "minimum": 0}}, + /// "required": ["name"] + /// }); + /// let validator = jsonschema::validator_for(&schema)?; + /// let evaluation = validator.evaluate(&json!({"name": "Alice", "age": 30})); + /// + /// let entries: Vec<_> = evaluation.iter_annotations().collect(); + /// assert_eq!(entries.len(), 1); + /// assert_eq!(entries[0].schema_location, "/properties"); + /// assert_eq!(entries[0].instance_location.as_str(), ""); + /// assert_eq!(entries[0].annotations.value(), &json!(["age", "name"])); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn iter_annotations(&self) -> AnnotationIter<'_> { + AnnotationIter::new(&self.root) + } + /// Returns an iterator over all errors produced during evaluation. + /// + /// Each error entry contains information about a validation failure, + /// including its location in both the schema and instance. + /// + /// # Examples + /// + /// ```rust + /// # fn main() -> Result<(), Box> { + /// use serde_json::json; + /// + /// let schema = json!({ + /// "type": "object", + /// "required": ["name"], + /// "properties": { + /// "age": {"type": "number"} + /// } + /// }); + /// let validator = jsonschema::validator_for(&schema)?; + /// let evaluation = validator.evaluate(&json!({"name": "Bob", "age": "oops"})); + /// + /// let errors: Vec<_> = evaluation.iter_errors().collect(); + /// assert_eq!(errors.len(), 1); + /// assert_eq!(errors[0].schema_location, "/properties/age/type"); + /// assert_eq!(errors[0].instance_location.as_str(), "/age"); + /// assert_eq!(errors[0].error.to_string(), "\"oops\" is not of type \"number\""); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn iter_errors(&self) -> ErrorIter<'_> { + ErrorIter::new(&self.root) + } +} + +/// Flag output format containing only a validity indicator. +/// +/// This is the simplest output format defined in the JSON Schema specification. +/// It contains only a single boolean field indicating whether validation succeeded. +/// +/// # JSON Structure +/// +/// ```json +/// { +/// "valid": true +/// } +/// ``` +/// +/// # Examples +/// +/// ```rust +/// # fn main() -> Result<(), Box> { +/// use serde_json::json; +/// +/// let schema = json!({"type": "string"}); +/// let validator = jsonschema::validator_for(&schema)?; +/// let evaluation = validator.evaluate(&json!("hello")); +/// +/// let flag = evaluation.flag(); +/// assert_eq!(flag.valid, true); +/// +/// let output = serde_json::to_value(flag)?; +/// assert_eq!(output, json!({"valid": true})); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, Debug, Serialize)] +pub struct FlagOutput { + /// Whether the instance is valid according to the schema. + pub valid: bool, +} + +/// List output format providing a flat list of evaluation units. +/// +/// This format represents the evaluation result as a flat sequence where each +/// entry corresponds to a validation step. Each unit includes its evaluation path, +/// schema location, instance location, validity, and any annotations or errors. +/// +/// See [`Evaluation::list`] for an example JSON payload produced by this type. +#[derive(Debug)] +pub struct ListOutput<'a> { + root: &'a EvaluationNode, +} + +/// Hierarchical output format providing a tree structure of evaluation results. +/// +/// This format represents the evaluation as a nested tree that mirrors the logical +/// structure of the schema. Each node contains validation results and child nodes +/// representing nested sub-schema evaluations. +/// +/// See [`Evaluation::hierarchical`] for an example JSON payload produced by this type. +#[derive(Debug)] +pub struct HierarchicalOutput<'a> { + root: &'a EvaluationNode, +} + +impl Serialize for ListOutput<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serialize_list(self.root, serializer) + } +} + +impl Serialize for HierarchicalOutput<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serialize_hierarchical(self.root, serializer) + } +} + +fn serialize_list(root: &EvaluationNode, serializer: S) -> Result +where + S: serde::Serializer, +{ + let mut state = serializer.serialize_struct("ListOutput", 2)?; + state.serialize_field("valid", &root.valid)?; + let mut entries = Vec::new(); + collect_list_entries(root, &mut entries); + state.serialize_field("details", &entries)?; + state.end() +} + +fn serialize_hierarchical(root: &EvaluationNode, serializer: S) -> Result +where + S: serde::Serializer, +{ + serialize_unit(root, serializer, true) +} + +fn collect_list_entries<'a>(node: &'a EvaluationNode, out: &mut Vec>) { + // Note: The spec says "Output units which do not contain errors or annotations SHOULD be + // excluded" but the official test suite includes all nodes. We include all nodes to match + // the reference implementation and test suite expectations. + out.push(ListEntry::new(node)); + for child in &node.children { + collect_list_entries(child, out); + } +} + +fn serialize_unit( + node: &EvaluationNode, + serializer: S, + include_children: bool, +) -> Result +where + S: serde::Serializer, +{ + let mut state = serializer.serialize_struct("OutputUnit", 7)?; + state.serialize_field("valid", &node.valid)?; + state.serialize_field("evaluationPath", node.keyword_location.as_str())?; + state.serialize_field("schemaLocation", node.schema_location.as_ref())?; + state.serialize_field("instanceLocation", node.instance_location.as_str())?; + if let Some(annotations) = &node.annotations { + state.serialize_field("annotations", annotations)?; + } + if let Some(annotations) = &node.dropped_annotations { + state.serialize_field("droppedAnnotations", annotations)?; + } + if !node.errors.is_empty() { + state.serialize_field("errors", &ErrorEntriesSerializer(&node.errors))?; + } + if include_children && !node.children.is_empty() { + state.serialize_field( + "details", + &DetailsSerializer { + children: &node.children, + }, + )?; + } + state.end() +} + +pub(crate) fn format_schema_location( + location: &Location, + absolute: Option<&Arc>>, +) -> Arc { + if let Some(uri) = absolute { + let base = uri.as_str(); + if base.contains('#') { + Arc::from(base) + } else if location.as_str().is_empty() { + Arc::from(format!("{base}#")) + } else { + Arc::from(format!("{base}#{}", location.as_str())) + } + } else { + location.as_arc() + } +} + +struct ListEntry<'a> { + node: &'a EvaluationNode, +} + +impl<'a> ListEntry<'a> { + fn new(node: &'a EvaluationNode) -> Self { + ListEntry { node } + } +} + +impl Serialize for ListEntry<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serialize_unit(self.node, serializer, false) + } +} + +struct DetailsSerializer<'a> { + children: &'a [EvaluationNode], +} + +impl Serialize for DetailsSerializer<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.children.len()))?; + for child in self.children { + seq.serialize_element(&SeqEntry { node: child })?; + } + seq.end() + } +} + +struct SeqEntry<'a> { + node: &'a EvaluationNode, +} + +impl Serialize for SeqEntry<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serialize_unit(self.node, serializer, true) + } +} + +/// Entry describing annotations emitted by a keyword during evaluation. +/// +/// Annotations are metadata produced by keywords during successful validation. +/// They provide additional information about which schema keywords matched +/// and what values they produced. +/// +/// # Examples +/// +/// ```rust +/// # fn main() -> Result<(), Box> { +/// use serde_json::json; +/// +/// let schema = json!({ +/// "type": "object", +/// "properties": { +/// "name": {"type": "string"}, +/// "age": {"type": "number"} +/// } +/// }); +/// let validator = jsonschema::validator_for(&schema)?; +/// let instance = json!({"name": "Alice", "age": 30}); +/// let evaluation = validator.evaluate(&instance); +/// let entry = evaluation.iter_annotations().next().unwrap(); +/// assert_eq!(entry.schema_location, "/properties"); +/// assert_eq!(entry.instance_location.as_str(), ""); +/// assert_eq!(entry.annotations.value(), &json!(["age", "name"])); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct AnnotationEntry<'a> { + /// The JSON Pointer to the schema keyword that produced the annotation. + pub schema_location: &'a str, + /// The absolute URI of the keyword location, if available. + pub absolute_keyword_location: Option<&'a Uri>, + /// The JSON Pointer to the instance location being validated. + pub instance_location: &'a Location, + /// The annotations produced by the keyword. + pub annotations: &'a Annotations, +} + +/// Entry describing errors emitted by a keyword during evaluation. +/// +/// Error entries contain information about validation failures, including +/// the locations in both the schema and instance where the error occurred. +/// +/// # Examples +/// +/// ```rust +/// # fn main() -> Result<(), Box> { +/// use serde_json::json; +/// +/// let schema = json!({ +/// "type": "object", +/// "required": ["name"], +/// "properties": { +/// "age": {"type": "number"} +/// } +/// }); +/// let validator = jsonschema::validator_for(&schema)?; +/// let instance = json!({"age": "oops"}); +/// let evaluation = validator.evaluate(&instance); +/// let entry = evaluation.iter_errors().next().unwrap(); +/// assert_eq!(entry.schema_location, "/properties/age/type"); +/// assert_eq!(entry.instance_location.as_str(), "/age"); +/// assert_eq!(entry.error.to_string(), "\"oops\" is not of type \"number\""); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct ErrorEntry<'a> { + /// The JSON Pointer to the schema keyword that produced the error. + pub schema_location: &'a str, + /// The absolute URI of the keyword location, if available. + pub absolute_keyword_location: Option<&'a Uri>, + /// The JSON Pointer to the instance location that failed validation. + pub instance_location: &'a Location, + /// The error description. + pub error: &'a ErrorDescription, +} + +struct NodeIter<'a> { + stack: Vec<&'a EvaluationNode>, +} + +impl<'a> NodeIter<'a> { + fn new(root: &'a EvaluationNode) -> Self { + NodeIter { stack: vec![root] } + } +} + +impl<'a> Iterator for NodeIter<'a> { + type Item = &'a EvaluationNode; + + fn next(&mut self) -> Option { + let node = self.stack.pop()?; + for child in node.children.iter().rev() { + self.stack.push(child); + } + Some(node) + } +} + +/// Iterator over annotations produced during evaluation. +/// +/// This iterator traverses the evaluation tree and yields [`AnnotationEntry`] +/// for each node that produced annotations during validation. +/// +/// Annotations are only present for nodes where validation succeeded. +/// +/// # Examples +/// +/// ```rust +/// # fn main() -> Result<(), Box> { +/// use serde_json::json; +/// +/// let schema = json!({ +/// "type": "object", +/// "properties": { +/// "name": {"type": "string"}, +/// "age": {"type": "number"} +/// } +/// }); +/// let validator = jsonschema::validator_for(&schema)?; +/// let evaluation = validator.evaluate(&json!({"name": "Alice", "age": 30})); +/// +/// let annotations: Vec<_> = evaluation.iter_annotations().collect(); +/// assert_eq!(annotations.len(), 1); +/// assert_eq!(annotations[0].instance_location.as_str(), ""); +/// assert_eq!(annotations[0].annotations.value(), &json!(["age", "name"])); +/// # Ok(()) +/// # } +/// ``` +pub struct AnnotationIter<'a> { + nodes: NodeIter<'a>, +} + +impl<'a> AnnotationIter<'a> { + fn new(root: &'a EvaluationNode) -> Self { + AnnotationIter { + nodes: NodeIter::new(root), + } + } +} + +impl<'a> Iterator for AnnotationIter<'a> { + type Item = AnnotationEntry<'a>; + + fn next(&mut self) -> Option { + for node in self.nodes.by_ref() { + if let Some(annotations) = node.annotations.as_ref() { + return Some(AnnotationEntry { + schema_location: &node.schema_location, + absolute_keyword_location: node.absolute_keyword_location.as_deref(), + instance_location: &node.instance_location, + annotations, + }); + } + } + None + } +} + +/// Iterator over errors produced during evaluation. +/// +/// This iterator traverses the evaluation tree and yields [`ErrorEntry`] +/// for each error encountered during validation. +/// +/// Nodes can have multiple errors, and this iterator will yield all of them +/// in depth-first order. +/// +/// # Examples +/// +/// ```rust +/// # fn main() -> Result<(), Box> { +/// use serde_json::json; +/// +/// let schema = json!({ +/// "type": "object", +/// "required": ["name"], +/// "properties": { +/// "name": {"type": "string"}, +/// "age": {"type": "number", "minimum": 0} +/// } +/// }); +/// let validator = jsonschema::validator_for(&schema)?; +/// let evaluation = validator.evaluate(&json!({"age": -5})); +/// +/// let errors: Vec<_> = evaluation.iter_errors().collect(); +/// assert_eq!(errors.len(), 2); +/// assert_eq!(errors[0].instance_location.as_str(), "/age"); +/// assert_eq!(errors[0].schema_location, "/properties/age/minimum"); +/// assert_eq!(errors[1].schema_location, "/required"); +/// # Ok(()) +/// # } +/// ``` +pub struct ErrorIter<'a> { + nodes: NodeIter<'a>, + current: Option<(&'a EvaluationNode, usize)>, +} + +impl<'a> ErrorIter<'a> { + fn new(root: &'a EvaluationNode) -> Self { + ErrorIter { + nodes: NodeIter::new(root), + current: None, + } + } +} + +impl<'a> Iterator for ErrorIter<'a> { + type Item = ErrorEntry<'a>; + + fn next(&mut self) -> Option { + loop { + if let Some((node, idx)) = self.current { + if idx < node.errors.len() { + let entry = ErrorEntry { + schema_location: &node.schema_location, + absolute_keyword_location: node.absolute_keyword_location.as_deref(), + instance_location: &node.instance_location, + error: &node.errors[idx], + }; + self.current = Some((node, idx + 1)); + return Some(entry); + } + self.current = None; + } + + match self.nodes.next() { + Some(node) => { + if node.errors.is_empty() { + continue; + } + self.current = Some((node, 0)); + } + None => return None, + } + } + } +} + +struct ErrorEntriesSerializer<'a>(&'a [ErrorDescription]); + +impl Serialize for ErrorEntriesSerializer<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut grouped: Vec<(&'static str, Vec<&str>)> = Vec::new(); + let mut indexes: AHashMap<&'static str, usize> = AHashMap::new(); + + for error in self.0 { + let keyword = error.keyword(); + let msg = error.message(); + if let Some(&idx) = indexes.get(keyword) { + grouped[idx].1.push(msg); + } else { + indexes.insert(keyword, grouped.len()); + grouped.push((keyword, vec![msg])); + } + } + + let mut map = serializer.serialize_map(Some(grouped.len()))?; + for (keyword, messages) in grouped { + if messages.len() == 1 { + map.serialize_entry(keyword, messages[0])?; + } else { + map.serialize_entry(keyword, &messages)?; + } + } + map.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Arc; + + fn loc() -> Location { + Location::new() + } + + fn annotation(value: serde_json::Value) -> Annotations { + Annotations::new(value) + } + + impl ErrorDescription { + fn from_string(s: &str) -> Self { + ErrorDescription { + keyword: "error", + message: s.to_string(), + } + } + } + + fn leaf_with_annotation(schema: &str, ann: serde_json::Value) -> EvaluationNode { + EvaluationNode::valid( + loc(), + None, + schema.to_string(), + loc(), + Some(annotation(ann)), + Vec::new(), + ) + } + + fn leaf_with_error(schema: &str, msg: &str) -> EvaluationNode { + EvaluationNode::invalid( + loc(), + None, + schema.to_string(), + loc(), + None, + vec![ErrorDescription::from_string(msg)], + Vec::new(), + ) + } + + #[test] + fn iter_annotations_visits_all_nodes() { + let child = leaf_with_annotation("/child", json!({"k": "v"})); + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"root": true}))), + vec![child], + ); + let evaluation = Evaluation::new(root); + let entries: Vec<_> = evaluation.iter_annotations().collect(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].schema_location, "/root"); + assert_eq!(entries[1].schema_location, "/child"); + } + + #[test] + fn iter_errors_visits_all_nodes() { + let child = leaf_with_error("/child", "boom"); + let root = EvaluationNode::invalid( + loc(), + None, + "/root".to_string(), + loc(), + None, + vec![ErrorDescription::from_string("root error")], + vec![child], + ); + let evaluation = Evaluation::new(root); + let entries: Vec<_> = evaluation.iter_errors().collect(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].error.to_string(), "root error"); + assert_eq!(entries[1].error.to_string(), "boom"); + } + + #[test] + fn flag_output_valid() { + let root = EvaluationNode::valid(loc(), None, "/root".to_string(), loc(), None, Vec::new()); + let evaluation = Evaluation::new(root); + let flag = evaluation.flag(); + assert!(flag.valid); + } + + #[test] + fn flag_output_invalid() { + let root = EvaluationNode::invalid( + loc(), + None, + "/root".to_string(), + loc(), + None, + vec![ErrorDescription::from_string("error")], + Vec::new(), + ); + let evaluation = Evaluation::new(root); + let flag = evaluation.flag(); + assert!(!flag.valid); + } + + #[test] + fn flag_output_serialization() { + let root = EvaluationNode::valid(loc(), None, "/root".to_string(), loc(), None, Vec::new()); + let evaluation = Evaluation::new(root); + let flag = evaluation.flag(); + let serialized = serde_json::to_value(flag).expect("serialization succeeds"); + assert_eq!(serialized, json!({"valid": true})); + } + + #[test] + fn list_output_serialization_valid() { + let root = EvaluationNode::valid(loc(), None, "#".to_string(), loc(), None, Vec::new()); + let evaluation = Evaluation::new(root); + let list = evaluation.list(); + let serialized = serde_json::to_value(list).expect("serialization succeeds"); + assert_eq!( + serialized, + json!({ + "valid": true, + "details": [ + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "#", + "instanceLocation": "" + } + ] + }) + ); + } + + #[test] + fn list_output_serialization_with_children() { + let child1 = leaf_with_annotation("/child1", json!({"key": "value"})); + let child2 = leaf_with_error("/child2", "child error"); + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"root": true}))), + vec![child1, child2], + ); + let evaluation = Evaluation::new(root); + let list = evaluation.list(); + let serialized = serde_json::to_value(list).expect("serialization succeeds"); + assert_eq!( + serialized, + json!({ + "valid": true, + "details": [ + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "/root", + "instanceLocation": "", + "annotations": {"root": true} + }, + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "/child1", + "instanceLocation": "", + "annotations": {"key": "value"} + }, + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "/child2", + "instanceLocation": "", + "errors": {"error": "child error"} + } + ] + }) + ); + } + + #[test] + fn hierarchical_output_serialization() { + let child = leaf_with_annotation("/child", json!({"nested": "data"})); + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"root": "annotation"}))), + vec![child], + ); + let evaluation = Evaluation::new(root); + let hierarchical = evaluation.hierarchical(); + let serialized = serde_json::to_value(hierarchical).expect("serialization succeeds"); + assert_eq!( + serialized, + json!({ + "valid": true, + "evaluationPath": "", + "schemaLocation": "/root", + "instanceLocation": "", + "annotations": {"root": "annotation"}, + "details": [ + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "/child", + "instanceLocation": "", + "annotations": {"nested": "data"} + } + ] + }) + ); + } + + #[test] + fn outputs_include_errors_and_dropped_annotations() { + let invalid_child = EvaluationNode::invalid( + loc(), + None, + "/items/type".to_string(), + Location::new().join(1usize), + None, + vec![ErrorDescription::from_string("child error")], + Vec::new(), + ); + let prefix_child = leaf_with_annotation("/prefix", json!(0)); + let root = EvaluationNode::invalid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"dropped": true}))), + vec![ErrorDescription::from_string("root failure")], + vec![invalid_child, prefix_child], + ); + let evaluation = Evaluation::new(root); + let list = serde_json::to_value(evaluation.list()).expect("serialization succeeds"); + assert_eq!( + list, + json!({ + "valid": false, + "details": [ + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "/root", + "instanceLocation": "", + "droppedAnnotations": {"dropped": true}, + "errors": {"error": "root failure"} + }, + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "/items/type", + "instanceLocation": "/1", + "errors": {"error": "child error"} + }, + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "/prefix", + "instanceLocation": "", + "annotations": 0 + } + ] + }) + ); + let hierarchical = + serde_json::to_value(evaluation.hierarchical()).expect("serialization succeeds"); + assert_eq!( + hierarchical, + json!({ + "valid": false, + "evaluationPath": "", + "schemaLocation": "/root", + "instanceLocation": "", + "droppedAnnotations": {"dropped": true}, + "errors": {"error": "root failure"}, + "details": [ + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "/items/type", + "instanceLocation": "/1", + "errors": {"error": "child error"} + }, + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "/prefix", + "instanceLocation": "", + "annotations": 0 + } + ] + }) + ); + } + + #[test] + fn empty_evaluation_tree() { + let root = EvaluationNode::valid(loc(), None, "/root".to_string(), loc(), None, Vec::new()); + let evaluation = Evaluation::new(root); + + // No annotations + assert_eq!(evaluation.iter_annotations().count(), 0); + // No errors + assert_eq!(evaluation.iter_errors().count(), 0); + + let flag = evaluation.flag(); + assert!(flag.valid); + } + + #[test] + fn deep_nesting() { + // Create a deeply nested tree: root -> level1 -> level2 -> level3 + let level3 = leaf_with_annotation("/level3", json!({"level": 3})); + let level2 = EvaluationNode::valid( + loc(), + None, + "/level2".to_string(), + loc(), + Some(annotation(json!({"level": 2}))), + vec![level3], + ); + let level1 = EvaluationNode::valid( + loc(), + None, + "/level1".to_string(), + loc(), + Some(annotation(json!({"level": 1}))), + vec![level2], + ); + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"level": 0}))), + vec![level1], + ); + + let evaluation = Evaluation::new(root); + let annotations: Vec<_> = evaluation.iter_annotations().collect(); + assert_eq!(annotations.len(), 4); + + // Check depth-first order + assert_eq!(annotations[0].schema_location, "/root"); + assert_eq!(annotations[1].schema_location, "/level1"); + assert_eq!(annotations[2].schema_location, "/level2"); + assert_eq!(annotations[3].schema_location, "/level3"); + } + + #[test] + fn wide_tree() { + // Create a wide tree with many siblings + let children: Vec<_> = (0..10) + .map(|i| leaf_with_annotation(&format!("/child{i}"), json!({"index": i}))) + .collect(); + + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"root": true}))), + children, + ); + + let evaluation = Evaluation::new(root); + let annotations: Vec<_> = evaluation.iter_annotations().collect(); + assert_eq!(annotations.len(), 11); // root + 10 children + } + + #[test] + fn multiple_errors_per_node() { + let errors = vec![ + ErrorDescription::from_string("error 1"), + ErrorDescription::from_string("error 2"), + ErrorDescription::from_string("error 3"), + ]; + let root = EvaluationNode::invalid( + loc(), + None, + "/root".to_string(), + loc(), + None, + errors, + Vec::new(), + ); + + let evaluation = Evaluation::new(root); + let error_entries: Vec<_> = evaluation.iter_errors().collect(); + assert_eq!(error_entries.len(), 3); + assert_eq!(error_entries[0].error.to_string(), "error 1"); + assert_eq!(error_entries[1].error.to_string(), "error 2"); + assert_eq!(error_entries[2].error.to_string(), "error 3"); + } + + #[test] + fn mixed_valid_and_invalid_nodes() { + let valid_child = leaf_with_annotation("/valid", json!({"ok": true})); + let invalid_child = leaf_with_error("/invalid", "failed"); + + let root = EvaluationNode::invalid( + loc(), + None, + "/root".to_string(), + loc(), + Some(annotation(json!({"attempted": true}))), + vec![ErrorDescription::from_string("root failed")], + vec![valid_child, invalid_child], + ); + + let evaluation = Evaluation::new(root); + + // Should have 1 annotation (from valid child only; root has dropped annotations) + let annotations: Vec<_> = evaluation.iter_annotations().collect(); + assert_eq!(annotations.len(), 1); + assert_eq!(annotations[0].schema_location, "/valid"); + + // Should have 2 errors (root + invalid child) + let errors: Vec<_> = evaluation.iter_errors().collect(); + assert_eq!(errors.len(), 2); + } + + #[test] + fn annotations_iterator_skips_nodes_without_annotations() { + let no_annotation = + EvaluationNode::valid(loc(), None, "/no_ann".to_string(), loc(), None, Vec::new()); + let with_annotation = leaf_with_annotation("/with_ann", json!({"present": true})); + + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + None, + vec![no_annotation, with_annotation], + ); + + let evaluation = Evaluation::new(root); + let annotations: Vec<_> = evaluation.iter_annotations().collect(); + assert_eq!(annotations.len(), 1); + assert_eq!(annotations[0].schema_location, "/with_ann"); + } + + #[test] + fn errors_iterator_skips_nodes_without_errors() { + let no_error = EvaluationNode::valid( + loc(), + None, + "/no_error".to_string(), + loc(), + Some(annotation(json!({"ok": true}))), + Vec::new(), + ); + let with_error = leaf_with_error("/with_error", "failed"); + + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + None, + vec![no_error, with_error], + ); + + let evaluation = Evaluation::new(root); + let errors: Vec<_> = evaluation.iter_errors().collect(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].schema_location, "/with_error"); + } + + #[test] + fn error_entries_serialization_empty() { + let entries = ErrorEntriesSerializer(&[]); + let serialized = serde_json::to_value(&entries).expect("serialization succeeds"); + assert!(serialized.is_object()); + assert_eq!(serialized.as_object().unwrap().len(), 0); + } + + #[test] + fn error_entries_serialization_single() { + let errors = vec![ErrorDescription::from_string("test error")]; + let entries = ErrorEntriesSerializer(&errors); + let serialized = serde_json::to_value(&entries).expect("serialization succeeds"); + assert!(serialized.is_object()); + assert_eq!(serialized.as_object().unwrap().len(), 1); + assert!(serialized.get("error").is_some()); + } + + #[test] + fn error_entries_serialization_multiple() { + let errors = vec![ + ErrorDescription::new("alpha", "error 1".to_string()), + ErrorDescription::new("beta", "error 2".to_string()), + ErrorDescription::new("gamma", "error 3".to_string()), + ]; + let entries = ErrorEntriesSerializer(&errors); + let serialized = serde_json::to_value(&entries).expect("serialization succeeds"); + assert_eq!(serialized.as_object().unwrap().len(), 3); + assert!(serialized.get("alpha").is_some()); + assert!(serialized.get("beta").is_some()); + assert!(serialized.get("gamma").is_some()); + } + + #[test] + fn error_entries_serialization_preserves_duplicates() { + let errors = vec![ + ErrorDescription::new("required", "\"foo\" is required".to_string()), + ErrorDescription::new("required", "\"bar\" is required".to_string()), + ]; + let entries = ErrorEntriesSerializer(&errors); + let serialized = serde_json::to_value(&entries).expect("serialization succeeds"); + let value = serialized + .get("required") + .expect("required keyword present") + .as_array() + .expect("multiple errors serialized as array"); + assert_eq!(value.len(), 2); + assert_eq!(value[0], "\"foo\" is required"); + assert_eq!(value[1], "\"bar\" is required"); + } + + #[test] + fn list_output_preserves_multiple_errors_per_keyword() { + let errors = vec![ + ErrorDescription::new("required", "\"foo\" is required".to_string()), + ErrorDescription::new("required", "\"bar\" is required".to_string()), + ]; + let root = EvaluationNode::invalid( + loc(), + None, + "/required".to_string(), + loc(), + None, + errors, + Vec::new(), + ); + + let evaluation = Evaluation::new(root); + let list = serde_json::to_value(evaluation.list()).expect("serialization succeeds"); + let root_unit = list + .get("details") + .and_then(|value| value.as_array()) + .and_then(|details| details.first()) + .expect("list output contains root unit"); + let errors = root_unit + .get("errors") + .and_then(|errors| errors.get("required")) + .and_then(|value| value.as_array()) + .expect("errors serialized as array"); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0], "\"foo\" is required"); + assert_eq!(errors[1], "\"bar\" is required"); + } + + #[test] + fn format_schema_location_without_absolute() { + let location = Location::new().join("properties").join("name"); + let formatted = format_schema_location(&location, None); + assert_eq!(formatted.as_ref(), "/properties/name"); + } + + #[test] + fn format_schema_location_with_absolute_no_fragment() { + let location = Location::new().join("properties"); + let uri = Arc::new( + Uri::parse("http://example.com/schema.json") + .unwrap() + .to_owned(), + ); + let formatted = format_schema_location(&location, Some(&uri)); + assert_eq!( + formatted.as_ref(), + "http://example.com/schema.json#/properties" + ); + } + + #[test] + fn format_schema_location_with_absolute_empty_location() { + let location = Location::new(); + let uri = Arc::new( + Uri::parse("http://example.com/schema.json") + .unwrap() + .to_owned(), + ); + let formatted = format_schema_location(&location, Some(&uri)); + assert_eq!(formatted.as_ref(), "http://example.com/schema.json#"); + } + + #[test] + fn format_schema_location_with_absolute_existing_fragment() { + let location = Location::new().join("properties"); + let uri = Arc::new( + Uri::parse("http://example.com/schema.json#/defs/myDef") + .unwrap() + .to_owned(), + ); + let formatted = format_schema_location(&location, Some(&uri)); + // When URI already contains a fragment, use it as-is + assert_eq!( + formatted.as_ref(), + "http://example.com/schema.json#/defs/myDef" + ); + } + + #[test] + fn dropped_annotations_on_invalid_node() { + let annotations = Some(annotation(json!({"dropped": true}))); + let root = EvaluationNode::invalid( + loc(), + None, + "/root".to_string(), + loc(), + annotations.clone(), + vec![ErrorDescription::from_string("failed")], + Vec::new(), + ); + + assert!(!root.valid); + assert!(root.annotations.is_none()); + assert!(root.dropped_annotations.is_some()); + assert_eq!( + root.dropped_annotations.as_ref().unwrap(), + annotations.as_ref().unwrap() + ); + } + + #[test] + fn valid_node_has_no_dropped_annotations() { + let annotations = Some(annotation(json!({"kept": true}))); + let root = EvaluationNode::valid( + loc(), + None, + "/root".to_string(), + loc(), + annotations.clone(), + Vec::new(), + ); + + assert!(root.valid); + assert!(root.annotations.is_some()); + assert!(root.dropped_annotations.is_none()); + assert_eq!( + root.annotations.as_ref().unwrap(), + annotations.as_ref().unwrap() + ); + } + + #[test] + fn absolute_keyword_location_populated_with_id() { + use serde_json::json; + + // Schema with $id should populate absoluteKeywordLocation + let schema = json!({ + "$id": "https://example.com/schema", + "type": "object", + "properties": { + "name": {"type": "string"} + } + }); + + let validator = crate::validator_for(&schema).expect("schema compiles"); + let evaluation = validator.evaluate(&json!({"name": "test"})); + + // Verify that absoluteKeywordLocation is populated for nodes + let annotations: Vec<_> = evaluation.iter_annotations().collect(); + assert!(!annotations.is_empty()); + + // At least one annotation should have an absolute keyword location + let with_absolute = annotations + .iter() + .filter(|a| a.absolute_keyword_location.is_some()) + .count(); + + assert!(with_absolute > 0); + + // Verify the absolute locations start with the schema's $id + for annotation in annotations + .iter() + .filter(|a| a.absolute_keyword_location.is_some()) + { + let uri_str = annotation.absolute_keyword_location.unwrap().as_str(); + assert!(uri_str.starts_with("https://example.com/schema")); + } + } + + #[test] + fn annotations_value_returns_reference() { + let expected = json!({"key": "value"}); + let annotations = Annotations::new(expected.clone()); + + // value() should return a reference to the inner value + assert_eq!(annotations.value(), &expected); + } + + #[test] + fn annotations_into_inner_consumes_and_returns_value() { + let expected = json!({"key": "value", "nested": {"array": [1, 2, 3]}}); + let annotations = Annotations::new(expected.clone()); + + // into_inner() should consume self and return the owned value + let inner = annotations.into_inner(); + assert_eq!(inner, expected); + } + + #[test] + fn error_description_into_inner_consumes_and_returns_message() { + let expected_message = "test error message"; + let error = ErrorDescription::from_string(expected_message); + + // into_inner() should consume self and return the owned message + let message = error.into_inner(); + assert_eq!(message, expected_message); + } +} diff --git a/crates/jsonschema/src/keywords/additional_properties.rs b/crates/jsonschema/src/keywords/additional_properties.rs index ad7ad5a92..54953aa58 100644 --- a/crates/jsonschema/src/keywords/additional_properties.rs +++ b/crates/jsonschema/src/keywords/additional_properties.rs @@ -9,10 +9,10 @@ use crate::{ compiler, error::{no_error, ErrorIterator, ValidationError}, + evaluation::{format_schema_location, Annotations, ErrorDescription, EvaluationNode}, keywords::CompilationResult, node::SchemaNode, options::PatternEngineOptions, - output::{Annotations, BasicOutput, OutputUnit}, paths::{LazyLocation, Location}, properties::{ are_properties_valid, compile_big_map, compile_dynamic_prop_map_validator, @@ -21,7 +21,7 @@ use crate::{ }, regex::RegexEngine, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use referencing::Uri; use serde_json::{Map, Value}; @@ -131,20 +131,23 @@ impl Validate for AdditionalPropertiesValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { - let mut matched_props = Vec::with_capacity(item.len()); - let mut output = BasicOutput::default(); + let mut children = Vec::with_capacity(item.len()); for (name, value) in item { let path = location.push(name.as_str()); - output += self.node.apply_rooted(value, &path); - matched_props.push(name.clone()); + children.push(self.node.evaluate_instance(value, &path)); } - let mut result: PartialApplication = output.into(); - result.annotate(Value::from(matched_props).into()); + let mut result = EvaluationResult::from_children(children); + let annotated_props = item + .keys() + .cloned() + .map(serde_json::Value::String) + .collect(); + result.annotate(Annotations::new(serde_json::Value::Array(annotated_props))); result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -302,33 +305,32 @@ impl Validate for AdditionalPropertiesNotEmptyFalseV Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { let mut unexpected = Vec::with_capacity(item.len()); - let mut output = BasicOutput::default(); + let mut children = Vec::with_capacity(item.len()); for (property, value) in item { if let Some((_name, node)) = self.properties.get_key_validator(property) { let path = location.push(property.as_str()); - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } else { unexpected.push(property.clone()); } } - let mut result: PartialApplication = output.into(); + let mut result = EvaluationResult::from_children(children); if !unexpected.is_empty() { - result.mark_errored( - ValidationError::additional_properties( + result.mark_errored(ErrorDescription::from_validation_error( + &ValidationError::additional_properties( self.location.clone(), location.into(), instance, unexpected, - ) - .into(), - ); + ), + )); } result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -431,28 +433,28 @@ impl Validate for AdditionalPropertiesNotEmptyValida Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(map) = instance { let mut matched_propnames = Vec::with_capacity(map.len()); - let mut output = BasicOutput::default(); + let mut children = Vec::with_capacity(map.len()); for (property, value) in map { let path = location.push(property.as_str()); if let Some((_name, property_validators)) = self.properties.get_key_validator(property) { - output += property_validators.apply_rooted(value, &path); + children.push(property_validators.evaluate_instance(value, &path)); } else { - output += self.node.apply_rooted(value, &path); + children.push(self.node.evaluate_instance(value, &path)); matched_propnames.push(property.clone()); } } - let mut result: PartialApplication = output.into(); + let mut result = EvaluationResult::from_children(children); if !matched_propnames.is_empty() { - result.annotate(Value::from(matched_propnames).into()); + result.annotate(Annotations::new(Value::from(matched_propnames))); } result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -555,11 +557,11 @@ impl Validate for AdditionalPropertiesWithPatternsValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { - let mut output = BasicOutput::default(); let mut pattern_matched_propnames = Vec::with_capacity(item.len()); let mut additional_matched_propnames = Vec::with_capacity(item.len()); + let mut children = Vec::with_capacity(item.len()); for (property, value) in item { let path = location.push(property.as_str()); let mut has_match = false; @@ -567,30 +569,36 @@ impl Validate for AdditionalPropertiesWithPatternsValidator { if pattern.is_match(property).unwrap_or(false) { has_match = true; pattern_matched_propnames.push(property.clone()); - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } if !has_match { additional_matched_propnames.push(property.clone()); - output += self.node.apply_rooted(value, &path); + children.push(self.node.evaluate_instance(value, &path)); } } if !pattern_matched_propnames.is_empty() { - output += OutputUnit::::annotations( + let annotation = Annotations::new(Value::from(pattern_matched_propnames)); + let schema_location = format_schema_location( + &self.pattern_keyword_path, + self.pattern_keyword_absolute_location.as_ref(), + ); + children.push(EvaluationNode::valid( self.pattern_keyword_path.clone(), - location.into(), self.pattern_keyword_absolute_location.clone(), - Value::from(pattern_matched_propnames).into(), - ) - .into(); + schema_location, + location.into(), + Some(annotation), + Vec::new(), + )); } - let mut result: PartialApplication = output.into(); + let mut result = EvaluationResult::from_children(children); if !additional_matched_propnames.is_empty() { - result.annotate(Value::from(additional_matched_propnames).into()); + result.annotate(Annotations::new(Value::from(additional_matched_propnames))); } result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -695,11 +703,11 @@ impl Validate for AdditionalPropertiesWithPatternsFalseValidator Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { - let mut output = BasicOutput::default(); let mut unexpected = Vec::with_capacity(item.len()); let mut pattern_matched_props = Vec::with_capacity(item.len()); + let mut children = Vec::with_capacity(item.len()); for (property, value) in item { let path = location.push(property.as_str()); let mut has_match = false; @@ -707,7 +715,7 @@ impl Validate for AdditionalPropertiesWithPatternsFalseValidator if pattern.is_match(property).unwrap_or(false) { has_match = true; pattern_matched_props.push(property.clone()); - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } if !has_match { @@ -715,29 +723,34 @@ impl Validate for AdditionalPropertiesWithPatternsFalseValidator } } if !pattern_matched_props.is_empty() { - output += OutputUnit::::annotations( + let annotation = Annotations::new(Value::from(pattern_matched_props)); + let schema_location = format_schema_location( + &self.pattern_keyword_path, + self.pattern_keyword_absolute_location.as_ref(), + ); + children.push(EvaluationNode::valid( self.pattern_keyword_path.clone(), - location.into(), self.pattern_keyword_absolute_location.clone(), - Value::from(pattern_matched_props).into(), - ) - .into(); + schema_location, + location.into(), + Some(annotation), + Vec::new(), + )); } - let mut result: PartialApplication = output.into(); + let mut result = EvaluationResult::from_children(children); if !unexpected.is_empty() { - result.mark_errored( - ValidationError::additional_properties( + result.mark_errored(ErrorDescription::from_validation_error( + &ValidationError::additional_properties( self.location.clone(), location.into(), instance, unexpected, - ) - .into(), - ); + ), + )); } result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -881,17 +894,17 @@ impl Validate Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { - let mut output = BasicOutput::default(); let mut additional_matches = Vec::with_capacity(item.len()); + let mut children = Vec::with_capacity(item.len()); for (property, value) in item { let path = location.push(property.as_str()); if let Some((_name, node)) = self.properties.get_key_validator(property) { - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); for (pattern, node) in &self.patterns { if pattern.is_match(property).unwrap_or(false) { - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } } else { @@ -899,20 +912,20 @@ impl Validate for (pattern, node) in &self.patterns { if pattern.is_match(property).unwrap_or(false) { has_match = true; - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } if !has_match { additional_matches.push(property.clone()); - output += self.node.apply_rooted(value, &path); + children.push(self.node.evaluate_instance(value, &path)); } } } - let mut result: PartialApplication = output.into(); - result.annotate(Value::from(additional_matches).into()); + let mut result = EvaluationResult::from_children(children); + result.annotate(Annotations::new(Value::from(additional_matches))); result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -1062,18 +1075,18 @@ impl Validate Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { - let mut output = BasicOutput::default(); let mut unexpected = vec![]; + let mut children = Vec::with_capacity(item.len()); // No properties are allowed, except ones defined in `properties` or `patternProperties` for (property, value) in item { let path = location.push(property.as_str()); if let Some((_name, node)) = self.properties.get_key_validator(property) { - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); for (pattern, node) in &self.patterns { if pattern.is_match(property).unwrap_or(false) { - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } } else { @@ -1081,7 +1094,7 @@ impl Validate for (pattern, node) in &self.patterns { if pattern.is_match(property).unwrap_or(false) { has_match = true; - output += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } if !has_match { @@ -1089,21 +1102,20 @@ impl Validate } } } - let mut result: PartialApplication = output.into(); + let mut result = EvaluationResult::from_children(children); if !unexpected.is_empty() { - result.mark_errored( - ValidationError::additional_properties( + result.mark_errored(ErrorDescription::from_validation_error( + &ValidationError::additional_properties( self.location.clone(), location.into(), instance, unexpected, - ) - .into(), - ); + ), + )); } result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } diff --git a/crates/jsonschema/src/keywords/all_of.rs b/crates/jsonschema/src/keywords/all_of.rs index fccadec3a..67cbb9e13 100644 --- a/crates/jsonschema/src/keywords/all_of.rs +++ b/crates/jsonschema/src/keywords/all_of.rs @@ -2,10 +2,9 @@ use crate::{ compiler, error::{ErrorIterator, ValidationError}, node::SchemaNode, - output::BasicOutput, paths::{LazyLocation, Location}, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -58,12 +57,13 @@ impl Validate for AllOfValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - self.schemas + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + let children = self + .schemas .iter() - .map(move |node| node.apply_rooted(instance, location)) - .sum::() - .into() + .map(move |node| node.evaluate_instance(instance, location)) + .collect(); + EvaluationResult::from_children(children) } } @@ -98,8 +98,8 @@ impl Validate for SingleValueAllOfValidator { self.node.validate(instance, location) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - self.node.apply_rooted(instance, location).into() + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + EvaluationResult::from(self.node.evaluate_instance(instance, location)) } } diff --git a/crates/jsonschema/src/keywords/any_of.rs b/crates/jsonschema/src/keywords/any_of.rs index e223e4cbd..9f93ac325 100644 --- a/crates/jsonschema/src/keywords/any_of.rs +++ b/crates/jsonschema/src/keywords/any_of.rs @@ -4,7 +4,7 @@ use crate::{ node::SchemaNode, paths::{LazyLocation, Location}, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -82,22 +82,27 @@ impl Validate for AnyOfValidator { } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - let mut successes = Vec::new(); - let mut failures = Vec::new(); - for node in &self.schemas { - let result = node.apply_rooted(instance, location); - if result.is_valid() { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + let total = self.schemas.len(); + let mut failures = Vec::with_capacity(total); + let mut iter = self.schemas.iter(); + while let Some(node) = iter.next() { + let result = node.evaluate_instance(instance, location); + if result.valid { + let remaining = total.saturating_sub(failures.len() + 1); + let mut successes = Vec::with_capacity(remaining + 1); successes.push(result); - } else { - failures.push(result); + for node in iter { + let tail = node.evaluate_instance(instance, location); + if tail.valid { + successes.push(tail); + } + } + return EvaluationResult::from_children(successes); } + failures.push(result); } - if successes.is_empty() { - failures.into_iter().collect() - } else { - successes.into_iter().collect() - } + EvaluationResult::from_children(failures) } } diff --git a/crates/jsonschema/src/keywords/contains.rs b/crates/jsonschema/src/keywords/contains.rs index c6f1ea3ff..275706349 100644 --- a/crates/jsonschema/src/keywords/contains.rs +++ b/crates/jsonschema/src/keywords/contains.rs @@ -1,10 +1,11 @@ use crate::{ compiler, error::ValidationError, + evaluation::{Annotations, ErrorDescription}, keywords::CompilationResult, node::SchemaNode, paths::LazyLocation, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, Draft, }; use serde_json::{Map, Value}; @@ -53,35 +54,39 @@ impl Validate for ContainsValidator { } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Array(items) = instance { let mut results = Vec::with_capacity(items.len()); - let mut indices = Vec::new(); + let mut indices = Vec::with_capacity(items.len()); for (idx, item) in items.iter().enumerate() { let path = location.push(idx); - let result = self.node.apply_rooted(item, &path); - if result.is_valid() { + let result = self.node.evaluate_instance(item, &path); + if result.valid { indices.push(idx); results.push(result); } } - let mut result: PartialApplication = results.into_iter().collect(); if indices.is_empty() { - result.mark_errored( - ValidationError::contains( - self.node.location().clone(), - location.into(), - instance, - ) - .into(), - ); + EvaluationResult::Invalid { + errors: vec![ErrorDescription::from_validation_error( + &ValidationError::contains( + self.node.location().clone(), + location.into(), + instance, + ), + )], + children: Vec::new(), + annotations: None, + } } else { - result.annotate(Value::from(indices).into()); + EvaluationResult::Valid { + annotations: Some(Annotations::new(Value::from(indices))), + children: results, + } } - result } else { - let mut result = PartialApplication::valid_empty(); - result.annotate(Value::Array(Vec::new()).into()); + let mut result = EvaluationResult::valid_empty(); + result.annotate(Annotations::new(Value::Array(Vec::new()))); result } } diff --git a/crates/jsonschema/src/keywords/dependencies.rs b/crates/jsonschema/src/keywords/dependencies.rs index 36b01cdf5..d9d3f9bfa 100644 --- a/crates/jsonschema/src/keywords/dependencies.rs +++ b/crates/jsonschema/src/keywords/dependencies.rs @@ -5,7 +5,7 @@ use crate::{ node::SchemaNode, paths::{LazyLocation, Location}, types::JsonType, - validator::Validate, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -89,6 +89,20 @@ impl Validate for DependenciesValidator { } Ok(()) } + + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + if let Value::Object(item) = instance { + let mut children = Vec::new(); + for (property, dependency) in &self.dependencies { + if item.contains_key(property) { + children.push(dependency.evaluate_instance(instance, location)); + } + } + EvaluationResult::from_children(children) + } else { + EvaluationResult::valid_empty() + } + } } pub(crate) struct DependentRequiredValidator { @@ -180,6 +194,20 @@ impl Validate for DependentRequiredValidator { Ok(()) } } + + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + if let Value::Object(item) = instance { + let mut children = Vec::new(); + for (property, dependency) in &self.dependencies { + if item.contains_key(property) { + children.push(dependency.evaluate_instance(instance, location)); + } + } + EvaluationResult::from_children(children) + } else { + EvaluationResult::valid_empty() + } + } } pub(crate) struct DependentSchemasValidator { @@ -248,6 +276,20 @@ impl Validate for DependentSchemasValidator { Ok(()) } } + + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + if let Value::Object(item) = instance { + let mut children = Vec::new(); + for (property, dependency) in &self.dependencies { + if item.contains_key(property) { + children.push(dependency.evaluate_instance(instance, location)); + } + } + EvaluationResult::from_children(children) + } else { + EvaluationResult::valid_empty() + } + } } #[inline] diff --git a/crates/jsonschema/src/keywords/if_.rs b/crates/jsonschema/src/keywords/if_.rs index 81b73faa0..b0dadf2fd 100644 --- a/crates/jsonschema/src/keywords/if_.rs +++ b/crates/jsonschema/src/keywords/if_.rs @@ -4,7 +4,7 @@ use crate::{ keywords::CompilationResult, node::SchemaNode, paths::LazyLocation, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, ValidationError, }; use serde_json::{Map, Value}; @@ -65,14 +65,13 @@ impl Validate for IfThenValidator { } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - let mut if_result = self.schema.apply_rooted(instance, location); - if if_result.is_valid() { - let then_result = self.then_schema.apply_rooted(instance, location); - if_result += then_result; - if_result.into() + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + let if_node = self.schema.evaluate_instance(instance, location); + if if_node.valid { + let then_node = self.then_schema.evaluate_instance(instance, location); + EvaluationResult::from_children(vec![if_node, then_node]) } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -133,12 +132,13 @@ impl Validate for IfElseValidator { } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - let if_result = self.schema.apply_rooted(instance, location); - if if_result.is_valid() { - if_result.into() + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + let if_node = self.schema.evaluate_instance(instance, location); + if if_node.valid { + EvaluationResult::from_children(vec![if_node]) } else { - self.else_schema.apply_rooted(instance, location).into() + let else_node = self.else_schema.evaluate_instance(instance, location); + EvaluationResult::from_children(vec![else_node]) } } } @@ -206,13 +206,14 @@ impl Validate for IfThenElseValidator { } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - let mut if_result = self.schema.apply_rooted(instance, location); - if if_result.is_valid() { - if_result += self.then_schema.apply_rooted(instance, location); - if_result.into() + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + let if_node = self.schema.evaluate_instance(instance, location); + if if_node.valid { + let then_node = self.then_schema.evaluate_instance(instance, location); + EvaluationResult::from_children(vec![if_node, then_node]) } else { - self.else_schema.apply_rooted(instance, location).into() + let else_node = self.else_schema.evaluate_instance(instance, location); + EvaluationResult::from_children(vec![else_node]) } } } diff --git a/crates/jsonschema/src/keywords/items.rs b/crates/jsonschema/src/keywords/items.rs index ced6264f3..10515e846 100644 --- a/crates/jsonschema/src/keywords/items.rs +++ b/crates/jsonschema/src/keywords/items.rs @@ -1,10 +1,11 @@ use crate::{ compiler, error::{no_error, ErrorIterator}, + evaluation::Annotations, keywords::CompilationResult, node::SchemaNode, paths::LazyLocation, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, ValidationError, }; use serde_json::{Map, Value}; @@ -68,6 +69,19 @@ impl Validate for ItemsArrayValidator { } Ok(()) } + + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + if let Value::Array(items) = instance { + let mut children = Vec::with_capacity(self.items.len().min(items.len())); + for (idx, (item, node)) in items.iter().zip(self.items.iter()).enumerate() { + let child_location = location.push(idx); + children.push(node.evaluate_instance(item, &child_location)); + } + EvaluationResult::from_children(children) + } else { + EvaluationResult::valid_empty() + } + } } pub(crate) struct ItemsObjectValidator { @@ -119,24 +133,24 @@ impl Validate for ItemsObjectValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Array(items) = instance { - let mut results = Vec::with_capacity(items.len()); + let mut children = Vec::with_capacity(items.len()); for (idx, item) in items.iter().enumerate() { let path = location.push(idx); - results.push(self.node.apply_rooted(item, &path)); + children.push(self.node.evaluate_instance(item, &path)); } - let mut output: PartialApplication = results.into_iter().collect(); // Per draft 2020-12 section https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.10.3.1.2 // we must produce an annotation with a boolean value indicating whether the subschema // was applied to any positions in the underlying array. Since the struct // `ItemsObjectValidator` is not used when prefixItems is defined, this is true if // there are any items in the instance. let schema_was_applied = !items.is_empty(); - output.annotate(serde_json::json!(schema_was_applied).into()); - output + let mut result = EvaluationResult::from_children(children); + result.annotate(Annotations::new(serde_json::json!(schema_was_applied))); + result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -206,22 +220,22 @@ impl Validate for ItemsObjectSkipPrefixValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Array(items) = instance { - let mut results = Vec::with_capacity(items.len().saturating_sub(self.skip_prefix)); + let mut children = Vec::with_capacity(items.len().saturating_sub(self.skip_prefix)); for (idx, item) in items.iter().enumerate().skip(self.skip_prefix) { let path = location.push(idx); - results.push(self.node.apply_rooted(item, &path)); + children.push(self.node.evaluate_instance(item, &path)); } - let mut output: PartialApplication = results.into_iter().collect(); // Per draft 2020-12 section https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.10.3.1.2 // we must produce an annotation with a boolean value indicating whether the subschema // was applied to any positions in the underlying array. let schema_was_applied = items.len() > self.skip_prefix; - output.annotate(serde_json::json!(schema_was_applied).into()); - output + let mut result = EvaluationResult::from_children(children); + result.annotate(Annotations::new(serde_json::json!(schema_was_applied))); + result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } diff --git a/crates/jsonschema/src/keywords/one_of.rs b/crates/jsonschema/src/keywords/one_of.rs index e38d696ab..84ae3483a 100644 --- a/crates/jsonschema/src/keywords/one_of.rs +++ b/crates/jsonschema/src/keywords/one_of.rs @@ -1,12 +1,12 @@ use crate::{ compiler, error::ValidationError, + evaluation::ErrorDescription, keywords::CompilationResult, node::SchemaNode, - output::BasicOutput, paths::{LazyLocation, Location}, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -99,24 +99,39 @@ impl Validate for OneOfValidator { )) } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - let mut failures = Vec::new(); - let mut successes = Vec::new(); - for node in &self.schemas { - match node.apply_rooted(instance, location) { - output @ BasicOutput::Valid(..) => successes.push(output), - output @ BasicOutput::Invalid(..) => failures.push(output), + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + let total = self.schemas.len(); + let mut failures = Vec::with_capacity(total); + let mut iter = self.schemas.iter(); + while let Some(node) = iter.next() { + let child = node.evaluate_instance(instance, location); + if child.valid { + let mut successes = Vec::with_capacity(total.saturating_sub(failures.len())); + successes.push(child); + for node in iter { + let next = node.evaluate_instance(instance, location); + if next.valid { + successes.push(next); + } + } + return match successes.len() { + 1 => EvaluationResult::from(successes.remove(0)), + _ => EvaluationResult::Invalid { + errors: vec![ErrorDescription::new( + "oneOf", + "more than one subschema succeeded".to_string(), + )], + children: successes, + annotations: None, + }, + }; } + failures.push(child); } - if successes.len() == 1 { - let success = successes.remove(0); - success.into() - } else if successes.len() > 1 { - PartialApplication::invalid_empty(vec!["more than one subschema succeeded".into()]) - } else if !failures.is_empty() { - failures.into_iter().sum::().into() - } else { - unreachable!("compilation should fail for oneOf with no subschemas") + EvaluationResult::Invalid { + errors: Vec::new(), + children: failures, + annotations: None, } } } diff --git a/crates/jsonschema/src/keywords/pattern_properties.rs b/crates/jsonschema/src/keywords/pattern_properties.rs index a42ff0a80..935255e4c 100644 --- a/crates/jsonschema/src/keywords/pattern_properties.rs +++ b/crates/jsonschema/src/keywords/pattern_properties.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use crate::{ compiler, error::{no_error, ErrorIterator, ValidationError}, + evaluation::Annotations, keywords::CompilationResult, node::SchemaNode, options::PatternEngineOptions, - output::BasicOutput, paths::{LazyLocation, Location}, regex::RegexEngine, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -70,24 +70,24 @@ impl Validate for PatternPropertiesValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { let mut matched_propnames = Vec::with_capacity(item.len()); - let mut sub_results = BasicOutput::default(); + let mut children = Vec::new(); for (pattern, node) in &self.patterns { for (key, value) in item { if pattern.is_match(key).unwrap_or(false) { let path = location.push(key.as_str()); matched_propnames.push(key.clone()); - sub_results += node.apply_rooted(value, &path); + children.push(node.evaluate_instance(value, &path)); } } } - let mut result: PartialApplication = sub_results.into(); - result.annotate(Value::from(matched_propnames).into()); + let mut result = EvaluationResult::from_children(children); + result.annotate(Annotations::new(Value::from(matched_propnames))); result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } @@ -140,22 +140,22 @@ impl Validate for SingleValuePatternPropertiesValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { let mut matched_propnames = Vec::with_capacity(item.len()); - let mut outputs = BasicOutput::default(); + let mut children = Vec::new(); for (key, value) in item { if self.regex.is_match(key).unwrap_or(false) { let path = location.push(key.as_str()); matched_propnames.push(key.clone()); - outputs += self.node.apply_rooted(value, &path); + children.push(self.node.evaluate_instance(value, &path)); } } - let mut result: PartialApplication = outputs.into(); - result.annotate(Value::from(matched_propnames).into()); + let mut result = EvaluationResult::from_children(children); + result.annotate(Annotations::new(Value::from(matched_propnames))); result } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } diff --git a/crates/jsonschema/src/keywords/prefix_items.rs b/crates/jsonschema/src/keywords/prefix_items.rs index c7c97ef50..306ed771a 100644 --- a/crates/jsonschema/src/keywords/prefix_items.rs +++ b/crates/jsonschema/src/keywords/prefix_items.rs @@ -1,10 +1,11 @@ use crate::{ compiler, error::{no_error, ErrorIterator, ValidationError}, + evaluation::Annotations, node::SchemaNode, paths::{LazyLocation, Location}, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -73,33 +74,32 @@ impl Validate for PrefixItemsValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Array(items) = instance { if !items.is_empty() { - let validate_total = self.schemas.len(); - let mut results = Vec::with_capacity(validate_total); - let mut max_index_applied = 0; + let mut children = Vec::with_capacity(self.schemas.len().min(items.len())); + let mut max_index_applied = 0usize; for (idx, (schema_node, item)) in self.schemas.iter().zip(items.iter()).enumerate() { let path = location.push(idx); - results.push(schema_node.apply_rooted(item, &path)); + children.push(schema_node.evaluate_instance(item, &path)); max_index_applied = idx; } // Per draft 2020-12 section https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.10.3.1.1 // we must produce an annotation with the largest index of the underlying // array which the subschema was applied. The value MAY be a boolean true if // a subschema was applied to every index of the instance. - let schema_was_applied: Value = if results.len() == items.len() { - true.into() + let annotation = if children.len() == items.len() { + Value::Bool(true) } else { - max_index_applied.into() + Value::from(max_index_applied) }; - let mut output: PartialApplication = results.into_iter().collect(); - output.annotate(schema_was_applied.into()); - return output; + let mut result = EvaluationResult::from_children(children); + result.annotate(Annotations::new(annotation)); + return result; } } - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } @@ -135,116 +135,140 @@ mod tests { tests_util::assert_schema_location(schema, instance, expected); } - #[test_case{ - &json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "array", - "prefixItems": [ - { - "type": "string" - } - ] - }), - &json!([]), - &json!({ - "valid": true, - "annotations": [] - }); "valid prefixItems empty array" - }] - #[test_case{ - &json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "array", - "prefixItems": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }), - &json!(["string", 1]), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/prefixItems", - "instanceLocation": "", - "annotations": true - }, - ] - }); "prefixItems valid items" - }] - #[test_case{ - &json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "array", - "prefixItems": [ - { - "type": "string" - } - ] - }), - &json!(["string", 1]), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/prefixItems", - "instanceLocation": "", - "annotations": 0 - }, - ] - }); "prefixItems valid mixed items" - }] - #[test_case{ - &json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "array", - "items": { - "type": "number", - "annotation": "value" - }, - "prefixItems": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ] - }), - &json!(["string", true, 2, 3]), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/prefixItems", - "instanceLocation": "", - "annotations": 1 - }, - { - "keywordLocation": "/items", - "instanceLocation": "", - "annotations": true - }, - { - "annotations": {"annotation": "value" }, - "instanceLocation": "/2", - "keywordLocation": "/items" - }, - { - "annotations": {"annotation": "value" }, - "instanceLocation": "/3", - "keywordLocation": "/items" - } - ] - }); "valid prefixItems with mixed items" - }] - fn test_basic_output(schema: &Value, instance: &Value, expected_output: &Value) { - let validator = crate::validator_for(schema).unwrap(); - let output = serde_json::to_value(validator.apply(instance).basic()).unwrap(); - assert_eq!(&output, expected_output); + #[test] + fn evaluation_outputs_cover_prefix_items() { + let schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "number", "minimum": 0}}, + "required": ["name"] + }); + let validator = crate::validator_for(&schema).expect("schema compiles"); + let evaluation = validator.evaluate(&json!({"name": "Alice", "age": 1})); + + assert_eq!( + serde_json::to_value(evaluation.list()).unwrap(), + json!({ + "valid": true, + "details": [ + {"evaluationPath": "", "instanceLocation": "", "schemaLocation": "", "valid": true}, + { + "valid": true, + "evaluationPath": "/properties", + "instanceLocation": "", + "schemaLocation": "/properties", + "annotations": ["age", "name"] + }, + { + "valid": true, + "evaluationPath": "/properties/age", + "instanceLocation": "/age", + "schemaLocation": "/properties/age" + }, + { + "valid": true, + "evaluationPath": "/properties/age/minimum", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/minimum" + }, + { + "valid": true, + "evaluationPath": "/properties/age/type", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/type" + }, + { + "valid": true, + "evaluationPath": "/properties/name", + "instanceLocation": "/name", + "schemaLocation": "/properties/name" + }, + { + "valid": true, + "evaluationPath": "/properties/name/type", + "instanceLocation": "/name", + "schemaLocation": "/properties/name/type" + }, + { + "valid": true, + "evaluationPath": "/required", + "instanceLocation": "", + "schemaLocation": "/required" + }, + { + "valid": true, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type" + } + ] + }) + ); + + assert_eq!( + serde_json::to_value(evaluation.hierarchical()).unwrap(), + json!({ + "valid": true, + "evaluationPath": "", + "instanceLocation": "", + "schemaLocation": "", + "details": [ + { + "valid": true, + "evaluationPath": "/properties", + "instanceLocation": "", + "schemaLocation": "/properties", + "annotations": ["age", "name"], + "details": [ + { + "valid": true, + "evaluationPath": "/properties/age", + "instanceLocation": "/age", + "schemaLocation": "/properties/age", + "details": [ + { + "valid": true, + "evaluationPath": "/properties/age/minimum", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/minimum" + }, + { + "valid": true, + "evaluationPath": "/properties/age/type", + "instanceLocation": "/age", + "schemaLocation": "/properties/age/type" + } + ] + }, + { + "valid": true, + "evaluationPath": "/properties/name", + "instanceLocation": "/name", + "schemaLocation": "/properties/name", + "details": [ + { + "valid": true, + "evaluationPath": "/properties/name/type", + "instanceLocation": "/name", + "schemaLocation": "/properties/name/type" + } + ] + } + ] + }, + { + "valid": true, + "evaluationPath": "/required", + "instanceLocation": "", + "schemaLocation": "/required" + }, + { + "valid": true, + "evaluationPath": "/type", + "instanceLocation": "", + "schemaLocation": "/type" + } + ] + }) + ); } } diff --git a/crates/jsonschema/src/keywords/properties.rs b/crates/jsonschema/src/keywords/properties.rs index 9f46bb341..3cb636e14 100644 --- a/crates/jsonschema/src/keywords/properties.rs +++ b/crates/jsonschema/src/keywords/properties.rs @@ -1,12 +1,12 @@ use crate::{ compiler, error::{no_error, ErrorIterator, ValidationError}, + evaluation::Annotations, keywords::CompilationResult, node::SchemaNode, - output::BasicOutput, paths::{LazyLocation, Location}, types::JsonType, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -87,22 +87,22 @@ impl Validate for PropertiesValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(props) = instance { - let mut result = BasicOutput::default(); let mut matched_props = Vec::with_capacity(props.len()); + let mut children = Vec::new(); for (prop_name, node) in &self.properties { if let Some(prop) = props.get(prop_name) { let path = location.push(prop_name.as_str()); matched_props.push(prop_name.clone()); - result += node.apply_rooted(prop, &path); + children.push(node.evaluate_instance(prop, &path)); } } - let mut application: PartialApplication = result.into(); - application.annotate(Value::from(matched_props).into()); + let mut application = EvaluationResult::from_children(children); + application.annotate(Annotations::new(Value::from(matched_props))); application } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } diff --git a/crates/jsonschema/src/keywords/property_names.rs b/crates/jsonschema/src/keywords/property_names.rs index a81c21f33..c5301bf61 100644 --- a/crates/jsonschema/src/keywords/property_names.rs +++ b/crates/jsonschema/src/keywords/property_names.rs @@ -4,7 +4,7 @@ use crate::{ keywords::CompilationResult, node::SchemaNode, paths::{LazyLocation, Location}, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, }; use serde_json::{Map, Value}; @@ -86,16 +86,16 @@ impl Validate for PropertyNamesObjectValidator { Ok(()) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { if let Value::Object(item) = instance { - item.keys() - .map(|key| { - let wrapper = Value::String(key.clone()); - self.node.apply_rooted(&wrapper, location) - }) - .collect() + let mut children = Vec::with_capacity(item.len()); + for key in item.keys() { + let wrapper = Value::String(key.clone()); + children.push(self.node.evaluate_instance(&wrapper, location)); + } + EvaluationResult::from_children(children) } else { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } } } diff --git a/crates/jsonschema/src/keywords/unevaluated_items.rs b/crates/jsonschema/src/keywords/unevaluated_items.rs index 9589e2d34..c5d381b30 100644 --- a/crates/jsonschema/src/keywords/unevaluated_items.rs +++ b/crates/jsonschema/src/keywords/unevaluated_items.rs @@ -12,10 +12,11 @@ use std::sync::OnceLock; use crate::{ compiler, + evaluation::ErrorDescription, node::SchemaNode, paths::{LazyLocation, Location}, thread::Shared, - validator::Validate, + validator::{EvaluationResult, Validate}, ValidationError, }; @@ -570,6 +571,61 @@ impl Validate for UnevaluatedItemsValidator { } Ok(()) } + + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + if let Value::Array(items) = instance { + let mut indexes = vec![false; items.len()]; + self.validators + .mark_evaluated_indexes(instance, &mut indexes); + let mut children = Vec::new(); + let mut unevaluated = Vec::new(); + let mut invalid = false; + + for (idx, (item, is_evaluated)) in items.iter().zip(indexes.iter()).enumerate() { + if *is_evaluated { + continue; + } + if let Some(validator) = &self.validators.unevaluated { + let child = validator.evaluate_instance(item, &location.push(idx)); + if !child.valid { + invalid = true; + unevaluated.push(item.to_string()); + } + children.push(child); + } else { + invalid = true; + unevaluated.push(item.to_string()); + } + } + + let mut errors = Vec::new(); + if !unevaluated.is_empty() { + errors.push(ErrorDescription::from_validation_error( + &ValidationError::unevaluated_items( + self.location.clone(), + location.into(), + instance, + unevaluated, + ), + )); + } + + if invalid { + EvaluationResult::Invalid { + errors, + children, + annotations: None, + } + } else { + EvaluationResult::Valid { + annotations: None, + children, + } + } + } else { + EvaluationResult::valid_empty() + } + } } pub(crate) fn compile<'a>( diff --git a/crates/jsonschema/src/keywords/unevaluated_properties.rs b/crates/jsonschema/src/keywords/unevaluated_properties.rs index bb35f7e20..8659ac48a 100644 --- a/crates/jsonschema/src/keywords/unevaluated_properties.rs +++ b/crates/jsonschema/src/keywords/unevaluated_properties.rs @@ -13,10 +13,11 @@ use std::sync::OnceLock; use crate::{ compiler, ecma, + evaluation::ErrorDescription, node::SchemaNode, paths::{LazyLocation, Location}, thread::Shared, - validator::Validate, + validator::{EvaluationResult, Validate}, ValidationError, }; @@ -648,6 +649,60 @@ impl Validate for UnevaluatedPropertiesValidator { } true } + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + if let Value::Object(properties) = instance { + let mut evaluated = AHashSet::with_capacity(properties.len()); + self.validators + .mark_evaluated_properties(instance, &mut evaluated); + let mut children = Vec::new(); + let mut unevaluated = Vec::new(); + let mut invalid = false; + + for (property, value) in properties { + if evaluated.contains(property) { + continue; + } + if let Some(validator) = &self.validators.unevaluated { + let child = validator.evaluate_instance(value, &location.push(property)); + if !child.valid { + invalid = true; + unevaluated.push(property.clone()); + } + children.push(child); + } else { + invalid = true; + unevaluated.push(property.clone()); + } + } + + let mut errors = Vec::new(); + if !unevaluated.is_empty() { + errors.push(ErrorDescription::from_validation_error( + &ValidationError::unevaluated_properties( + self.location.clone(), + location.into(), + instance, + unevaluated, + ), + )); + } + + if invalid { + EvaluationResult::Invalid { + errors, + children, + annotations: None, + } + } else { + EvaluationResult::Valid { + annotations: None, + children, + } + } + } else { + EvaluationResult::valid_empty() + } + } } pub(crate) fn compile<'a>( diff --git a/crates/jsonschema/src/lib.rs b/crates/jsonschema/src/lib.rs index cc6874944..ad8ce94d0 100644 --- a/crates/jsonschema/src/lib.rs +++ b/crates/jsonschema/src/lib.rs @@ -6,7 +6,7 @@ //! - πŸ“š Support for popular JSON Schema drafts //! - πŸ”§ Custom keywords and format validators //! - 🌐 Blocking & non-blocking remote reference fetching (network/file) -//! - 🎨 `Basic` output style as per JSON Schema spec +//! - 🎨 Structured Output v1 reports (flag/list/hierarchical) //! - ✨ Meta-schema validation for schema documents //! - πŸš€ WebAssembly support //! @@ -89,6 +89,167 @@ //! //! Once built, any `format` keywords in your schema will be actively validated according to the chosen draft. //! +//! # Structured Output +//! +//! The `evaluate()` method provides access to structured validation output formats defined by +//! [JSON Schema Output v1](https://github.com/json-schema-org/json-schema-spec/blob/main/specs/output/jsonschema-validation-output-machines.md). +//! This is useful when you need detailed information about the validation process beyond simple pass/fail results. +//! +//! ```rust +//! # fn main() -> Result<(), Box> { +//! use serde_json::json; +//! +//! let schema = json!({ +//! "type": "object", +//! "properties": { +//! "name": {"type": "string"}, +//! "age": {"type": "number", "minimum": 0} +//! }, +//! "required": ["name"] +//! }); +//! +//! let validator = jsonschema::validator_for(&schema)?; +//! let instance = json!({"name": "Alice", "age": 30}); +//! +//! // Evaluate the instance +//! let evaluation = validator.evaluate(&instance); +//! +//! // Flag format: Simple boolean validity +//! let flag = evaluation.flag(); +//! assert!(flag.valid); +//! +//! // List format: Flat list of all evaluation steps +//! let list_output = serde_json::to_value(evaluation.list())?; +//! println!("List output: {}", serde_json::to_string_pretty(&list_output)?); +//! +//! // Hierarchical format: Nested tree structure +//! let hierarchical_output = serde_json::to_value(evaluation.hierarchical())?; +//! println!( +//! "Hierarchical output: {}", +//! serde_json::to_string_pretty(&hierarchical_output)? +//! ); +//! +//! // Iterate over annotations collected during validation +//! for annotation in evaluation.iter_annotations() { +//! println!("Annotation at {}: {:?}", +//! annotation.instance_location, +//! annotation.annotations +//! ); +//! } +//! +//! // Iterate over errors (if any) +//! for error in evaluation.iter_errors() { +//! println!("Error: {}", error.error); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! The structured output formats are particularly useful for: +//! - **Debugging**: Understanding exactly which schema keywords matched or failed +//! - **User feedback**: Providing detailed, actionable error messages +//! - **Annotations**: Collecting metadata produced by successful validation +//! - **Tooling**: Building development tools that work with JSON Schema +//! +//! For example, validating `["hello", "oops"]` against a schema with both `prefixItems` and +//! `items` produces list output similar to: +//! +//! ```json +//! { +//! "valid": false, +//! "details": [ +//! {"valid": false, "evaluationPath": "", "schemaLocation": "", "instanceLocation": ""}, +//! { +//! "valid": false, +//! "evaluationPath": "/items", +//! "schemaLocation": "/items", +//! "instanceLocation": "", +//! "droppedAnnotations": true +//! }, +//! { +//! "valid": false, +//! "evaluationPath": "/items", +//! "schemaLocation": "/items", +//! "instanceLocation": "/1" +//! }, +//! { +//! "valid": false, +//! "evaluationPath": "/items/type", +//! "schemaLocation": "/items/type", +//! "instanceLocation": "/1", +//! "errors": {"type": "\"oops\" is not of type \"integer\""} +//! }, +//! {"valid": true, "evaluationPath": "/prefixItems", "schemaLocation": "/prefixItems", "instanceLocation": "", "annotations": 0} +//! ] +//! } +//! ``` +//! +//! ## Output Formats +//! +//! ### Flag Format +//! +//! The simplest format, containing only a boolean validity indicator: +//! +//! ```rust +//! # fn main() -> Result<(), Box> { +//! # use serde_json::json; +//! # let schema = json!({"type": "string"}); +//! # let validator = jsonschema::validator_for(&schema)?; +//! let evaluation = validator.evaluate(&json!("hello")); +//! let flag = evaluation.flag(); +//! +//! let output = serde_json::to_value(flag)?; +//! // Output: {"valid": true} +//! # Ok(()) +//! # } +//! ``` +//! +//! ### List Format +//! +//! A flat list of all evaluation units, where each unit describes a validation step: +//! +//! ```rust +//! # fn main() -> Result<(), Box> { +//! # use serde_json::json; +//! let schema = json!({ +//! "allOf": [ +//! {"type": "number"}, +//! {"minimum": 0} +//! ] +//! }); +//! let validator = jsonschema::validator_for(&schema)?; +//! let evaluation = validator.evaluate(&json!(42)); +//! +//! let list = evaluation.list(); +//! let output = serde_json::to_value(list)?; +//! // Output includes all evaluation steps in a flat array +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Hierarchical Format +//! +//! A nested tree structure that mirrors the schema's logical structure: +//! +//! ```rust +//! # fn main() -> Result<(), Box> { +//! # use serde_json::json; +//! let schema = json!({ +//! "allOf": [ +//! {"type": "number"}, +//! {"minimum": 0} +//! ] +//! }); +//! let validator = jsonschema::validator_for(&schema)?; +//! let evaluation = validator.evaluate(&json!(42)); +//! +//! let hierarchical = evaluation.hierarchical(); +//! let output = serde_json::to_value(hierarchical)?; +//! // Output has nested "details" arrays for sub-schema evaluations +//! # Ok(()) +//! # } +//! ``` +//! //! # Meta-Schema Validation //! //! The crate provides functionality to validate JSON Schema documents themselves against their meta-schemas. @@ -362,43 +523,6 @@ //! //! On `wasm32` targets, use `async_trait::async_trait(?Send)` so your retriever can rely on `Rc`, `JsFuture`, or other non-thread-safe types. //! -//! # Output Styles -//! -//! `jsonschema` supports the `basic` output style as defined in JSON Schema Draft 2019-09. -//! This styles allow you to serialize validation results in a standardized format using `serde`. -//! -//! ```rust -//! # fn main() -> Result<(), Box> { -//! use serde_json::json; -//! -//! let schema_json = json!({ -//! "title": "string value", -//! "type": "string" -//! }); -//! let instance = json!("some string"); -//! let validator = jsonschema::validator_for(&schema_json)?; -//! -//! let output = validator.apply(&instance).basic(); -//! -//! assert_eq!( -//! serde_json::to_value(output)?, -//! json!({ -//! "valid": true, -//! "annotations": [ -//! { -//! "keywordLocation": "", -//! "instanceLocation": "", -//! "annotations": { -//! "title": "string value" -//! } -//! } -//! ] -//! }) -//! ); -//! # Ok(()) -//! # } -//! ``` -//! //! # Regular Expression Configuration //! //! The `jsonschema` crate allows configuring the regular expression engine used for validating @@ -717,6 +841,7 @@ mod content_encoding; mod content_media_type; mod ecma; pub mod error; +mod evaluation; #[doc(hidden)] pub mod ext; mod keywords; @@ -732,9 +857,11 @@ pub mod types; mod validator; pub use error::{ErrorIterator, MaskedValidationError, ValidationError}; +pub use evaluation::{ + AnnotationEntry, ErrorEntry, Evaluation, FlagOutput, HierarchicalOutput, ListOutput, +}; pub use keywords::custom::Keyword; pub use options::{FancyRegex, PatternOptions, Regex, ValidationOptions}; -pub use output::BasicOutput; pub use referencing::{ Draft, Error as ReferencingError, Registry, RegistryOptions, Resource, Retrieve, Uri, }; @@ -2276,7 +2403,7 @@ pub mod draft202012 { #[cfg(test)] pub(crate) mod tests_util { use super::Validator; - use crate::{output::OutputUnit, BasicOutput, ValidationError}; + use crate::ValidationError; use serde_json::Value; #[track_caller] @@ -2293,9 +2420,10 @@ pub(crate) mod tests_util { validator.iter_errors(instance).next().is_some(), "{instance} should not be valid (via validate)", ); + let evaluation = validator.evaluate(instance); assert!( - !validator.apply(instance).basic().is_valid(), - "{instance} should not be valid (via apply)", + !evaluation.flag().valid, + "{instance} should not be valid (via evaluate)", ); } @@ -2335,9 +2463,10 @@ pub(crate) mod tests_util { validator.validate(instance).is_ok(), "{instance} should be valid (via is_valid)", ); + let evaluation = validator.evaluate(instance); assert!( - validator.apply(instance).basic().is_valid(), - "{instance} should be valid (via apply)", + evaluation.flag().valid, + "{instance} should be valid (via evaluate)", ); } @@ -2385,38 +2514,44 @@ pub(crate) mod tests_util { instance_pointer: &str, keyword_pointer: &str, ) { - fn ensure_location( - units: Vec>, - instance_pointer: &str, - keyword_pointer: &str, - ) -> Result<(), Vec> { - let mut available = Vec::new(); - for unit in units { - let instance_location = unit.instance_location().as_str(); - if instance_location == instance_pointer { - let keyword_location = unit.keyword_location().as_str().to_string(); - if keyword_location == keyword_pointer { - return Ok(()); - } - available.push(keyword_location); - } - } - Err(available) + fn pointer_from_schema_location(location: &str) -> &str { + location + .split_once('#') + .map_or(location, |(_, fragment)| fragment) } - match validator.apply(instance).basic() { - BasicOutput::Valid(units) => { - ensure_location(units, instance_pointer, keyword_pointer) + let evaluation = validator.evaluate(instance); + let serialized = + serde_json::to_value(evaluation.list()).expect("List output should be serializable"); + let details = serialized + .get("details") + .and_then(|value| value.as_array()) + .expect("List output must contain details"); + let mut available = Vec::new(); + for entry in details { + let Some(instance_location) = entry + .get("instanceLocation") + .and_then(|value| value.as_str()) + else { + continue; + }; + if instance_location != instance_pointer { + continue; } - BasicOutput::Invalid(units) => { - ensure_location(units, instance_pointer, keyword_pointer) + let schema_location = entry + .get("schemaLocation") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let pointer = pointer_from_schema_location(schema_location); + if pointer == keyword_pointer { + return; } + available.push(pointer.to_string()); } - .unwrap_or_else(|available| { - panic!( - "No annotation for instance pointer `{instance_pointer}` with keyword location `{keyword_pointer}`. Available keyword locations for pointer: {available:?}" - ) - }); + + panic!( + "No annotation for instance pointer `{instance_pointer}` with keyword location `{keyword_pointer}`. Available keyword locations for pointer: {available:?}" + ); } } diff --git a/crates/jsonschema/src/node.rs b/crates/jsonschema/src/node.rs index 6389d0261..49cace8fc 100644 --- a/crates/jsonschema/src/node.rs +++ b/crates/jsonschema/src/node.rs @@ -1,11 +1,11 @@ use crate::{ compiler::Context, error::ErrorIterator, + evaluation::{format_schema_location, Annotations, EvaluationNode}, keywords::{BoxedValidator, Keyword}, - output::{Annotations, BasicOutput, ErrorDescription, OutputUnit}, paths::{LazyLocation, Location}, thread::{Shared, SharedWeak}, - validator::{PartialApplication, Validate}, + validator::{EvaluationResult, Validate}, ValidationError, }; use referencing::Uri; @@ -155,8 +155,8 @@ impl Validate for PendingSchemaNode { self.with_node(|node| node.iter_errors(instance, location)) } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { - self.with_node(|node| node.apply(instance, location)) + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { + self.with_node(|node| node.evaluate(instance, location)) } } @@ -249,78 +249,48 @@ impl SchemaNode { } } - /// This is similar to `Validate::apply` except that `SchemaNode` knows where it is in the - /// validator tree and so rather than returning a `PartialApplication` it is able to return a - /// complete `BasicOutput`. This is the mechanism which compositional validators use to combine - /// results from sub-schemas - pub(crate) fn apply_rooted(&self, instance: &Value, location: &LazyLocation) -> BasicOutput { - match self.apply(instance, location) { - PartialApplication::Valid { + pub(crate) fn evaluate_instance( + &self, + instance: &Value, + location: &LazyLocation, + ) -> EvaluationNode { + let instance_location: Location = location.into(); + let schema_location = format_schema_location(&self.location, self.absolute_path.as_ref()); + match self.evaluate(instance, location) { + EvaluationResult::Valid { annotations, - child_results, - } => { - if let Some(annotations) = annotations { - let mut outputs = Vec::with_capacity(child_results.len() + 1); - outputs.push(self.annotation_at(location, annotations)); - outputs.extend(child_results); - BasicOutput::Valid(outputs) - } else { - BasicOutput::Valid(child_results) - } - } - PartialApplication::Invalid { + children, + } => EvaluationNode::valid( + self.location.clone(), + self.absolute_path.clone(), + schema_location, + instance_location, + annotations, + children, + ), + EvaluationResult::Invalid { errors, - child_results, - } => { - if errors.is_empty() { - BasicOutput::Invalid(child_results) - } else { - let mut outputs = Vec::with_capacity(child_results.len() + errors.len()); - for error in errors { - outputs.push(self.error_at(location, error)); - } - outputs.extend(child_results); - BasicOutput::Invalid(outputs) - } - } + children, + annotations, + } => EvaluationNode::invalid( + self.location.clone(), + self.absolute_path.clone(), + schema_location, + instance_location, + annotations, + errors, + children, + ), } } - /// Create an error output which is marked as occurring at this schema node - pub(crate) fn error_at( - &self, - location: &LazyLocation, - error: ErrorDescription, - ) -> OutputUnit { - OutputUnit::::error( - self.location.clone(), - location.into(), - self.absolute_path.clone(), - error, - ) - } - - /// Create an annotation output which is marked as occurring at this schema node - pub(crate) fn annotation_at( - &self, - location: &LazyLocation, - annotations: Annotations, - ) -> OutputUnit { - OutputUnit::::annotations( - self.location.clone(), - location.into(), - self.absolute_path.clone(), - annotations, - ) - } - - /// Helper function to apply subschemas which already know their locations. - fn apply_subschemas<'a, I>( + /// Helper function to evaluate subschemas which already know their locations. + fn evaluate_subschemas<'a, I>( instance: &Value, location: &LazyLocation, subschemas: I, annotations: Option, - ) -> PartialApplication + ) -> EvaluationResult where I: Iterator< Item = ( @@ -331,73 +301,61 @@ impl SchemaNode { > + 'a, { let (lower_bound, _) = subschemas.size_hint(); - let mut success_annotations: Vec> = Vec::with_capacity(lower_bound); - let mut success_children: Vec> = Vec::with_capacity(lower_bound); - let mut error_results: Vec> = Vec::with_capacity(lower_bound); + let mut children: Vec = Vec::with_capacity(lower_bound); + let mut invalid = false; let instance_location: OnceCell = OnceCell::new(); - macro_rules! instance_location { - () => { - instance_location.get_or_init(|| location.into()).clone() - }; - } - for (child_location, absolute_location, validator) in subschemas { + let child_result = validator.evaluate(instance, location); + + // Only materialize locations and format strings when actually needed let schema_location = child_location.clone(); let absolute_location = absolute_location.cloned(); - match validator.apply(instance, location) { - PartialApplication::Valid { + let instance_loc = instance_location.get_or_init(|| location.into()).clone(); + let formatted_schema_location = + format_schema_location(&schema_location, absolute_location.as_ref()); + + let child_node = match child_result { + EvaluationResult::Valid { annotations, - mut child_results, - } => { - if let Some(annotations) = annotations { - success_annotations.push(OutputUnit::::annotations( - schema_location.clone(), - instance_location!(), - absolute_location.clone(), - annotations, - )); - } - success_children.append(&mut child_results); - } - PartialApplication::Invalid { - errors: these_errors, - mut child_results, - } => { - let child_len = child_results.len(); - error_results.reserve(child_len + these_errors.len()); - error_results.append(&mut child_results); - error_results.extend(these_errors.into_iter().map(|error| { - OutputUnit::::error( - schema_location.clone(), - instance_location!(), - absolute_location.clone(), - error, - ) - })); - } - } - } - if error_results.is_empty() { - let mut child_results = success_children; - if success_annotations.is_empty() { - PartialApplication::Valid { + children, + } => EvaluationNode::valid( + schema_location, + absolute_location, + formatted_schema_location, + instance_loc, annotations, - child_results, - } - } else { - let mut annotations_front = success_annotations; - annotations_front.reverse(); - annotations_front.append(&mut child_results); - PartialApplication::Valid { + children, + ), + EvaluationResult::Invalid { + errors, + children, annotations, - child_results: annotations_front, + } => { + invalid = true; + EvaluationNode::invalid( + schema_location, + absolute_location, + formatted_schema_location, + instance_loc, + annotations, + errors, + children, + ) } + }; + children.push(child_node); + } + if invalid { + EvaluationResult::Invalid { + errors: Vec::new(), + children, + annotations, } } else { - PartialApplication::Invalid { - errors: Vec::new(), - child_results: error_results, + EvaluationResult::Valid { + annotations, + children, } } } @@ -489,9 +447,9 @@ impl Validate for SchemaNode { } } - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { match self.validators.as_ref() { - NodeValidators::Array { ref validators } => SchemaNode::apply_subschemas( + NodeValidators::Array { ref validators } => Self::evaluate_subschemas( instance, location, validators.iter().map(|entry| { @@ -505,11 +463,11 @@ impl Validate for SchemaNode { ), NodeValidators::Boolean { ref validator } => { if let Some(validator) = validator { - validator.apply(instance, location) + validator.evaluate(instance, location) } else { - PartialApplication::Valid { + EvaluationResult::Valid { annotations: None, - child_results: Vec::new(), + children: Vec::new(), } } } @@ -518,9 +476,10 @@ impl Validate for SchemaNode { ref unmatched_keywords, ref validators, } = *kvals; - let annotations: Option = - unmatched_keywords.as_ref().map(Annotations::from); - SchemaNode::apply_subschemas( + let annotations: Option = unmatched_keywords + .as_ref() + .map(|v| Annotations::new((**v).clone())); + Self::evaluate_subschemas( instance, location, validators.iter().map(|entry| { diff --git a/crates/jsonschema/src/output.rs b/crates/jsonschema/src/output.rs index 2ab56d151..9f959e253 100644 --- a/crates/jsonschema/src/output.rs +++ b/crates/jsonschema/src/output.rs @@ -1,408 +1,3 @@ -//! Implementation of json schema output formats specified in -//! -//! Currently the "basic" formats is supported. The main contribution of this module is [`Output::basic`]. -//! See the documentation of that method for more information. +//! Backwards-compatible re-exports for structured output helpers. -use std::{ - borrow::Cow, - fmt, - iter::{FromIterator, Sum}, - ops::AddAssign, - sync::Arc, -}; - -use crate::{paths::Location, validator::PartialApplication, ValidationError}; -use ahash::AHashMap; -use referencing::Uri; -use serde::ser::SerializeMap; - -use crate::{node::SchemaNode, paths::LazyLocation, Validator}; - -/// The output format resulting from the application of a schema. -/// -/// This can be converted into various representations based on the definitions in -/// -/// -/// Currently only the "flag" and "basic" output formats are supported -#[derive(Debug, Clone)] -pub struct Output<'a, 'b> { - schema: &'a Validator, - root_node: &'a SchemaNode, - instance: &'b serde_json::Value, -} - -impl Output<'_, '_> { - pub(crate) const fn new<'c, 'd>( - schema: &'c Validator, - root_node: &'c SchemaNode, - instance: &'d serde_json::Value, - ) -> Output<'c, 'd> { - Output { - schema, - root_node, - instance, - } - } - - /// Indicates whether the schema was valid, corresponds to the "flag" output - /// format - #[must_use] - pub fn flag(&self) -> bool { - self.schema.is_valid(self.instance) - } - - /// Output a list of errors and annotations for each element in the schema - /// according to the basic output format. [`BasicOutput`] implements - /// `serde::Serialize` in a manner which conforms to the json core spec so - /// one way to use this is to serialize the `BasicOutput` and examine the - /// JSON which is produced. However, for rust programs this is not - /// necessary. Instead you can match on the `BasicOutput` and examine the - /// results. To use this API you'll need to understand a few things: - /// - /// Regardless of whether the the schema validation was successful or not - /// the `BasicOutput` is a sequence of [`OutputUnit`]s. An `OutputUnit` is - /// some metadata about where the output is coming from (where in the schema - /// and where in the instance). The difference between the - /// `BasicOutput::Valid` and `BasicOutput::Invalid` cases is the value which - /// is associated with each `OutputUnit`. For `Valid` outputs the value is - /// an annotation, whilst for `Invalid` outputs it's an [`ErrorDescription`] - /// (a `String` really). - /// - /// # Examples - /// - /// ```rust - /// # use jsonschema::BasicOutput; - /// # use serde_json::json; - /// # let schema = json!({ - /// # "title": "string value", - /// # "type": "string" - /// # }); - /// # let instance = json!("some string"); - /// # let validator = jsonschema::validator_for(&schema).expect("Invalid schema"); - /// let output = validator.apply(&instance).basic(); - /// match output { - /// BasicOutput::Valid(annotations) => { - /// for annotation in annotations { - /// println!( - /// "Value: {} at path {}", - /// annotation.value(), - /// annotation.instance_location() - /// ) - /// } - /// }, - /// BasicOutput::Invalid(errors) => { - /// for error in errors { - /// println!( - /// "Error: {} at path {}", - /// error.error_description(), - /// error.instance_location() - /// ) - /// } - /// } - /// } - /// ``` - #[must_use] - pub fn basic(&self) -> BasicOutput { - self.root_node - .apply_rooted(self.instance, &LazyLocation::new()) - } -} - -/// The "basic" output format. See the documentation for [`Output::basic`] for -/// examples of how to use this. -#[derive(Debug, PartialEq)] -pub enum BasicOutput { - /// The schema was valid, collected annotations can be examined - Valid(Vec>), - /// The schema was invalid - Invalid(Vec>), -} - -impl BasicOutput { - /// A shortcut to check whether the output represents passed validation. - #[must_use] - pub const fn is_valid(&self) -> bool { - match self { - BasicOutput::Valid(..) => true, - BasicOutput::Invalid(..) => false, - } - } -} - -impl From> for BasicOutput { - fn from(unit: OutputUnit) -> Self { - BasicOutput::Valid(vec![unit]) - } -} - -impl AddAssign for BasicOutput { - fn add_assign(&mut self, rhs: Self) { - match (&mut *self, rhs) { - (BasicOutput::Valid(ref mut anns), BasicOutput::Valid(anns_rhs)) => { - anns.reserve(anns_rhs.len()); - anns.extend(anns_rhs); - } - (BasicOutput::Valid(..), BasicOutput::Invalid(errors)) => { - *self = BasicOutput::Invalid(errors); - } - (BasicOutput::Invalid(..), BasicOutput::Valid(..)) => {} - (BasicOutput::Invalid(errors), BasicOutput::Invalid(errors_rhs)) => { - errors.reserve(errors_rhs.len()); - errors.extend(errors_rhs); - } - } - } -} - -impl Sum for BasicOutput { - fn sum>(iter: I) -> Self { - let result = BasicOutput::Valid(Vec::new()); - iter.fold(result, |mut acc, elem| { - acc += elem; - acc - }) - } -} - -impl Default for BasicOutput { - fn default() -> Self { - BasicOutput::Valid(Vec::new()) - } -} - -impl From for PartialApplication { - fn from(output: BasicOutput) -> Self { - match output { - BasicOutput::Valid(anns) => PartialApplication::Valid { - annotations: None, - child_results: anns, - }, - BasicOutput::Invalid(errors) => PartialApplication::Invalid { - errors: Vec::new(), - child_results: errors, - }, - } - } -} - -impl FromIterator for PartialApplication { - fn from_iter>(iter: T) -> Self { - iter.into_iter().sum::().into() - } -} - -/// A reference to a place in a schema and a place in an instance along with some value associated to that place. -/// -/// For annotations the value will be an [`Annotations`] and for errors it will be an -/// [`ErrorDescription`]. See the documentation for [`Output::basic`] for a -/// detailed example. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OutputUnit { - keyword_location: Location, - instance_location: Location, - absolute_keyword_location: Option>>, - value: T, -} - -impl OutputUnit { - pub(crate) const fn annotations( - keyword_location: Location, - instance_location: Location, - absolute_keyword_location: Option>>, - annotations: Annotations, - ) -> OutputUnit { - OutputUnit { - keyword_location, - instance_location, - absolute_keyword_location, - value: annotations, - } - } - - pub(crate) const fn error( - keyword_location: Location, - instance_location: Location, - absolute_keyword_location: Option>>, - error: ErrorDescription, - ) -> OutputUnit { - OutputUnit { - keyword_location, - instance_location, - absolute_keyword_location, - value: error, - } - } - - /// The location in the schema of the keyword - pub const fn keyword_location(&self) -> &Location { - &self.keyword_location - } - - /// The absolute location in the schema of the keyword. This will be - /// different to `keyword_location` if the schema is a resolved reference. - pub fn absolute_keyword_location(&self) -> Option> { - self.absolute_keyword_location - .as_ref() - .map(|uri| uri.borrow()) - } - - /// The location in the instance - pub const fn instance_location(&self) -> &Location { - &self.instance_location - } -} - -impl OutputUnit { - /// The annotations found at this output unit. - #[must_use] - #[allow(clippy::missing_panics_doc)] - pub fn value(&self) -> Cow<'_, serde_json::Value> { - Cow::Borrowed(self.value.value()) - } -} - -impl OutputUnit { - /// The error for this output unit - #[must_use] - pub const fn error_description(&self) -> &ErrorDescription { - &self.value - } -} - -/// Annotations associated with an output unit. -#[derive(Debug, Clone, PartialEq)] -pub struct Annotations(Arc); - -impl Annotations { - /// The `serde_json::Value` of the annotation. - #[must_use] - pub fn value(&self) -> &serde_json::Value { - &self.0 - } -} - -impl From<&AHashMap> for Annotations { - fn from(anns: &AHashMap) -> Self { - let mut object = serde_json::Map::with_capacity(anns.len()); - for (key, value) in anns { - object.insert(key.clone(), value.clone()); - } - Annotations(Arc::new(serde_json::Value::Object(object))) - } -} - -impl From<&serde_json::Value> for Annotations { - fn from(v: &serde_json::Value) -> Self { - Annotations(Arc::new(v.clone())) - } -} - -impl From> for Annotations { - fn from(v: Arc) -> Self { - Annotations(v) - } -} - -impl From<&Arc> for Annotations { - fn from(v: &Arc) -> Self { - Annotations(Arc::clone(v)) - } -} - -impl From for Annotations { - fn from(v: serde_json::Value) -> Self { - Annotations(Arc::new(v)) - } -} - -impl serde::Serialize for Annotations { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.0.serialize(serializer) - } -} - -/// An error associated with an [`OutputUnit`] -#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)] -pub struct ErrorDescription(String); - -impl ErrorDescription { - /// Returns the inner [`String`] of the error description. - #[inline] - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } -} - -impl fmt::Display for ErrorDescription { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl From> for ErrorDescription { - fn from(e: ValidationError<'_>) -> Self { - ErrorDescription(e.to_string()) - } -} - -impl<'a> From<&'a str> for ErrorDescription { - fn from(s: &'a str) -> Self { - ErrorDescription(s.to_string()) - } -} - -impl serde::Serialize for BasicOutput { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map_ser = serializer.serialize_map(Some(2))?; - match self { - BasicOutput::Valid(outputs) => { - map_ser.serialize_entry("valid", &true)?; - map_ser.serialize_entry("annotations", outputs)?; - } - BasicOutput::Invalid(errors) => { - map_ser.serialize_entry("valid", &false)?; - map_ser.serialize_entry("errors", errors)?; - } - } - map_ser.end() - } -} - -impl serde::Serialize for OutputUnit { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map_ser = serializer.serialize_map(Some(4))?; - map_ser.serialize_entry("keywordLocation", self.keyword_location.as_str())?; - map_ser.serialize_entry("instanceLocation", self.instance_location.as_str())?; - if let Some(absolute) = &self.absolute_keyword_location { - map_ser.serialize_entry("absoluteKeywordLocation", absolute.as_str())?; - } - map_ser.serialize_entry("annotations", &self.value)?; - map_ser.end() - } -} - -impl serde::Serialize for OutputUnit { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map_ser = serializer.serialize_map(Some(4))?; - map_ser.serialize_entry("keywordLocation", self.keyword_location.as_str())?; - map_ser.serialize_entry("instanceLocation", self.instance_location.as_str())?; - if let Some(absolute) = &self.absolute_keyword_location { - map_ser.serialize_entry("absoluteKeywordLocation", absolute.as_str())?; - } - map_ser.serialize_entry("error", &self.value)?; - map_ser.end() - } -} +pub use crate::evaluation::{Annotations, ErrorDescription}; diff --git a/crates/jsonschema/src/validator.rs b/crates/jsonschema/src/validator.rs index dabe11504..19370941b 100644 --- a/crates/jsonschema/src/validator.rs +++ b/crates/jsonschema/src/validator.rs @@ -3,8 +3,8 @@ //! everything needed to perform such validation in runtime. use crate::{ error::{error, no_error, ErrorIterator}, + evaluation::{Annotations, ErrorDescription, Evaluation, EvaluationNode}, node::SchemaNode, - output::{Annotations, ErrorDescription, Output, OutputUnit}, paths::LazyLocation, thread::ThreadBound, Draft, ValidationError, ValidationOptions, @@ -19,12 +19,12 @@ use serde_json::Value; /// in that case the `is_valid` function is sufficient. Sometimes applications will want more /// detail about why a schema has failed, in which case the `validate` method can be used to /// iterate over the errors produced by this validator. Finally, applications may be interested in -/// annotations produced by schemas over valid results, in this case the `apply` method can be used +/// annotations produced by schemas over valid results, in this case the `evaluate` method can be used /// to obtain this information. /// /// If you are implementing `Validate` it is often sufficient to implement `validate` and -/// `is_valid`. `apply` is only necessary for validators which compose other validators. See the -/// documentation for `apply` for more information. +/// `is_valid`. `evaluate` is only necessary for validators which compose other validators. See the +/// documentation for `evaluate` for more information. pub(crate) trait Validate: ThreadBound { fn iter_errors<'i>(&self, instance: &'i Value, location: &LazyLocation) -> ErrorIterator<'i> { match self.validate(instance, location) { @@ -43,121 +43,148 @@ pub(crate) trait Validate: ThreadBound { location: &LazyLocation, ) -> Result<(), ValidationError<'i>>; - /// `apply` applies this validator and any sub-validators it is composed of to the value in - /// question and collects the resulting annotations or errors. Note that the result of `apply` - /// is a `PartialApplication`. + /// `evaluate` applies this validator and any sub-validators it is composed of to the value in + /// question and collects the resulting annotations or errors. Note that the result of this + /// method is a `EvaluationResult`. /// /// What does "partial" mean in this context? Each validator can produce annotations or errors - /// in the case of successful or unsuccessful validation respectively. We're ultimately - /// producing these errors and annotations to produce the "basic" output format as specified in - /// the 2020-12 draft specification. In this format each annotation or error must include a - /// json pointer to the keyword in the schema and to the property in the instance. However, - /// most validators don't know where they are in the schema tree so we allow them to return the - /// errors or annotations they produce directly and leave it up to the parent validator to fill - /// in the path information. This means that only validators which are composed of other - /// validators must implement `apply`, for validators on the leaves of the validator tree the + /// in the case of successful or unsuccessful validation respectively. The evaluation layer is + /// responsible for attaching schema/instance locations to those annotations and errors to build + /// the final evaluation tree. Most validators don't know where they are in the schema tree so we + /// allow them to return the errors or annotations they produce directly and leave it up to the + /// parent validator (or [`SchemaNode::evaluate_instance`](crate::node::SchemaNode::evaluate_instance)) + /// to fill in the path information. This means that only validators which are composed of other + /// validators must implement `evaluate`; for validators on the leaves of the validator tree the /// default implementation which is defined in terms of `validate` will suffice. /// /// If you are writing a validator which is composed of other validators then your validator will /// need to store references to the `SchemaNode`s which contain those other validators. /// `SchemaNode` stores information about where it is in the schema tree and therefore provides an - /// `apply_rooted` method which returns a full `BasicOutput`. `BasicOutput` implements `AddAssign` - /// so a typical pattern is to compose results from sub validators using `+=` and then use the - /// `From for PartialApplication` impl to convert the composed outputs into a - /// `PartialApplication` to return. For example, here is the implementation of - /// `IfThenValidator` + /// `evaluate_instance` method which returns an [`EvaluationNode`]. + /// A typical pattern is to evaluate the subschemas and combine their resulting nodes, e.g. the + /// `if`/`then` composition can be implemented as follows: /// /// ```rust,ignore - /// // Note that self.schema is a `SchemaNode` and we use `apply_rooted` to return a `BasicOutput` - /// let mut if_result = self.schema.apply_rooted(instance, instance_path); - /// if if_result.is_valid() { - /// // here we use the `AddAssign` implementation to combine the results of subschemas - /// if_result += self - /// .then_schema - /// .apply_rooted(instance, instance_path); - /// // Here we use the `From for PartialApplication impl - /// if_result.into() + /// let if_node = self.schema.evaluate_instance(instance, instance_path); + /// if if_node.valid { + /// let then_node = self.then_schema.evaluate_instance(instance, instance_path); + /// EvaluationResult::from_children(vec![if_node, then_node]) /// } else { - /// self.else_schema - /// .apply_rooted(instance, instance_path) - /// .into() + /// let else_node = self.else_schema.evaluate_instance(instance, instance_path); + /// EvaluationResult::from_children(vec![else_node]) /// } /// ``` - /// - /// `BasicOutput` also implements `Sum` and `FromIterator` for `PartialApplication` - /// so you can use `sum()` and `collect()` in simple cases. - fn apply(&self, instance: &Value, location: &LazyLocation) -> PartialApplication { + fn evaluate(&self, instance: &Value, location: &LazyLocation) -> EvaluationResult { let errors: Vec = self .iter_errors(instance, location) - .map(ErrorDescription::from) + .map(|e| ErrorDescription::from_validation_error(&e)) .collect(); if errors.is_empty() { - PartialApplication::valid_empty() + EvaluationResult::valid_empty() } else { - PartialApplication::invalid_empty(errors) + EvaluationResult::invalid_empty(errors) } } } -/// The result of applying a validator to an instance. As explained in the documentation for -/// `Validate::apply` this is a "partial" result because it does not include information about +/// The result of evaluating a validator against an instance. This is a "partial" result because it does not include information about /// where the error or annotation occurred. -#[derive(Clone, PartialEq)] -pub(crate) enum PartialApplication { +#[derive(PartialEq)] +pub(crate) enum EvaluationResult { Valid { /// Annotations produced by this validator annotations: Option, - /// Any outputs produced by validators which are children of this validator - child_results: Vec>, + /// Children evaluation nodes + children: Vec, }, Invalid { /// Errors which caused this schema to be invalid errors: Vec, - /// Any error outputs produced by child validators of this validator - child_results: Vec>, + /// Children evaluation nodes + children: Vec, + /// Potential annotations that should be reported as dropped on failure + annotations: Option, }, } -impl PartialApplication { - /// Create an empty `PartialApplication` which is valid - pub(crate) fn valid_empty() -> PartialApplication { - PartialApplication::Valid { +impl EvaluationResult { + /// Create an empty `EvaluationResult` which is valid + pub(crate) fn valid_empty() -> EvaluationResult { + EvaluationResult::Valid { annotations: None, - child_results: Vec::new(), + children: Vec::new(), } } - /// Create an empty `PartialApplication` which is invalid - pub(crate) fn invalid_empty(errors: Vec) -> PartialApplication { - PartialApplication::Invalid { + /// Create an empty `EvaluationResult` which is invalid + pub(crate) fn invalid_empty(errors: Vec) -> EvaluationResult { + EvaluationResult::Invalid { errors, - child_results: Vec::new(), + children: Vec::new(), + annotations: None, } } /// Set the annotation that will be returned for the current validator. If this - /// `PartialApplication` is invalid then this method does nothing + /// `EvaluationResult` is invalid then this method does nothing pub(crate) fn annotate(&mut self, new_annotations: Annotations) { match self { - Self::Valid { annotations, .. } => *annotations = Some(new_annotations), - Self::Invalid { .. } => {} + Self::Valid { annotations, .. } | Self::Invalid { annotations, .. } => { + *annotations = Some(new_annotations); + } } } /// Set the error that will be returned for the current validator. If this - /// `PartialApplication` is valid then this method converts this application into - /// `PartialApplication::Invalid` + /// `EvaluationResult` is valid then this method converts this application into + /// `EvaluationResult::Invalid` pub(crate) fn mark_errored(&mut self, error: ErrorDescription) { match self { Self::Invalid { errors, .. } => errors.push(error), - Self::Valid { .. } => { + Self::Valid { + annotations, + children, + } => { *self = Self::Invalid { errors: vec![error], - child_results: Vec::new(), + children: std::mem::take(children), + annotations: annotations.take(), } } } } + + pub(crate) fn from_children(children: Vec) -> EvaluationResult { + if children.iter().any(|node| !node.valid) { + EvaluationResult::Invalid { + errors: Vec::new(), + children, + annotations: None, + } + } else { + EvaluationResult::Valid { + annotations: None, + children, + } + } + } +} + +impl From for EvaluationResult { + fn from(node: EvaluationNode) -> Self { + if node.valid { + EvaluationResult::Valid { + annotations: None, + children: vec![node], + } + } else { + EvaluationResult::Invalid { + errors: Vec::new(), + children: vec![node], + annotations: None, + } + } + } } /// A compiled JSON Schema validator. @@ -258,51 +285,13 @@ impl Validator { pub fn is_valid(&self, instance: &Value) -> bool { self.root.is_valid(instance) } - /// Apply the schema and return an [`Output`]. No actual work is done at this point, the - /// evaluation of the schema is deferred until a method is called on the `Output`. This is - /// because different output formats will have different performance characteristics. - /// - /// # Examples - /// - /// "basic" output format - /// - /// ```rust - /// # fn main() -> Result<(), Box> { - /// use serde_json::json; - /// - /// let schema = json!({ - /// "title": "string value", - /// "type": "string" - /// }); - /// let instance = json!("some string"); - /// - /// let validator = jsonschema::validator_for(&schema) - /// .expect("Invalid schema"); - /// - /// let output = validator.apply(&instance).basic(); - /// assert_eq!( - /// serde_json::to_value(output)?, - /// json!({ - /// "valid": true, - /// "annotations": [ - /// { - /// "keywordLocation": "", - /// "instanceLocation": "", - /// "annotations": { - /// "title": "string value" - /// } - /// } - /// ] - /// }) - /// ); - /// # Ok(()) - /// # } - /// ``` + /// Evaluate the schema and expose structured output formats. #[must_use] - pub const fn apply<'a, 'b>(&'a self, instance: &'b Value) -> Output<'a, 'b> { - Output::new(self, &self.root, instance) + #[inline] + pub fn evaluate(&self, instance: &Value) -> Evaluation { + let root = self.root.evaluate_instance(instance, &LazyLocation::new()); + Evaluation::new(root) } - /// The [`Draft`] which was used to build this validator. #[must_use] pub fn draft(&self) -> Draft { diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/contains.json b/crates/jsonschema/tests/output-extra/v1-extra/content/contains.json new file mode 100644 index 000000000..2f2e7741d --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/contains.json @@ -0,0 +1,154 @@ +[ + { + "description": "contains annotations capture matching indices", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0", + "type": "array", + "contains": {"type": "integer"} + }, + "tests": [ + { + "description": "all items match", + "data": [1, 2], + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0/tests/0/list", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": true}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/contains/0#/contains"}, + "annotations": { + "type": "array", + "allOf": [ + {"contains": {"const": 0}}, + {"contains": {"const": 1}} + ] + } + }, + "required": ["schemaLocation", "annotations"] + } + } + }, + "required": ["valid", "details"] + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0/tests/0/hierarchical", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": true}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/contains/0#/contains"}, + "annotations": { + "type": "array", + "allOf": [ + {"contains": {"const": 0}}, + {"contains": {"const": 1}} + ] + } + }, + "required": ["schemaLocation", "annotations"] + } + } + }, + "required": ["valid", "details"] + } + } + }, + { + "description": "only a subset matches", + "data": [1, "foo"], + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0/tests/1/list", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": true}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/contains/0#/contains"}, + "annotations": { + "type": "array", + "contains": {"const": 0} + } + }, + "required": ["schemaLocation", "annotations"] + } + } + }, + "required": ["valid", "details"] + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0/tests/1/hierarchical", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": true}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/contains/0#/contains"}, + "annotations": { + "type": "array", + "contains": {"const": 0}, + "not": { + "contains": {"const": 1} + } + } + }, + "required": ["schemaLocation", "annotations"] + } + } + }, + "required": ["valid", "details"] + } + } + }, + { + "description": "no item satisfies contains", + "data": ["foo", "bar"], + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0/tests/2/list", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": false}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/contains/0#/contains"}, + "errors": {} + }, + "required": ["schemaLocation", "errors"] + } + } + }, + "required": ["valid", "details"] + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/contains/0/tests/2/hierarchical", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": false}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/contains/0#/contains"}, + "errors": {} + }, + "required": ["schemaLocation", "errors"] + } + } + }, + "required": ["valid", "details"] + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/dropped_annotations.json b/crates/jsonschema/tests/output-extra/v1-extra/content/dropped_annotations.json new file mode 100644 index 000000000..ae6292676 --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/dropped_annotations.json @@ -0,0 +1,21 @@ +[ + { + "description": "dropped annotations are preserved when requested", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/dropped_annotations/0", + "type": "object", + "properties": { + "foo": {"type": "string"}, + "fails": false + } + }, + "tests": [ + { + "description": "hierarchical output includes droppedProperties", + "data": {"fails": "value"}, + "output": {} + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/dynamic_ref_chain.json b/crates/jsonschema/tests/output-extra/v1-extra/content/dynamic_ref_chain.json new file mode 100644 index 000000000..1ef84c2e8 --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/dynamic_ref_chain.json @@ -0,0 +1,236 @@ +[ + { + "description": "dynamicRef recursion rebinds anchors", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "child": { + "$dynamicRef": "#node" + } + }, + "required": [ + "value" + ] + }, + "tests": [ + { + "description": "nested dynamicRef reports type error", + "data": { + "value": 1, + "child": { + "value": "boom" + } + }, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0/tests/0/list", + "const": { + "valid": false, + "details": [ + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#", + "instanceLocation": "" + }, + { + "valid": false, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties", + "instanceLocation": "", + "droppedAnnotations": [ + "child", + "value" + ] + }, + { + "valid": false, + "evaluationPath": "/properties/child", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child", + "instanceLocation": "/child" + }, + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef", + "instanceLocation": "/child" + }, + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/properties", + "instanceLocation": "/child", + "droppedAnnotations": [ + "value" + ] + }, + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef/properties/value", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/properties/value", + "instanceLocation": "/child/value" + }, + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef/properties/value/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/properties/value/type", + "instanceLocation": "/child/value", + "errors": { + "type": "\"boom\" is not of type \"integer\"" + } + }, + { + "valid": true, + "evaluationPath": "/properties/child/$dynamicRef/required", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/required", + "instanceLocation": "/child" + }, + { + "valid": true, + "evaluationPath": "/properties/child/$dynamicRef/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/type", + "instanceLocation": "/child" + }, + { + "valid": true, + "evaluationPath": "/properties/value", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/value", + "instanceLocation": "/value" + }, + { + "valid": true, + "evaluationPath": "/properties/value/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/value/type", + "instanceLocation": "/value" + }, + { + "valid": true, + "evaluationPath": "/required", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/required", + "instanceLocation": "" + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/type", + "instanceLocation": "" + } + ] + } + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0/tests/0/hierarchical", + "const": { + "valid": false, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#", + "instanceLocation": "", + "details": [ + { + "valid": false, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties", + "instanceLocation": "", + "droppedAnnotations": [ + "child", + "value" + ], + "details": [ + { + "valid": false, + "evaluationPath": "/properties/child", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child", + "instanceLocation": "/child", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef", + "instanceLocation": "/child", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/properties", + "instanceLocation": "/child", + "droppedAnnotations": [ + "value" + ], + "details": [ + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef/properties/value", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/properties/value", + "instanceLocation": "/child/value", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/child/$dynamicRef/properties/value/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/properties/value/type", + "instanceLocation": "/child/value", + "errors": { + "type": "\"boom\" is not of type \"integer\"" + } + } + ] + } + ] + }, + { + "valid": true, + "evaluationPath": "/properties/child/$dynamicRef/required", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/required", + "instanceLocation": "/child" + }, + { + "valid": true, + "evaluationPath": "/properties/child/$dynamicRef/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/child/$dynamicRef/type", + "instanceLocation": "/child" + } + ] + } + ] + }, + { + "valid": true, + "evaluationPath": "/properties/value", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/value", + "instanceLocation": "/value", + "details": [ + { + "valid": true, + "evaluationPath": "/properties/value/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/properties/value/type", + "instanceLocation": "/value" + } + ] + } + ] + }, + { + "valid": true, + "evaluationPath": "/required", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/required", + "instanceLocation": "" + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/dynamic_ref_chain/0#/type", + "instanceLocation": "" + } + ] + } + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/escape.json b/crates/jsonschema/tests/output-extra/v1-extra/content/escape.json new file mode 100644 index 000000000..855d63ed0 --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/escape.json @@ -0,0 +1,72 @@ +[ + { + "description": "property names are escaped in locations", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/escape/0", + "properties": { + "~a/b": {"type": "number"} + } + }, + "tests": [ + { + "description": "incorrect type reports escaped locations", + "data": {"~a/b": "foobar"}, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/escape/0/tests/0/list", + "$ref": "/v1/output/schema", + "properties": { + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/escape/0#/properties/~0a~1b/type"}, + "instanceLocation": {"const": "/~0a~1b"}, + "errors": { + "type": "object", + "minProperties": 1 + } + }, + "required": ["schemaLocation", "instanceLocation", "errors"] + } + } + }, + "required": ["details"] + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/escape/0/tests/0/hierarchical", + "$ref": "/v1/output/schema", + "properties": { + "details": { + "contains": { + "properties": { + "droppedAnnotations": { + "contains": {"const": "~a/b"} + }, + "details": { + "contains": { + "properties": { + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/escape/0#/properties/~0a~1b/type"}, + "instanceLocation": {"const": "/~0a~1b"} + }, + "required": ["schemaLocation", "instanceLocation"] + } + } + }, + "required": ["details"] + } + } + } + } + } + }, + "required": ["details"] + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/list-hierarchical.json b/crates/jsonschema/tests/output-extra/v1-extra/content/list-hierarchical.json new file mode 100644 index 000000000..092b87efd --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/list-hierarchical.json @@ -0,0 +1,382 @@ +[ + { + "description": "property annotations are reported for list and hierarchical outputs", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0", + "type": "object", + "$defs": { + "integer": { + "type": "integer" + }, + "minimum": { + "minimum": 5 + } + }, + "properties": { + "passes": true, + "fails": false, + "refs": { + "$ref": "#/$defs/integer" + }, + "multi": { + "allOf": [ + { + "$ref": "#/$defs/integer" + }, + { + "$ref": "#/$defs/minimum" + } + ] + } + } + }, + "tests": [ + { + "description": "list output includes root annotations", + "data": { + "passes": "value" + }, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0/tests/0/list", + "const": { + "valid": true, + "details": [ + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#", + "instanceLocation": "" + }, + { + "valid": true, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties", + "instanceLocation": "", + "annotations": [ + "passes" + ] + }, + { + "valid": true, + "evaluationPath": "/properties/passes", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/passes", + "instanceLocation": "/passes" + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/type", + "instanceLocation": "" + } + ] + } + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0/tests/0/hierarchical", + "const": { + "valid": true, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#", + "instanceLocation": "", + "details": [ + { + "valid": true, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties", + "instanceLocation": "", + "annotations": [ + "passes" + ], + "details": [ + { + "valid": true, + "evaluationPath": "/properties/passes", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/passes", + "instanceLocation": "/passes" + } + ] + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/type", + "instanceLocation": "" + } + ] + } + } + } + }, + { + "description": "false property is reported as error", + "data": { + "fails": "value" + }, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0/tests/1/list", + "const": { + "valid": false, + "details": [ + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#", + "instanceLocation": "" + }, + { + "valid": false, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties", + "instanceLocation": "", + "droppedAnnotations": [ + "fails" + ] + }, + { + "valid": false, + "evaluationPath": "/properties/fails", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/fails", + "instanceLocation": "/fails", + "errors": { + "falseSchema": "False schema does not allow \"value\"" + } + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/type", + "instanceLocation": "" + } + ] + } + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0/tests/1/hierarchical", + "const": { + "valid": false, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#", + "instanceLocation": "", + "details": [ + { + "valid": false, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties", + "instanceLocation": "", + "droppedAnnotations": [ + "fails" + ], + "details": [ + { + "valid": false, + "evaluationPath": "/properties/fails", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/fails", + "instanceLocation": "/fails", + "errors": { + "falseSchema": "False schema does not allow \"value\"" + } + } + ] + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/type", + "instanceLocation": "" + } + ] + } + } + } + }, + { + "description": "allOf failure exposes nested evaluation paths", + "data": { + "multi": 3.5 + }, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0/tests/2/list", + "const": { + "valid": false, + "details": [ + { + "valid": false, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#", + "instanceLocation": "" + }, + { + "valid": false, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties", + "instanceLocation": "", + "droppedAnnotations": [ + "multi" + ] + }, + { + "valid": false, + "evaluationPath": "/properties/multi", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi", + "instanceLocation": "/multi" + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf", + "instanceLocation": "/multi" + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/0", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/0", + "instanceLocation": "/multi" + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/0/$ref", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/0/$ref", + "instanceLocation": "/multi" + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/0/$ref/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/0/$ref/type", + "instanceLocation": "/multi", + "errors": { + "type": "3.5 is not of type \"integer\"" + } + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/1", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/1", + "instanceLocation": "/multi" + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/1/$ref", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/1/$ref", + "instanceLocation": "/multi" + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/1/$ref/minimum", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/1/$ref/minimum", + "instanceLocation": "/multi", + "errors": { + "minimum": "3.5 is less than the minimum of 5" + } + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/type", + "instanceLocation": "" + } + ] + } + }, + "hierarchical": { + "$id": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0/tests/2/hierarchical", + "const": { + "valid": false, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#", + "instanceLocation": "", + "details": [ + { + "valid": false, + "evaluationPath": "/properties", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties", + "instanceLocation": "", + "droppedAnnotations": [ + "multi" + ], + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi", + "instanceLocation": "/multi", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi/allOf", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf", + "instanceLocation": "/multi", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/0", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/0", + "instanceLocation": "/multi", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/0/$ref", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/0/$ref", + "instanceLocation": "/multi", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/0/$ref/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/0/$ref/type", + "instanceLocation": "/multi", + "errors": { + "type": "3.5 is not of type \"integer\"" + } + } + ] + } + ] + }, + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/1", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/1", + "instanceLocation": "/multi", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/1/$ref", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/1/$ref", + "instanceLocation": "/multi", + "details": [ + { + "valid": false, + "evaluationPath": "/properties/multi/allOf/1/$ref/minimum", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/properties/multi/allOf/1/$ref/minimum", + "instanceLocation": "/multi", + "errors": { + "minimum": "3.5 is less than the minimum of 5" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "valid": true, + "evaluationPath": "/type", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/list-hierarchical/0#/type", + "instanceLocation": "" + } + ] + } + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/properties.json b/crates/jsonschema/tests/output-extra/v1-extra/content/properties.json new file mode 100644 index 000000000..005002019 --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/properties.json @@ -0,0 +1,60 @@ +[ + { + "description": "multiple properties report independent errors", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/properties/0", + "type": "object", + "properties": { + "str": {"type": "string"}, + "num": {"type": "number"} + }, + "required": ["str", "num"] + }, + "tests": [ + { + "description": "both properties produce errors", + "data": {"str": 1, "num": "boom"}, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/properties/0/tests/0/list", + "$ref": "/v1/output/schema", + "properties": { + "details": { + "allOf": [ + { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/properties/0#/properties/str/type"}, + "instanceLocation": {"const": "/str"} + }, + "required": ["schemaLocation", "instanceLocation"] + } + }, + { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/properties/0#/properties/num/type"}, + "instanceLocation": {"const": "/num"} + }, + "required": ["schemaLocation", "instanceLocation"] + } + } + ] + } + }, + "required": ["details"] + }, + "flag": { + "$id": "https://json-schema.org/tests/content/v1-extra/properties/0/tests/0/flag", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": false} + }, + "required": ["valid"] + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_items.json b/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_items.json new file mode 100644 index 000000000..c62aea43e --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_items.json @@ -0,0 +1,38 @@ +[ + { + "description": "unevaluatedItems flags additional array elements", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/unevaluated_items/0", + "type": "array", + "prefixItems": [true], + "unevaluatedItems": false + }, + "tests": [ + { + "description": "extra item produces unevaluatedItems error", + "data": [1, 2], + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/unevaluated_items/0/tests/0/list", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": false}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/unevaluated_items/0#/unevaluatedItems"}, + "instanceLocation": {"const": "/1"}, + "errors": {} + }, + "required": ["schemaLocation", "instanceLocation", "errors"] + } + } + }, + "required": ["valid", "details"] + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_properties.json b/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_properties.json new file mode 100644 index 000000000..0eca9c796 --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_properties.json @@ -0,0 +1,40 @@ +[ + { + "description": "unevaluatedProperties error targets extra keys", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/unevaluated_properties/0", + "type": "object", + "properties": { + "foo": true + }, + "unevaluatedProperties": false + }, + "tests": [ + { + "description": "extra property produces unevaluatedProperties error", + "data": {"foo": null, "bar": null}, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/unevaluated_properties/0/tests/0/list", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": false}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/unevaluated_properties/0#/unevaluatedProperties"}, + "instanceLocation": {"const": "/bar"}, + "errors": {} + }, + "required": ["schemaLocation", "instanceLocation", "errors"] + } + } + }, + "required": ["valid", "details"] + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_properties_ref.json b/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_properties_ref.json new file mode 100644 index 000000000..6202ffe48 --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/unevaluated_properties_ref.json @@ -0,0 +1,46 @@ +[ + { + "description": "unevaluatedProperties fires through $ref", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/unevaluated_properties_ref/0", + "type": "object", + "$defs": { + "inner": { + "type": "object", + "properties": { + "foo": true + } + } + }, + "$ref": "#/$defs/inner", + "unevaluatedProperties": false + }, + "tests": [ + { + "description": "extra property still reports location", + "data": {"foo": null, "bar": null}, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/unevaluated_properties_ref/0/tests/0/list", + "$ref": "/v1/output/schema", + "properties": { + "valid": {"const": false}, + "details": { + "contains": { + "properties": { + "schemaLocation": {"const": "https://json-schema.org/tests/content/v1-extra/unevaluated_properties_ref/0#/unevaluatedProperties"}, + "instanceLocation": {"const": "/bar"}, + "errors": {} + }, + "required": ["schemaLocation", "instanceLocation", "errors"] + } + } + }, + "required": ["valid", "details"] + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/content/unknown_annotations.json b/crates/jsonschema/tests/output-extra/v1-extra/content/unknown_annotations.json new file mode 100644 index 000000000..a392ca1cd --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/content/unknown_annotations.json @@ -0,0 +1,35 @@ +[ + { + "description": "unknown keywords produce annotations", + "schema": { + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/tests/content/v1-extra/unknown_annotations/0", + "extra": "annotation" + }, + "tests": [ + { + "description": "list and hierarchical include extra annotation", + "data": {}, + "output": { + "list": { + "$id": "https://json-schema.org/tests/content/v1-extra/unknown_annotations/0/tests/0/list", + "const": { + "valid": true, + "details": [ + { + "valid": true, + "evaluationPath": "", + "schemaLocation": "https://json-schema.org/tests/content/v1-extra/unknown_annotations/0#", + "instanceLocation": "", + "annotations": { + "extra": "annotation" + } + } + ] + } + } + } + } + ] + } +] diff --git a/crates/jsonschema/tests/output-extra/v1-extra/output-schema.json b/crates/jsonschema/tests/output-extra/v1-extra/output-schema.json new file mode 100644 index 000000000..b3049644c --- /dev/null +++ b/crates/jsonschema/tests/output-extra/v1-extra/output-schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/v1", + "$id": "https://json-schema.org/v1/output/schema", + "description": "A schema that validates the minimum requirements for validation output", + + "anyOf": [ + { "$ref": "#/$defs/flag" }, + { "$ref": "#/$defs/basic" }, + { "$ref": "#/$defs/hierarchical" } + ], + "$defs": { + "outputUnit":{ + "properties": { + "valid": { "type": "boolean" }, + "evaluationPath": { + "type": "string", + "format": "json-pointer" + }, + "schemaLocation": { + "type": "string", + "format": "uri" + }, + "instanceLocation": { + "type": "string", + "format": "json-pointer" + }, + "details": { + "$ref": "#/$defs/outputUnitArray" + }, + "annotations": { + "type": "object", + "additionalProperties": true + }, + "droppedAnnotations": { + "type": "object", + "additionalProperties": true + }, + "errors": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + ] + } + } + }, + "required": [ "valid", "evaluationPath", "schemaLocation", "instanceLocation" ], + "allOf": [ + { + "if": { + "anyOf": [ + { + "required": [ "errors" ] + }, + { + "required": [ "droppedAnnotations" ] + } + ] + }, + "then": { + "properties": { + "valid": { "const": false } + } + } + }, + { + "if": { + "required": [ "annotations" ] + }, + "then": { + "properties": { + "valid": { "const": true } + } + } + } + ] + }, + "outputUnitArray": { + "type": "array", + "items": { "$ref": "#/$defs/outputUnit" } + }, + "flag": { + "properties": { + "valid": { "type": "boolean" } + }, + "required": [ "valid" ] + }, + "basic": { + "properties": { + "valid": { "type": "boolean" }, + "details": { + "$ref": "#/$defs/outputUnitArray" + } + }, + "required": [ "valid", "details" ] + }, + "hierarchical": { "$ref": "#/$defs/outputUnit" } + } +} diff --git a/crates/jsonschema/tests/output.rs b/crates/jsonschema/tests/output.rs deleted file mode 100644 index bab4bde3c..000000000 --- a/crates/jsonschema/tests/output.rs +++ /dev/null @@ -1,1019 +0,0 @@ -use serde_json::json; -use test_case::test_case; - -#[test_case{ - &json!({"allOf": [{"type": "string", "typeannotation": "value"}, {"maxLength": 20, "lengthannotation": "value"}]}), - &json!("some string"), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/allOf/0", - "instanceLocation": "", - "annotations": { - "typeannotation": "value" - } - }, - { - "keywordLocation": "/allOf/1", - "instanceLocation": "", - "annotations": { "lengthannotation": "value" } } - ] - }); "valid allOf" -}] -#[test_case{ - &json!({"allOf": [{"type": "array"}, {"maxLength": 4}]}), - &json!("some string"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/allOf/0/type", - "instanceLocation": "", - "error": "\"some string\" is not of type \"array\"" - }, - { - "keywordLocation": "/allOf/1/maxLength", - "instanceLocation": "", - "error": "\"some string\" is longer than 4 characters" - } - ] - }); "invalid allOf" -}] -#[test_case{ - &json!({"allOf": [{"type": "string", "typeannotation": "value"}]}), - &json!("some string"), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/allOf/0", - "instanceLocation": "", - "annotations": { - "typeannotation": "value" - } - } - ] - }); "valid single value allOf" -}] -#[test_case{ - &json!({"allOf": [{"type": "array"}]}), - &json!("some string"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/allOf/0/type", - "instanceLocation": "", - "error": "\"some string\" is not of type \"array\"" - } - ] - }); "invalid single value allOf" -}] -#[test_case{ - &json!({"anyOf": [{"type": "string", "someannotation": "value"}, {"maxLength": 4}, {"minLength": 1}]}), - &json!("some string"), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/anyOf/0", - "instanceLocation": "", - "annotations": { - "someannotation": "value" - } - } - ] - }); "valid anyOf" -}] -#[test_case{ - &json!({"anyOf": [{"type": "object"}, {"maxLength": 4}]}), - &json!("some string"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/anyOf/0/type", - "instanceLocation": "", - "error": "\"some string\" is not of type \"object\"" - }, - { - "keywordLocation": "/anyOf/1/maxLength", - "instanceLocation": "", - "error": "\"some string\" is longer than 4 characters" - } - ] - }); "invalid anyOf" -}] -#[test_case{ - &json!({"oneOf": [{"type": "object", "someannotation": "somevalue"}, {"type": "string"}]}), - &json!({"somekey": "some value"}), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/oneOf/0", - "instanceLocation": "", - "annotations": { - "someannotation": "somevalue" - } - } - ] - }); "valid oneOf" -}] -#[test_case{ - &json!({"oneOf": [{"type": "object"}, {"maxLength": 4}]}), - &json!("some string"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/oneOf/0/type", - "instanceLocation": "", - "error": "\"some string\" is not of type \"object\"" - }, - { - "keywordLocation": "/oneOf/1/maxLength", - "instanceLocation": "", - "error": "\"some string\" is longer than 4 characters" - } - ] - }); "invalid oneOf" -}] -#[test_case{ - &json!({"oneOf": [{"type": "string"}, {"maxLength": 40}]}), - &json!("some string"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/oneOf", - "instanceLocation": "", - "error": "more than one subschema succeeded" - }, - ] - }); "invalid oneOf multiple successes" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "then": {"maxLength": 20, "thenannotation": "thenvalue"} - }), - &json!("some string"), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/if", - "instanceLocation": "", - "annotations": { - "ifannotation": "ifvalue" - } - }, - { - "keywordLocation": "/then", - "instanceLocation": "", - "annotations": { - "thenannotation": "thenvalue" - } - }, - ] - }); "valid if-then" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "then": {"maxLength": 4, "thenannotation": "thenvalue"} - }), - &json!("some string"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/then/maxLength", - "instanceLocation": "", - "error": "\"some string\" is longer than 4 characters" - }, - ] - }); "invalid if-then" -}] -#[test_case{ - &json!({ - "if": {"type": "object", "ifannotation": "ifvalue"}, - "else": {"maxLength": 20, "elseannotation": "elsevalue"} - }), - &json!("some string"), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/else", - "instanceLocation": "", - "annotations": { - "elseannotation": "elsevalue" - } - }, - ] - }); "valid if-else" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "else": {"type": "array", "elseannotation": "elsevalue"} - }), - &json!({"some": "object"}), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/else/type", - "instanceLocation": "", - "error": "{\"some\":\"object\"} is not of type \"array\"" - }, - ] - }); "invalid if-else" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "then": {"maxLength": 20, "thenannotation": "thenvalue"}, - "else": {"type": "number", "elseannotation": "elsevalue"} - }), - &json!("some string"), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/if", - "instanceLocation": "", - "annotations": { - "ifannotation": "ifvalue" - } - }, - { - "keywordLocation": "/then", - "instanceLocation": "", - "annotations": { - "thenannotation": "thenvalue" - } - }, - ] - }); "valid if-then-else then-branch" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "then": {"maxLength": 20, "thenannotation": "thenvalue"}, - "else": {"type": "number", "elseannotation": "elsevalue"} - }), - &json!(12), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/else", - "instanceLocation": "", - "annotations": { - "elseannotation": "elsevalue" - } - }, - ] - }); "valid if-then-else else-branch" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "then": {"maxLength": 4, "thenannotation": "thenvalue"}, - "else": {"type": "number", "elseannotation": "elsevalue"} - }), - &json!("12345"), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/then/maxLength", - "instanceLocation": "", - "error": "\"12345\" is longer than 4 characters" - }, - ] }); "invalid if-then-else then branch" -}] -#[test_case{ - &json!({ - "if": {"type": "string", "ifannotation": "ifvalue"}, - "then": {"maxLength": 20, "thenannotation": "thenvalue"}, - "else": {"type": "number", "elseannotation": "elsevalue"} - }), - &json!({"some": "object"}), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/else/type", - "instanceLocation": "", - "error": "{\"some\":\"object\"} is not of type \"number\"" - }, - ] - }); "invalid if-then-else else branch" -}] -#[test_case{ - &json!({ - "type": "array", - "items": { - "type": "number", - "annotation": "value" - } - }), - &json!([1,2]), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/items", - "instanceLocation": "", - "annotations": true - }, - { - "keywordLocation": "/items", - "instanceLocation": "/0", - "annotations": { - "annotation": "value" - } - }, - { - "keywordLocation": "/items", - "instanceLocation": "/1", - "annotations": { - "annotation": "value" - } - }, - ] - }); "valid items" -}] -#[test_case{ - &json!({ - "type": "array", - "items": { - "type": "number", - "annotation": "value" - } - }), - &json!([]), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/items", - "instanceLocation": "", - "annotations": false - }, - ] - }); "valid items empty array" -}] -#[test_case{ - &json!({ - "type": "array", - "items": { - "type": "string", - "annotation": "value" - } - }), - &json!([1,2,"3"]), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/items/type", - "instanceLocation": "/0", - "error": "1 is not of type \"string\"" - }, - { - "keywordLocation": "/items/type", - "instanceLocation": "/1", - "error": "2 is not of type \"string\"" - }, - ] - }); "invalid items" -}] -#[test_case{ - &json!({ - "contains": { - "type": "number", - "annotation": "value", - "maximum": 2 - } - }), - &json!([1,3,2]), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/contains", - "instanceLocation": "", - "annotations": [0, 2] - }, - { - "keywordLocation": "/contains", - "instanceLocation": "/0", - "annotations": { - "annotation": "value" - } - }, - { - "keywordLocation": "/contains", - "instanceLocation": "/2", - "annotations": { - "annotation": "value" - } - } - ] - }); "valid contains" -}] -#[test_case{ - &json!({ - "contains": { - "type": "number", - "annotation": "value", - "maximum": 2 - } - }), - &json!(["one"]), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/contains", - "instanceLocation": "", - "error": "None of [\"one\"] are valid under the given schema", - }, - ] - }); "invalid contains" -}] -#[test_case{ - &json!({ - "properties": { - "name": {"type": "string", "some": "subannotation"}, - "age": {"type": "number"} - } - }), - &json!({ - "name": "some name", - "age": 10 - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/properties", - "instanceLocation": "", - "annotations": [ - "age", - "name" - ] - }, - { - "keywordLocation": "/properties/name", - "instanceLocation": "/name", - "annotations": { - "some": "subannotation" - } - } - ] - }); "valid properties" -}] -#[test_case{ - &json!({ - "patternProperties": { - "numProp(\\d+)": {"type": "number", "some": "subannotation"}, - "stringProp(\\d+)": {"type": "string"}, - "unmatchedProp\\S": {"type": "object"}, - } - }), - &json!({ - "numProp1": 1, - "numProp2": 2, - "stringProp1": "1" - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/patternProperties", - "instanceLocation": "", - "annotations": [ - "numProp1", - "numProp2", - "stringProp1" - ] - }, - { - "keywordLocation": "/patternProperties/numProp(\\d+)", - "instanceLocation": "/numProp1", - "annotations": { - "some": "subannotation" - } - }, - { - "keywordLocation": "/patternProperties/numProp(\\d+)", - "instanceLocation": "/numProp2", - "annotations": { - "some": "subannotation" - } - } - ] - }); "valid patternProperties" -}] -#[test_case{ - &json!({ - "patternProperties": { - "numProp(\\d+)": {"type": "number", "some": "subannotation"} - } - }), - &json!({ - "numProp1": 1, - "numProp2": 2, - "stringProp1": "1" - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/patternProperties", - "instanceLocation": "", - "annotations": [ - "numProp1", - "numProp2", - ] - }, - { - "keywordLocation": "/patternProperties/numProp(\\d+)", - "instanceLocation": "/numProp1", - "annotations": { - "some": "subannotation" - } - }, - { - "keywordLocation": "/patternProperties/numProp(\\d+)", - "instanceLocation": "/numProp2", - "annotations": { - "some": "subannotation" - } - } - ] - }); "valid single value patternProperties" -}] -#[test_case{ - &json!({ - "propertyNames": {"maxLength": 10, "some": "annotation"} - }), - &json!({ - "name": "some name", - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/propertyNames", - "instanceLocation": "", - "annotations": {"some": "annotation"} - }, - ] - }); "valid propertyNames" -}] -fn test_basic_output( - schema: &serde_json::Value, - instance: &serde_json::Value, - expected_output: &serde_json::Value, -) { - let validator = jsonschema::validator_for(schema).unwrap(); - let output = serde_json::to_value(validator.apply(instance).basic()).unwrap(); - assert_eq!(&output, expected_output); -} - -/// These tests are separated from the rest of the basic output tests for convenience, there's -/// nothing different about them but they are all tests of the additionalProperties keyword, which -/// is complicated by the fact that there are eight different implementations based on the -/// interaction between the properties, patternProperties, and additionalProperties keywords. -/// Specifically there are these implementations: -/// -/// - `AdditionalPropertiesValidator` -/// - `AdditionalPropertiesFalseValidator` -/// - `AdditionalPropertiesNotEmptyFalseValidator` -/// - `AdditionalPropertiesNotEmptyValidator` -/// - `AdditionalPropertiesWithPatternsValidator` -/// - `AdditionalPropertiesWithPatternsFalseValidator` -/// - `AdditionalPropertiesWithPatternsNotEmptyValidator` -/// - `AdditionalPropertiesWithPatternsNotEmptyFalseValidator` -/// -/// For each of these we need two test cases, one for errors and one for annotations -#[test_case{ - &json!({ - "additionalProperties": {"type": "number" } - }), - &json!({ - "name": "somename", - "otherprop": "one" - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties/type", - "instanceLocation": "/name", - "error": "\"somename\" is not of type \"number\"" - }, - { - "keywordLocation": "/additionalProperties/type", - "instanceLocation": "/otherprop", - "error": "\"one\" is not of type \"number\"" - }, - ] - }); "invalid AdditionalPropertiesValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": {"type": "number", "some": "annotation" } - }), - &json!({ - "name": 1, - "otherprop": 2 - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "annotations": ["name", "otherprop"] - }, - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "/name", - "annotations": { - "some": "annotation" - } - }, - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "/otherprop", - "annotations": { - "some": "annotation" - } - }, - ] - }); "valid AdditionalPropertiesValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": false - }), - &json!({ - "name": "somename", - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "error": "False schema does not allow \"somename\"" - }, - ] - }); "invalid AdditionalPropertiesFalseValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": false - }), - &json!({}), - &json!({ - "valid": true, - "annotations": [] - }); "valid AdditionalPropertiesFalseValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": false, - "properties": { - "name": {"type": "string", "prop": "annotation"} - } - }), - &json!({ - "name": "somename", - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/properties/name", - "instanceLocation": "/name", - "annotations": {"prop": "annotation"} - } - ] - }); "valid AdditionalPropertiesNotEmptyFalseValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": false, - "properties": { - "name": {"type": "string", "prop": "annotation"} - } - }), - &json!({ - "name": "somename", - "other": "prop" - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "error": "Additional properties are not allowed ('other' was unexpected)" - } - ] - }); "invalid AdditionalPropertiesNotEmptyFalseValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": {"type": "integer", "other": "annotation"}, - "properties": { - "name": {"type": "string", "prop": "annotation"} - } - }), - &json!({ - "name": "somename", - "otherprop": 1 - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "annotations": ["otherprop"] - }, - { - "keywordLocation": "/properties/name", - "instanceLocation": "/name", - "annotations": {"prop": "annotation"} - }, - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "/otherprop", - "annotations": {"other": "annotation"} - } - ] - }); "valid AdditionalPropertiesNotEmptyValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": {"type": "integer", "other": "annotation"}, - "properties": { - "name": {"type": "string", "prop": "annotation"} - } - }), - &json!({ - "name": "somename", - "otherprop": "one" - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties/type", - "instanceLocation": "/otherprop", - "error": "\"one\" is not of type \"integer\"" - }, - ] - }); "invalid AdditionalPropertiesNotEmptyValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": {"type": "string", "other": "annotation"}, - "patternProperties": { - "^x-": {"type": "integer", "minimum": 5, "patternio": "annotation"}, - } - }), - &json!({ - "otherprop": "one", - "x-foo": 7 - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "annotations": ["otherprop"] - }, - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "/otherprop", - "annotations": {"other": "annotation"} - }, - { - "keywordLocation": "/patternProperties/^x-", - "instanceLocation": "/x-foo", - "annotations": {"patternio": "annotation"} - }, - { - "keywordLocation": "/patternProperties", - "instanceLocation": "", - "annotations": ["x-foo"] - } - ] - }); "valid AdditionalPropertiesWithPatternsValidator" -}] -#[test_case{ - &json!({ - "additionalProperties": {"type": "string" }, - "patternProperties": { - "^x-": {"type": "integer", "minimum": 5 }, - } - }), - &json!({ - "otherprop":1, - "x-foo": 3 - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties/type", - "instanceLocation": "/otherprop", - "error": "1 is not of type \"string\"" - }, - { - "keywordLocation": "/patternProperties/^x-/minimum", - "instanceLocation": "/x-foo", - "error": "3 is less than the minimum of 5" - }, - ] - }); "invalid AdditionalPropertiesWithPatternsValidator" -}] -#[test_case{ - &json!({ - "properties": { - "name": {"type": "string"} - }, - "patternProperties": { - "stringProp(\\d+)": {"type": "string" } - }, - "additionalProperties": {"type": "number" } - }), - &json!({ - "name": "somename", - "otherprop": "one" - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties/type", - "instanceLocation": "/otherprop", - "error": "\"one\" is not of type \"number\"" - }, - ] - }); "invalid AdditionalPropertiesWithPatternsNotEmptyValidator" -}] -#[test_case{ - &json!({ - "properties": { - "name": {"type": "string"} - }, - "patternProperties": { - "stringProp(\\d+)": {"type": "string" } - }, - "additionalProperties": {"type": "number" } - }), - &json!({ - "name": "somename", - "otherprop": 1 - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "annotations": ["otherprop"] - } - ] - }); "valid AdditionalPropertiesWithPatternsNotEmptyValidator" -}] -#[test_case{ - &json!({ - "properties": { - "name": {"type": "string", "prop": "annotation"} - }, - "patternProperties": { - "stringProp(\\d+)": {"type": "string" } - }, - "additionalProperties": false - }), - &json!({ - "name": "somename", - "stringProp1": "one" - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/properties/name", - "instanceLocation": "/name", - "annotations": { - "prop": "annotation" - } - } - ] - }); "valid AdditionalPropertiesWithPatternsNotEmptyFalseValidator" -}] -#[test_case{ - &json!({ - "properties": { - "name": {"type": "string", "prop": "annotation"} - }, - "patternProperties": { - "stringProp(\\d+)": {"type": "string" } - }, - "additionalProperties": false - }), - &json!({ - "name": "somename", - "stringProp1": "one", - "otherprop": "something" - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "error": "Additional properties are not allowed ('otherprop' was unexpected)" - } - ] - }); "invalid AdditionalPropertiesWithPatternsNotEmptyFalseValidator" -}] -#[test_case{ - &json!({ - "patternProperties": { - "stringProp(\\d+)": {"type": "string", "some": "annotation"} - }, - "additionalProperties": false - }), - &json!({ - "stringProp1": "one", - }), - &json!({ - "valid": true, - "annotations": [ - { - "keywordLocation": "/patternProperties/stringProp(\\d+)", - "instanceLocation": "/stringProp1", - "annotations": { - "some": "annotation" - } - }, - { - "keywordLocation": "/patternProperties", - "instanceLocation": "", - "annotations": ["stringProp1"] - }, - ] - }); "valid AdditionalPropertiesWithPatternsFalseValidator" -}] -#[test_case{ - &json!({ - "patternProperties": { - "stringProp(\\d+)": {"type": "string" } - }, - "additionalProperties": false - }), - &json!({ - "stringProp1": "one", - "otherprop": "something" - }), - &json!({ - "valid": false, - "errors": [ - { - "keywordLocation": "/additionalProperties", - "instanceLocation": "", - "error": "Additional properties are not allowed ('otherprop' was unexpected)" - } - ] - }); "invalid AdditionalPropertiesWithPatternsFalseValidator" -}] -fn test_additional_properties_basic_output( - schema: &serde_json::Value, - instance: &serde_json::Value, - expected: &serde_json::Value, -) { - let validator = jsonschema::validator_for(schema).unwrap(); - let output = serde_json::to_value(validator.apply(instance).basic()).unwrap(); - if &output != expected { - let expected_str = serde_json::to_string_pretty(expected).unwrap(); - let actual_str = serde_json::to_string_pretty(&output).unwrap(); - panic!("\nExpected:\n{expected_str}\n\nGot:\n{actual_str}\n"); - } -} diff --git a/crates/jsonschema/tests/output_spec_schema.json b/crates/jsonschema/tests/output_spec_schema.json new file mode 100644 index 000000000..f89aa7a4b --- /dev/null +++ b/crates/jsonschema/tests/output_spec_schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/next/schema", + "$id": "https://json-schema.org/draft/next/output/schema", + "description": "A schema that validates the minimum requirements for validation output", + + "anyOf": [ + { "$ref": "#/$defs/flag" }, + { "$ref": "#/$defs/list" }, + { "$ref": "#/$defs/hierarchical" } + ], + "$defs": { + "outputUnit": { + "properties": { + "valid": { "type": "boolean" }, + "evaluationPath": { + "type": "string", + "format": "json-pointer" + }, + "schemaLocation": { + "type": "string", + "format": "uri" + }, + "instanceLocation": { + "type": "string", + "format": "json-pointer" + }, + "details": { + "$ref": "#/$defs/outputUnitArray" + }, + "annotations": { + "type": "object", + "additionalProperties": true + }, + "droppedAnnotations": { + "type": "object", + "additionalProperties": true + }, + "errors": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + ] + } + } + }, + "required": [ "valid", "evaluationPath", "schemaLocation", "instanceLocation" ], + "allOf": [ + { + "if": { + "anyOf": [ + { + "required": [ "errors" ] + }, + { + "required": [ "droppedAnnotations" ] + } + ] + }, + "then": { + "properties": { + "valid": { "const": false } + } + } + }, + { + "if": { + "required": [ "annotations" ] + }, + "then": { + "properties": { + "valid": { "const": true } + } + } + } + ] + }, + "outputUnitArray": { + "type": "array", + "items": { "$ref": "#/$defs/outputUnit" } + }, + "flag": { + "properties": { + "valid": { "type": "boolean" } + }, + "required": [ "valid" ] + }, + "list": { + "properties": { + "valid": { "type": "boolean" }, + "details": { "$ref": "#/$defs/outputUnitArray" } + }, + "required": [ "valid", "details" ] + }, + "hierarchical": { "$ref": "#/$defs/outputUnit" } + } +} diff --git a/crates/jsonschema/tests/output_suite.rs b/crates/jsonschema/tests/output_suite.rs new file mode 100644 index 000000000..3326872bb --- /dev/null +++ b/crates/jsonschema/tests/output_suite.rs @@ -0,0 +1,181 @@ +#![cfg(not(target_arch = "wasm32"))] + +use jsonschema::{validator_for, Draft, Evaluation, Retrieve, Uri, Validator}; +use serde_json::Value; +use std::sync::OnceLock; +use testsuite::{output_suite, OutputRemote, OutputTest}; + +#[output_suite( + path = "crates/jsonschema/tests/suite/output-tests", + drafts = [ + "v1" + ] +)] +fn output_suite(test: OutputTest) { + run_output_case(test); +} + +#[output_suite( + path = "crates/jsonschema/tests/output-extra", + drafts = [ + "v1-extra" + ] +)] +fn output_suite_extra(test: OutputTest) { + run_output_case(test); +} + +#[allow(clippy::print_stderr)] +fn run_output_case(test: OutputTest) { + let OutputTest { + version, + file, + schema, + case, + description, + data, + outputs, + remotes, + } = test; + + let prepared_schema = prepare_schema_for_version(&schema, version); + let validator = build_validator(&prepared_schema, version, file); + let evaluation = validator.evaluate(&data); + let retriever = output_schema_retriever(remotes); + + for expected in outputs { + let format = expected.format; + let schema = expected.schema; + let expected_schema = prepare_schema_for_version(&schema, version); + let actual_output = produce_output(&evaluation, format).unwrap_or_else(|| { + panic!( + "Output format `{format}` is not supported (file: {file}, case: `{case}`, test: `{description}`)" + ) + }); + validate_against_output_spec(&actual_output); + let mut options = jsonschema::options().with_retriever(retriever); + if let Some(draft) = version_draft_override(version) { + options = options.with_draft(draft); + } + let output_validator = options.build(&expected_schema).unwrap_or_else(|err| { + panic!("Invalid output schema for {file} format {format}: {err}") + }); + if let Err(error) = output_validator.validate(&actual_output) { + eprintln!("Output validation error: {error:?}"); + panic!( + "Output format `{format}` failed for {file} (case: `{case}`, test: `{description}`): {error}" + ); + } + } +} + +fn output_spec_validator() -> &'static Validator { + static VALIDATOR: OnceLock = OnceLock::new(); + VALIDATOR.get_or_init(|| { + let mut schema: Value = serde_json::from_str(include_str!("output_spec_schema.json")) + .expect("output spec schema JSON is valid"); + if let Value::Object(ref mut map) = schema { + map.remove("$schema"); + } + validator_for(&schema).expect("output spec schema must be valid") + }) +} + +fn validate_against_output_spec(value: &Value) { + if let Err(error) = output_spec_validator().validate(value) { + panic!("Output does not match JSON Schema validation-output schema: {error}"); + } +} + +fn build_validator(schema: &Value, version: &str, file: &str) -> Validator { + match version_draft_override(version) { + Some(draft) => jsonschema::options() + .with_draft(draft) + .build(schema) + .unwrap_or_else(|err| panic!("Invalid schema in {file}: {err}")), + None => { + validator_for(schema).unwrap_or_else(|err| panic!("Invalid schema in {file}: {err}")) + } + } +} + +fn produce_output(evaluation: &Evaluation, format: &str) -> Option { + match format { + "flag" => { + let value = serde_json::to_value(evaluation.flag()).expect("flag output serializable"); + debug_output("flag", &value); + Some(value) + } + "list" => { + let value = serde_json::to_value(evaluation.list()).expect("list output serializable"); + debug_output("list", &value); + Some(value) + } + "hierarchical" => { + let value = serde_json::to_value(evaluation.hierarchical()) + .expect("hierarchical output serializable"); + debug_output("hierarchical", &value); + Some(value) + } + _ => None, + } +} + +// Prints serialized output when `JSONSCHEMA_DEBUG_OUTPUT` is set. +#[allow(clippy::print_stderr)] +fn debug_output(format: &str, value: &Value) { + if std::env::var("JSONSCHEMA_DEBUG_OUTPUT").is_ok() { + eprintln!( + "=== {format} ===\n{}", + serde_json::to_string_pretty(value).expect("output to stringify") + ); + } +} + +fn prepare_schema_for_version(schema: &Value, version: &str) -> Value { + if is_v1(version) { + if let Value::Object(mut map) = schema.clone() { + map.remove("$schema"); + map.into() + } else { + schema.clone() + } + } else { + schema.clone() + } +} + +fn version_draft_override(version: &str) -> Option { + match version { + v if is_v1(v) => Some(Draft::Draft202012), + _ => None, + } +} + +fn is_v1(version: &str) -> bool { + version == "v1" || version.starts_with("v1-") +} + +fn output_schema_retriever(remotes: &'static [OutputRemote]) -> OutputSchemaRetriever { + OutputSchemaRetriever { documents: remotes } +} + +#[derive(Clone, Copy)] +struct OutputSchemaRetriever { + documents: &'static [OutputRemote], +} + +impl Retrieve for OutputSchemaRetriever { + fn retrieve( + &self, + uri: &Uri, + ) -> Result> { + self.documents + .iter() + .find(|doc| doc.uri == uri.as_str()) + .map(|doc| { + serde_json::from_str(doc.contents).expect("Output schema must be valid JSON") + }) + .ok_or_else(|| format!("Unknown output schema reference: {uri}").into()) + } +} diff --git a/crates/jsonschema/tests/suite.rs b/crates/jsonschema/tests/suite.rs index 53d1fb3f8..4637b43ec 100644 --- a/crates/jsonschema/tests/suite.rs +++ b/crates/jsonschema/tests/suite.rs @@ -89,16 +89,19 @@ mod tests { pretty_json(&test.schema), pretty_json(&test.data), ); - let output = validator.apply(&test.data).basic(); + let evaluation = validator.evaluate(&test.data); assert!( - output.is_valid(), - "Test case should be valid via basic output:\nCase: {}\nTest: {}\nSchema: {}\nInstance: {}\nError: {:?}", - test.case, - test.description, - pretty_json(&test.schema), - pretty_json(&test.data), - output - ); + evaluation.flag().valid, + "Evaluation output should be valid:\nCase: {}\nTest: {}\nSchema: {}\nInstance: {}", + test.case, + test.description, + pretty_json(&test.schema), + pretty_json(&test.data), + ); + let _ = + serde_json::to_value(evaluation.list()).expect("List output should serialize"); + let _ = serde_json::to_value(evaluation.hierarchical()) + .expect("Hierarchical output should serialize"); } else { let errors = validator.iter_errors(&test.data).collect::>(); assert!( @@ -150,15 +153,19 @@ mod tests { &*error.instance, &pointer, ); - let output = validator.apply(&test.data).basic(); + let evaluation = validator.evaluate(&test.data); assert!( - !output.is_valid(), - "Test case should be invalid via basic output:\nCase: {}\nTest: {}\nSchema: {}\nInstance: {}", - test.case, - test.description, - pretty_json(&test.schema), - pretty_json(&test.data), - ); + !evaluation.flag().valid, + "Evaluation output should be invalid:\nCase: {}\nTest: {}\nSchema: {}\nInstance: {}", + test.case, + test.description, + pretty_json(&test.schema), + pretty_json(&test.data), + ); + let _ = + serde_json::to_value(evaluation.list()).expect("List output should serialize"); + let _ = serde_json::to_value(evaluation.hierarchical()) + .expect("Hierarchical output should serialize"); } } } diff --git a/fuzz/fuzz_targets/validation.rs b/fuzz/fuzz_targets/validation.rs index 5bfb154b3..036ab22fd 100644 --- a/fuzz/fuzz_targets/validation.rs +++ b/fuzz/fuzz_targets/validation.rs @@ -11,8 +11,12 @@ fuzz_target!(|data: (&[u8], &[u8])| { for error in validator.iter_errors(&instance) { let _ = error.to_string(); } - let output = validator.apply(&instance).basic(); - let _ = serde_json::to_value(output).expect("Failed to serialize"); + let evaluation = validator.evaluate(&instance); + let _ = evaluation.flag(); + let _ = serde_json::to_value(evaluation.list()) + .expect("Failed to serialize list output"); + let _ = serde_json::to_value(evaluation.hierarchical()) + .expect("Failed to serialize hierarchical output"); } } } diff --git a/profiler/Justfile b/profiler/Justfile index c7ffc0295..ca7ac5db4 100644 --- a/profiler/Justfile +++ b/profiler/Justfile @@ -47,37 +47,37 @@ citm-build: (flame "citm" "build" "10000") citm-is-valid: (flame "citm" "is_valid" "10000") citm-validate: (flame "citm" "validate" "1000") citm-iter-errors: (flame "citm" "iter_errors" "1000") -citm-apply: (flame "citm" "apply" "1000") +citm-evaluate: (flame "citm" "evaluate" "1000") openapi-build: (flame "openapi" "build" "10000") openapi-is-valid: (flame "openapi" "is_valid" "10000") openapi-validate: (flame "openapi" "validate" "1000") openapi-iter-errors: (flame "openapi" "iter_errors" "1000") -openapi-apply: (flame "openapi" "apply" "1000") +openapi-evaluate: (flame "openapi" "evaluate" "1000") swagger-build: (flame "swagger" "build" "5000") swagger-is-valid: (flame "swagger" "is_valid" "5000") swagger-validate: (flame "swagger" "validate" "500") swagger-iter-errors: (flame "swagger" "iter_errors" "500") -swagger-apply: (flame "swagger" "apply" "500") +swagger-evaluate: (flame "swagger" "evaluate" "500") geojson-build: (flame "geojson" "build" "5000") geojson-is-valid: (flame "geojson" "is_valid" "5000") geojson-validate: (flame "geojson" "validate" "500") geojson-iter-errors: (flame "geojson" "iter_errors" "500") -geojson-apply: (flame "geojson" "apply" "500") +geojson-evaluate: (flame "geojson" "evaluate" "500") fast-valid-build: (flame "fast-valid" "build" "10000") fast-valid-is-valid: (flame "fast-valid" "is_valid" "10000") fast-valid-validate: (flame "fast-valid" "validate" "10000") fast-valid-iter-errors: (flame "fast-valid" "iter_errors" "10000") -fast-valid-apply: (flame "fast-valid" "apply" "10000") +fast-valid-evaluate: (flame "fast-valid" "evaluate" "10000") fast-invalid-build: (flame "fast-invalid" "build" "10000") fast-invalid-is-valid: (flame "fast-invalid" "is_valid" "10000") fast-invalid-validate: (flame "fast-invalid" "validate" "10000") fast-invalid-iter-errors: (flame "fast-invalid" "iter_errors" "10000") -fast-invalid-apply: (flame "fast-invalid" "apply" "10000") +fast-invalid-evaluate: (flame "fast-invalid" "evaluate" "10000") registry: (flame "citm" "registry" "1000") @@ -85,25 +85,25 @@ dhat-citm-build: (dhat "citm" "build" "10000") dhat-citm-is-valid: (dhat "citm" "is_valid" "10000") dhat-citm-validate: (dhat "citm" "validate" "1000") dhat-citm-iter-errors: (dhat "citm" "iter_errors" "1000") -dhat-citm-apply: (dhat "citm" "apply" "1000") +dhat-citm-evaluate: (dhat "citm" "evaluate" "1000") dhat-openapi-build: (dhat "openapi" "build" "10000") dhat-openapi-is-valid: (dhat "openapi" "is_valid" "10000") dhat-openapi-validate: (dhat "openapi" "validate" "1000") dhat-openapi-iter-errors: (dhat "openapi" "iter_errors" "1000") -dhat-openapi-apply: (dhat "openapi" "apply" "1000") +dhat-openapi-evaluate: (dhat "openapi" "evaluate" "1000") dhat-fast-valid-build: (dhat "fast-valid" "build" "10000") dhat-fast-valid-is-valid: (dhat "fast-valid" "is_valid" "10000") dhat-fast-valid-validate: (dhat "fast-valid" "validate" "10000") dhat-fast-valid-iter-errors: (dhat "fast-valid" "iter_errors" "10000") -dhat-fast-valid-apply: (dhat "fast-valid" "apply" "10000") +dhat-fast-valid-evaluate: (dhat "fast-valid" "evaluate" "10000") dhat-fast-invalid-build: (dhat "fast-invalid" "build" "10000") dhat-fast-invalid-is-valid: (dhat "fast-invalid" "is_valid" "10000") dhat-fast-invalid-validate: (dhat "fast-invalid" "validate" "10000") dhat-fast-invalid-iter-errors: (dhat "fast-invalid" "iter_errors" "10000") -dhat-fast-invalid-apply: (dhat "fast-invalid" "apply" "10000") +dhat-fast-invalid-evaluate: (dhat "fast-invalid" "evaluate" "10000") # Clean generated flamegraphs clean: diff --git a/profiler/src/main.rs b/profiler/src/main.rs index e535f5a82..7ddfaa135 100644 --- a/profiler/src/main.rs +++ b/profiler/src/main.rs @@ -76,8 +76,10 @@ fn main() -> Result<(), Box> { let _ = Registry::try_from_resources(input_resources.into_iter()); } } - "is_valid" | "validate" | "iter_errors" | "apply" => { - let instance_path = args.instance_path.as_ref() + "is_valid" | "validate" | "iter_errors" | "evaluate" => { + let instance_path = args + .instance_path + .as_ref() .ok_or("--instance or --preset required for this method")?; let instance_str = fs::read_to_string(instance_path)?; let instance: Value = serde_json::from_str(&instance_str)?; @@ -99,9 +101,14 @@ fn main() -> Result<(), Box> { for _error in validator.iter_errors(&instance) {} } } - "apply" => { + "evaluate" => { for _ in 0..args.iterations { - let _ = validator.apply(&instance).basic(); + let evaluation = validator.evaluate(&instance); + let _ = evaluation.flag(); + let _ = serde_json::to_value(evaluation.list()) + .expect("Failed to serialize list output"); + let _ = serde_json::to_value(evaluation.hierarchical()) + .expect("Failed to serialize hierarchical output"); } } _ => unreachable!(), @@ -109,7 +116,7 @@ fn main() -> Result<(), Box> { } _ => { return Err( - "Invalid method. Use 'registry', 'build', 'is_valid', 'validate', 'iter_errors', or 'apply'".into() + "Invalid method. Use 'registry', 'build', 'is_valid', 'validate', 'iter_errors', or 'evaluate'".into() ); } }