#![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 mut expected_schema = prepare_schema_for_version(&schema, version);
let mut actual_output = produce_output(&evaluation, format).unwrap_or_else(|| {
panic!(
"Output format `{format}` is not supported (file: {file}, case: `{case}`, test: `{description}`)"
)
});
normalize_output(&mut actual_output);
normalize_const_values(&mut expected_schema);
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_entry_sort_key(value: &Value) -> (&str, &str, &str) {
let Some(entry) = value.as_object() else {
return ("", "", "");
};
let evaluation_path = entry
.get("evaluationPath")
.and_then(Value::as_str)
.unwrap_or("");
let schema_location = entry
.get("schemaLocation")
.and_then(Value::as_str)
.unwrap_or("");
let instance_location = entry
.get("instanceLocation")
.and_then(Value::as_str)
.unwrap_or("");
(evaluation_path, schema_location, instance_location)
}
fn normalize_output(value: &mut Value) {
match value {
Value::Object(map) => {
for nested in map.values_mut() {
normalize_output(nested);
}
if let Some(details) = map.get_mut("details").and_then(Value::as_array_mut) {
details.sort_by(|left, right| {
output_entry_sort_key(left).cmp(&output_entry_sort_key(right))
});
}
for key in ["annotations", "droppedAnnotations"] {
if let Some(items) = map.get_mut(key).and_then(Value::as_array_mut) {
if items.iter().all(Value::is_string) {
items.sort_by(|left, right| {
left.as_str()
.unwrap_or("")
.cmp(right.as_str().unwrap_or(""))
});
}
}
}
let mut keys: Vec<_> = map.keys().cloned().collect();
keys.sort_unstable();
let mut sorted = serde_json::Map::new();
for key in keys {
if let Some(value) = map.remove(&key) {
sorted.insert(key, value);
}
}
*map = sorted;
}
Value::Array(items) => {
for item in items {
normalize_output(item);
}
}
_ => {}
}
}
fn normalize_const_values(schema: &mut Value) {
match schema {
Value::Object(map) => {
for nested in map.values_mut() {
normalize_const_values(nested);
}
if let Some(const_value) = map.get_mut("const") {
normalize_output(const_value);
}
}
Value::Array(items) => {
for item in items {
normalize_const_values(item);
}
}
_ => {}
}
}
fn output_spec_validator() -> &'static Validator {
static VALIDATOR: OnceLock<Validator> = 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<Value> {
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,
}
}
#[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<Draft> {
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<String>,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
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())
}
}