jsoncompat 0.4.1

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

use jsoncompat_codegen::generate_dataclass_models;
use serde_json::json;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

#[test]
fn generated_dataclasses_typecheck_and_expose_precise_field_types() -> Result<(), Box<dyn Error>> {
    let schema = json!({
        "title": "InventoryItem",
        "type": "object",
        "properties": {
            "sku": {"type": "string"},
            "quantity": {"type": "integer"},
            "metadata": {
                "type": "object",
                "properties": {
                    "warehouse": {"type": "string"},
                },
                "required": ["warehouse"],
                "additionalProperties": false,
            },
            "tags": {
                "type": "array",
                "items": {"type": "string"},
            },
            "warehouseCode": {
                "$ref": "#/$defs/warehouseCode",
            },
            "coordinates": {
                "type": "array",
                "prefixItems": [
                    {"type": "string"},
                    {"type": "integer"},
                ],
                "items": false,
            },
        },
        "required": ["sku", "metadata"],
        "additionalProperties": {"type": "number"},
        "$defs": {
            "warehouseCode": {
                "type": "string",
            }
        }
    });
    let source = generate_dataclass_models(&schema)?;
    let work_dir = write_typecheck_files(
        &source,
        r#"
# pyright: strict

from typing import assert_type

from generated_models import InventoryItem, InventoryItemMetadata, JSONCOMPAT_MODEL
from jsoncompat.codegen.dataclasses import JSONCOMPAT_MISSING, JsoncompatMissingType, Omittable


item = InventoryItem.from_json({
    "sku": "sku-123",
    "metadata": {"warehouse": "west"},
    "quantity": 10,
    "tags": ["fresh", "boxed"],
    "priority": 1,
})

assert_type(JSONCOMPAT_MODEL, type[InventoryItem])
assert_type(item, InventoryItem)
assert_type(item.sku, str)
assert_type(item.quantity, Omittable[int])
assert_type(item.metadata, InventoryItemMetadata)
assert_type(item.metadata.warehouse, str)
assert_type(item.tags, Omittable[list[str]])
assert_type(item.warehouseCode, Omittable[str])
assert_type(item.coordinates, Omittable[list[int | str]])
assert_type(item.__jsoncompat_extra__, dict[str, float])
assert_type(
    item.get_additional_property("priority"),
    float | JsoncompatMissingType,
)

item_from_constructor = InventoryItem(
    sku="sku-456",
    metadata=InventoryItemMetadata(warehouse="east"),
    quantity=JSONCOMPAT_MISSING,
    tags=["dry"],
    warehouseCode="WH-123",
    coordinates=["aisle", 7],
    __jsoncompat_extra__={"priority": 2.5},
)
assert_type(item_from_constructor, InventoryItem)
"#,
        r#"
# pyright: strict

from generated_models import InventoryItem, InventoryItemMetadata


InventoryItem(
    sku=123,
    metadata=InventoryItemMetadata(warehouse="east"),
)

InventoryItem(
    sku="sku-456",
    metadata=InventoryItemMetadata(warehouse=123),
)

InventoryItemMetadata(warehouse="east").get_additional_property("priority")

InventoryItem(
    sku="sku-456",
    metadata=InventoryItemMetadata(warehouse="east"),
    coordinates=[{}],
)
"#,
    )?;

    let valid_output = run_pyright(&work_dir, "valid_usage.py")?;
    assert!(
        valid_output.status.success(),
        "pyright rejected valid generated dataclass usage:\n{}\n{}",
        String::from_utf8_lossy(&valid_output.stdout),
        String::from_utf8_lossy(&valid_output.stderr),
    );

    let invalid_output = run_pyright(&work_dir, "invalid_usage.py")?;
    assert!(
        !invalid_output.status.success(),
        "pyright accepted invalid generated dataclass usage"
    );
    let invalid_stdout = String::from_utf8_lossy(&invalid_output.stdout);
    assert!(
        invalid_stdout.contains("sku") && invalid_stdout.contains("str"),
        "pyright failure did not report the sku type mismatch:\n{invalid_stdout}",
    );
    assert!(
        invalid_stdout.contains("warehouse") && invalid_stdout.contains("str"),
        "pyright failure did not report the nested warehouse type mismatch:\n{invalid_stdout}",
    );

    Ok(())
}

#[test]
fn patterned_additional_properties_keep_precise_extra_types() -> Result<(), Box<dyn Error>> {
    let schema = json!({
        "title": "LabeledRecord",
        "type": "object",
        "properties": {
            "name": {"type": "string"},
        },
        "patternProperties": {
            "^x-": {"type": "integer"},
        },
        "required": ["name"],
        "additionalProperties": false,
    });
    let source = generate_dataclass_models(&schema)?;
    let work_dir = write_typecheck_files(
        &source,
        r#"
# pyright: strict

from typing import assert_type

from generated_models import LabeledRecord
from jsoncompat.codegen.dataclasses import JsoncompatMissingType


record = LabeledRecord.from_json({"name": "Ada", "x-rank": 7})
assert_type(record.__jsoncompat_extra__, dict[str, int])
assert_type(
    record.get_additional_property("x-rank"),
    int | JsoncompatMissingType,
)

constructed = LabeledRecord(name="Ada", __jsoncompat_extra__={"x-rank": 7})
assert_type(constructed, LabeledRecord)
"#,
        r#"
# pyright: strict

from generated_models import LabeledRecord


LabeledRecord(name="Ada", __jsoncompat_extra__={"x-rank": "high"})
"#,
    )?;

    let valid_output = run_pyright(&work_dir, "valid_usage.py")?;
    assert!(
        valid_output.status.success(),
        "pyright rejected valid patterned extras usage:\n{}\n{}",
        String::from_utf8_lossy(&valid_output.stdout),
        String::from_utf8_lossy(&valid_output.stderr),
    );

    let invalid_output = run_pyright(&work_dir, "invalid_usage.py")?;
    assert!(
        !invalid_output.status.success(),
        "pyright accepted invalid patterned extras usage"
    );
    let invalid_stdout = String::from_utf8_lossy(&invalid_output.stdout);
    assert!(
        invalid_stdout.contains("__jsoncompat_extra__")
            && invalid_stdout.contains("dict[str, int]"),
        "pyright failure did not report the patterned extra value mismatch:\n{invalid_stdout}",
    );

    Ok(())
}

fn write_typecheck_files(
    source: &str,
    valid_usage: &str,
    invalid_usage: &str,
) -> Result<PathBuf, Box<dyn Error>> {
    let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
    let dir = std::env::temp_dir().join(format!(
        "jsoncompat-dataclass-typing-{}-{unique}",
        std::process::id(),
    ));
    fs::create_dir_all(&dir)?;
    fs::write(dir.join("generated_models.py"), source)?;
    fs::write(dir.join("valid_usage.py"), valid_usage)?;
    fs::write(dir.join("invalid_usage.py"), invalid_usage)?;
    Ok(dir)
}

fn run_pyright(work_dir: &Path, file_name: &str) -> Result<std::process::Output, Box<dyn Error>> {
    let mut command = python_env::pyright_command();
    command
        .arg("--pythonversion")
        .arg("3.12")
        .arg(file_name)
        .current_dir(work_dir);
    Ok(command.output()?)
}