jsoncompat 0.4.1

JSON Schema and OpenAPI Compatibility Checker
Documentation
#[path = "support/dataclass_round_trip.rs"]
mod dataclass_round_trip;
#[path = "support/python_env.rs"]
mod python_env;

use dataclass_round_trip::{DataclassGeneratedValueRoundTripper, write_generated_module};
use json_schema_ast::SchemaDocument;
use json_schema_fuzz::{GenerationConfig, ValueGenerator};
use jsoncompat_codegen::generate_dataclass_models;
use rand::{SeedableRng, rngs::StdRng};
use serde::Deserialize;
use serde_json::Value;
use std::error::Error;
use std::fs;
use std::path::Path;

datatest_stable::harness! {
    { test = fixture, root = "tests/fixtures/backcompat", pattern = r".*[/\\]expect\.json$" },
}

const GENERATED_VALUE_ITERATIONS: usize = 100;

#[derive(Deserialize, Default)]
struct SampleSets {
    #[serde(default)]
    old_only: Vec<Value>,
    #[serde(default)]
    new_only: Vec<Value>,
    #[serde(default)]
    both: Vec<Value>,
}

fn fixture(expect_file: &Path) -> Result<(), Box<dyn Error>> {
    let fixture_dir = expect_file
        .parent()
        .expect("backcompat fixtures have a fixture directory");
    let case_name = fixture_dir
        .file_name()
        .and_then(|name| name.to_str())
        .expect("utf-8 fixture directory name");
    let old_raw = read_json(&fixture_dir.join("old.json"))?;
    let new_raw = read_json(&fixture_dir.join("new.json"))?;
    let old_schema = SchemaDocument::from_json(&old_raw)?;
    let new_schema = SchemaDocument::from_json(&new_raw)?;
    let mut old_round_tripper = build_round_tripper(case_name, "old", &old_raw)?;
    let mut new_round_tripper = build_round_tripper(case_name, "new", &new_raw)?;

    let examples_file = fixture_dir.join("examples.json");
    if examples_file.exists() {
        let samples: SampleSets = serde_json::from_slice(&fs::read(examples_file)?)?;
        round_trip_curated_samples(
            case_name,
            &old_schema,
            &new_schema,
            old_round_tripper.as_mut(),
            new_round_tripper.as_mut(),
            &samples,
        )?;
    }

    let mut rng = StdRng::seed_from_u64(0xDADA_CAFE + case_name.len() as u64);
    round_trip_generated_samples(
        case_name,
        "old",
        &old_schema,
        old_round_tripper.as_mut(),
        "new",
        &new_schema,
        new_round_tripper.as_mut(),
        &mut rng,
    )?;
    round_trip_generated_samples(
        case_name,
        "new",
        &new_schema,
        new_round_tripper.as_mut(),
        "old",
        &old_schema,
        old_round_tripper.as_mut(),
        &mut rng,
    )?;

    Ok(())
}

fn round_trip_curated_samples(
    case_name: &str,
    old_schema: &SchemaDocument,
    new_schema: &SchemaDocument,
    mut old_round_tripper: Option<&mut DataclassGeneratedValueRoundTripper>,
    mut new_round_tripper: Option<&mut DataclassGeneratedValueRoundTripper>,
    samples: &SampleSets,
) -> Result<(), Box<dyn Error>> {
    for sample in &samples.old_only {
        assert!(
            old_schema.is_valid(sample)?,
            "{case_name} old_only sample is invalid under the old schema: {sample:?}"
        );
        assert!(
            !new_schema.is_valid(sample)?,
            "{case_name} old_only sample unexpectedly matches the new schema: {sample:?}"
        );
        round_trip_valid(
            case_name,
            "old",
            old_schema,
            old_round_tripper.as_deref_mut(),
            sample,
        )?;
        reject_invalid(case_name, "new", new_round_tripper.as_deref_mut(), sample);
    }

    for sample in &samples.new_only {
        assert!(
            new_schema.is_valid(sample)?,
            "{case_name} new_only sample is invalid under the new schema: {sample:?}"
        );
        assert!(
            !old_schema.is_valid(sample)?,
            "{case_name} new_only sample unexpectedly matches the old schema: {sample:?}"
        );
        round_trip_valid(
            case_name,
            "new",
            new_schema,
            new_round_tripper.as_deref_mut(),
            sample,
        )?;
        reject_invalid(case_name, "old", old_round_tripper.as_deref_mut(), sample);
    }

    for sample in &samples.both {
        assert!(
            old_schema.is_valid(sample)? && new_schema.is_valid(sample)?,
            "{case_name} both sample must validate under both schemas: {sample:?}"
        );
        round_trip_valid(
            case_name,
            "old",
            old_schema,
            old_round_tripper.as_deref_mut(),
            sample,
        )?;
        round_trip_valid(
            case_name,
            "new",
            new_schema,
            new_round_tripper.as_deref_mut(),
            sample,
        )?;
    }

    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn round_trip_generated_samples(
    case_name: &str,
    source_side: &str,
    source_schema: &SchemaDocument,
    mut source_round_tripper: Option<&mut DataclassGeneratedValueRoundTripper>,
    counterpart_side: &str,
    counterpart_schema: &SchemaDocument,
    mut counterpart_round_tripper: Option<&mut DataclassGeneratedValueRoundTripper>,
    rng: &mut StdRng,
) -> Result<(), Box<dyn Error>> {
    let config = GenerationConfig::new(4);
    for _ in 0..GENERATED_VALUE_ITERATIONS {
        let sample = ValueGenerator::generate(source_schema, config, rng)?;
        assert!(
            source_schema.is_valid(&sample)?,
            "{case_name}/{source_side} generator emitted an invalid sample: {sample:?}"
        );
        round_trip_valid(
            case_name,
            source_side,
            source_schema,
            source_round_tripper.as_deref_mut(),
            &sample,
        )?;

        if counterpart_schema.is_valid(&sample)? {
            round_trip_valid(
                case_name,
                counterpart_side,
                counterpart_schema,
                counterpart_round_tripper.as_deref_mut(),
                &sample,
            )?;
        } else {
            reject_invalid(
                case_name,
                counterpart_side,
                counterpart_round_tripper.as_deref_mut(),
                &sample,
            );
        }
    }
    Ok(())
}

fn build_round_tripper(
    case_name: &str,
    side: &str,
    schema: &Value,
) -> Result<Option<DataclassGeneratedValueRoundTripper>, Box<dyn Error>> {
    let source = match generate_dataclass_models(schema) {
        Ok(source) => source,
        Err(error) => {
            assert_codegen_error_snapshot(case_name, side, &error.to_string())?;
            return Ok(None);
        }
    };
    let module_path = write_generated_module("dataclasses-backcompat", case_name, side, &source)?;
    Ok(Some(DataclassGeneratedValueRoundTripper::spawn(
        module_path,
    )?))
}

fn assert_codegen_error_snapshot(
    case_name: &str,
    side: &str,
    error: &str,
) -> Result<(), Box<dyn Error>> {
    let snapshot_path = Path::new("tests/fixtures/dataclasses/backcompat")
        .join(case_name)
        .join(format!("{side}.error.txt"));
    let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|read_error| {
        panic!(
            "dataclass backcompat example skipped {case_name}/{side} without an explicit error snapshot {}: {read_error}",
            snapshot_path.display(),
        )
    });
    let actual = format!("{error}\n");
    assert_eq!(
        normalized_newlines(&expected),
        normalized_newlines(&actual),
        "dataclass backcompat example codegen error snapshot is stale for {case_name}/{side}: {}",
        snapshot_path.display(),
    );
    Ok(())
}

fn normalized_newlines(contents: &str) -> String {
    contents.replace("\r\n", "\n")
}

fn round_trip_valid(
    case_name: &str,
    side: &str,
    schema: &SchemaDocument,
    round_tripper: Option<&mut DataclassGeneratedValueRoundTripper>,
    sample: &Value,
) -> Result<(), Box<dyn Error>> {
    let Some(round_tripper) = round_tripper else {
        return Ok(());
    };
    let emitted = round_tripper
        .round_trip_value(sample)
        .unwrap_or_else(|message| {
            panic!(
                "{case_name}/{side} generated dataclass rejected a valid sample {sample:?}: {message}"
            )
        });
    assert!(
        schema.is_valid(&emitted)?,
        "{case_name}/{side} generated dataclass emitted invalid JSON {emitted:?} from sample {sample:?}",
    );
    Ok(())
}

fn reject_invalid(
    case_name: &str,
    side: &str,
    round_tripper: Option<&mut DataclassGeneratedValueRoundTripper>,
    sample: &Value,
) {
    let Some(round_tripper) = round_tripper else {
        return;
    };
    round_tripper
        .reject_invalid_value(sample)
        .unwrap_or_else(|message| {
            panic!(
                "{case_name}/{side} generated dataclass accepted an invalid sample {sample:?}: {message}"
            )
        });
}

fn read_json(path: &Path) -> Result<Value, Box<dyn Error>> {
    Ok(serde_json::from_slice(&fs::read(path)?)?)
}