diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b5070a..09b4c4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ - **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). - **CLI**: `--errors-only` flag to suppress successful validation output and only show failures. +- **CLI**: When invoked with only a schema file (no instances), validates the schema against its meta-schema. [#804](https://github.com/Stranger6667/jsonschema/issues/804) - New `Validator::evaluate()` API exposes JSON Schema Output v1 (flag/list/hierarchical) reports along with iterator helpers for annotations and errors. +- `meta::validator_for()` function to build validators for meta-schema validation with full `Validator` API access. - `Validator` now implements `Clone`. [#809](https://github.com/Stranger6667/jsonschema/issues/809) ### Removed diff --git a/crates/jsonschema-cli/src/main.rs b/crates/jsonschema-cli/src/main.rs index 678f5ab6..1ba3b652 100644 --- a/crates/jsonschema-cli/src/main.rs +++ b/crates/jsonschema-cli/src/main.rs @@ -181,6 +181,65 @@ fn path_to_uri(path: &std::path::Path) -> String { result } +fn output_schema_validation( + schema_path: &Path, + schema_json: &serde_json::Value, + output: Output, + errors_only: bool, +) -> Result> { + let meta_validator = jsonschema::meta::validator_for(schema_json)?; + let evaluation = meta_validator.evaluate(schema_json); + let flag_output = evaluation.flag(); + + // Skip valid schemas if errors_only is enabled + if !(errors_only && flag_output.valid) { + let schema_display = schema_path.to_string_lossy().to_string(); + let output_format = output.as_str(); + let payload = match output { + Output::Text => unreachable!("text mode should not call this function"), + 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 record = json!({ + "output": output_format, + "schema": &schema_display, + "payload": payload, + }); + println!("{}", serde_json::to_string(&record)?); + } + + Ok(flag_output.valid) +} + +fn validate_schema_meta( + schema_path: &Path, + output: Output, + errors_only: bool, +) -> Result> { + let schema_json = read_json(schema_path)??; + + if matches!(output, Output::Text) { + // Text output mode + match jsonschema::meta::validate(&schema_json) { + Ok(()) => { + if !errors_only { + println!("Schema is valid"); + } + Ok(true) + } + Err(error) => { + println!("Schema is invalid. Error: {error}"); + Ok(false) + } + } + } else { + // Structured output modes using evaluate API + output_schema_validation(schema_path, &schema_json, output, errors_only) + } +} + fn validate_instances( instances: &[PathBuf], schema_path: &Path, @@ -255,7 +314,12 @@ fn validate_instances( } } Err(error) => { - println!("Schema is invalid. Error: {error}"); + if matches!(output, Output::Text) { + println!("Schema is invalid. Error: {error}"); + } else { + // Schema compilation failed - validate the schema itself to get structured output + output_schema_validation(schema_path, &schema_json, output, errors_only)?; + } success = false; } } @@ -274,7 +338,7 @@ fn main() -> ExitCode { if let Some(instances) = config.instances { // - Some(true) if --assert-format // - Some(false) if --no-assert-format - // - None if neither (use builder’s default) + // - None if neither (use builder's default) let assert_format = config.assert_format.or(config.no_assert_format); return match validate_instances( &instances, @@ -292,6 +356,15 @@ fn main() -> ExitCode { } }; } + // No instances provided - validate the schema itself + return match validate_schema_meta(&schema, config.output, config.errors_only) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::FAILURE, + Err(error) => { + println!("Error: {error}"); + ExitCode::FAILURE + } + }; } ExitCode::SUCCESS } diff --git a/crates/jsonschema-cli/tests/cli.rs b/crates/jsonschema-cli/tests/cli.rs index fe7ee718..fd0c3c63 100644 --- a/crates/jsonschema-cli/tests/cli.rs +++ b/crates/jsonschema-cli/tests/cli.rs @@ -713,3 +713,174 @@ fn test_errors_only_structured_output() { assert_eq!(records[0]["instance"], invalid); assert_eq!(records[0]["payload"]["valid"], false); } + +#[test] +fn test_validate_valid_schema() { + let dir = tempdir().unwrap(); + let schema = create_temp_file(&dir, "schema.json", r#"{"type": "string"}"#); + + let mut cmd = cli(); + cmd.arg(&schema); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Schema is valid")); +} + +#[test] +fn test_validate_invalid_schema() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "invalid_type", "minimum": "not a number"}"#, + ); + + let mut cmd = cli(); + cmd.arg(&schema); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Schema is invalid")); +} + +#[test] +fn test_instance_validation_with_invalid_schema_structured_output() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "invalid_type", "minimum": "not a number"}"#, + ); + let instance = create_temp_file(&dir, "instance.json", "42"); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&instance) + .arg("--output") + .arg("flag"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + + assert_eq!(json["output"], "flag"); + assert_eq!(json["payload"]["valid"], false); + assert!(json["schema"].as_str().unwrap().ends_with("schema.json")); +} + +#[test] +fn test_instance_validation_with_invalid_schema_list_output() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "invalid_type", "minimum": "not a number"}"#, + ); + let instance = create_temp_file(&dir, "instance.json", "42"); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&instance) + .arg("--output") + .arg("list"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + + assert_eq!(json["output"], "list"); + assert_eq!(json["payload"]["valid"], false); + assert!(json["schema"].as_str().unwrap().ends_with("schema.json")); +} + +#[test] +fn test_instance_validation_with_invalid_schema_hierarchical_output() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "invalid_type", "minimum": "not a number"}"#, + ); + let instance = create_temp_file(&dir, "instance.json", "42"); + + let mut cmd = cli(); + cmd.arg(&schema) + .arg("--instance") + .arg(&instance) + .arg("--output") + .arg("hierarchical"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + + assert_eq!(json["output"], "hierarchical"); + assert_eq!(json["payload"]["valid"], false); + assert!(json["schema"].as_str().unwrap().ends_with("schema.json")); +} + +#[test] +fn test_validate_invalid_schema_list_output() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "invalid_type", "minimum": "not a number"}"#, + ); + + let mut cmd = cli(); + cmd.arg(&schema).arg("--output").arg("list"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + + assert_eq!(json["output"], "list"); + assert_eq!(json["payload"]["valid"], false); + assert!(json["schema"].as_str().unwrap().ends_with("schema.json")); +} + +#[test] +fn test_validate_invalid_schema_hierarchical_output() { + let dir = tempdir().unwrap(); + let schema = create_temp_file( + &dir, + "schema.json", + r#"{"type": "invalid_type", "minimum": "not a number"}"#, + ); + + let mut cmd = cli(); + cmd.arg(&schema).arg("--output").arg("hierarchical"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + + assert_eq!(json["output"], "hierarchical"); + assert_eq!(json["payload"]["valid"], false); + assert!(json["schema"].as_str().unwrap().ends_with("schema.json")); +} + +#[test] +fn test_validate_schema_with_json_parse_error() { + let dir = tempdir().unwrap(); + let schema = create_temp_file(&dir, "schema.json", r#"{"type": "string"#); + + let mut cmd = cli(); + cmd.arg(&schema).arg("--output").arg("flag"); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Error:")); +} diff --git a/crates/jsonschema-cli/tests/snapshots/cli__no_instances.snap b/crates/jsonschema-cli/tests/snapshots/cli__no_instances.snap index 0a2de1dd..a7f6c2fa 100644 --- a/crates/jsonschema-cli/tests/snapshots/cli__no_instances.snap +++ b/crates/jsonschema-cli/tests/snapshots/cli__no_instances.snap @@ -2,4 +2,4 @@ source: crates/jsonschema-cli/tests/cli.rs expression: "String::from_utf8_lossy(&output.stdout)" --- - +Schema is valid diff --git a/crates/jsonschema/src/lib.rs b/crates/jsonschema/src/lib.rs index dacab69b..a9399328 100644 --- a/crates/jsonschema/src/lib.rs +++ b/crates/jsonschema/src/lib.rs @@ -1447,6 +1447,48 @@ pub mod meta { validator.as_ref().validate(schema) } + /// Build a validator for a JSON Schema's meta-schema. + /// Draft version is detected automatically. + /// + /// Returns a [`MetaValidator`] that can be used to validate the schema or access + /// structured validation output via the evaluate API. + /// + /// # Examples + /// + /// ```rust + /// use serde_json::json; + /// + /// let schema = json!({ + /// "type": "string", + /// "maxLength": 5 + /// }); + /// + /// let validator = jsonschema::meta::validator_for(&schema) + /// .expect("Valid meta-schema"); + /// + /// // Use evaluate API for structured output + /// let evaluation = validator.evaluate(&schema); + /// assert!(evaluation.flag().valid); + /// ``` + /// + /// # Errors + /// + /// Returns [`ValidationError`] if the meta-schema cannot be resolved or built. + /// + /// # Panics + /// + /// This function panics if the meta-schema can't be detected. + /// + /// # Note + /// + /// This helper only handles the bundled JSON Schema drafts. For custom meta-schemas, + /// use [`meta::options().with_registry(...)`](crate::meta::options). + pub fn validator_for( + schema: &Value, + ) -> Result, ValidationError<'static>> { + try_meta_validator_for(schema, None) + } + fn try_meta_validator_for<'a>( schema: &Value, registry: Option<&Registry>, @@ -3816,6 +3858,83 @@ mod tests { assert!(result.is_ok()); } } + + #[test] + fn test_meta_validator_for_valid_schema() { + let schema = json!({ + "type": "string", + "maxLength": 5 + }); + + let validator = crate::meta::validator_for(&schema).expect("Valid meta-schema"); + assert!(validator.is_valid(&schema)); + } + + #[test] + fn test_meta_validator_for_invalid_schema() { + let schema = json!({ + "type": "invalid_type" + }); + + let validator = crate::meta::validator_for(&schema).expect("Valid meta-schema"); + assert!(!validator.is_valid(&schema)); + } + + #[test] + fn test_meta_validator_for_evaluate_api() { + let schema = json!({ + "type": "string", + "maxLength": 5 + }); + + let validator = crate::meta::validator_for(&schema).expect("Valid meta-schema"); + let evaluation = validator.evaluate(&schema); + + let flag = evaluation.flag(); + assert!(flag.valid); + } + + #[test] + fn test_meta_validator_for_evaluate_api_invalid() { + let schema = json!({ + "type": "invalid_type", + "minimum": "not a number" + }); + + let validator = crate::meta::validator_for(&schema).expect("Valid meta-schema"); + let evaluation = validator.evaluate(&schema); + + let flag = evaluation.flag(); + assert!(!flag.valid); + } + + #[test] + fn test_meta_validator_for_all_drafts() { + let schemas = vec![ + json!({ "$schema": "http://json-schema.org/draft-04/schema#", "type": "string" }), + json!({ "$schema": "http://json-schema.org/draft-06/schema#", "type": "string" }), + json!({ "$schema": "http://json-schema.org/draft-07/schema#", "type": "string" }), + json!({ "$schema": "https://json-schema.org/draft/2019-09/schema", "type": "string" }), + json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string" }), + ]; + + for schema in schemas { + let validator = crate::meta::validator_for(&schema).unwrap(); + assert!(validator.is_valid(&schema)); + } + } + + #[test] + fn test_meta_validator_for_iter_errors() { + let schema = json!({ + "type": "invalid_type", + "minimum": "not a number" + }); + + let validator = crate::meta::validator_for(&schema).expect("Valid meta-schema"); + let errors: Vec<_> = validator.iter_errors(&schema).collect(); + assert!(!errors.is_empty()); + } } #[cfg(all(test, feature = "resolve-async", not(target_family = "wasm")))]