use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::{Command, Output},
};
#[path = "support/python_env.rs"]
mod python_env;
struct Work(PathBuf);
impl Work {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"jsoncompat-package-{}-{}",
std::process::id(),
rand::random::<u64>()
));
fs::create_dir(&path).unwrap();
let work = Self(path);
fs::copy(env!("CARGO_BIN_EXE_jsoncompat"), work.compiler()).unwrap();
work
}
fn compiler(&self) -> PathBuf {
self.0
.join(format!("compiler{}", std::env::consts::EXE_SUFFIX))
}
fn generate(&self, extra: &[&str]) -> Output {
Command::new(self.compiler())
.current_dir(&self.0)
.args([
"codegen",
"--target",
"dataclasses",
"--openapi",
"api.json",
"--output",
"models",
"--models-per-module",
"2",
])
.args(extra)
.output()
.unwrap()
}
fn write(&self, value: &Value) {
fs::write(self.0.join("api.json"), value.to_string()).unwrap();
}
}
impl Drop for Work {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn success(output: Output) -> String {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stderr).unwrap()
}
fn record(width: usize) -> Value {
let properties: serde_json::Map<_, _> = (0..width)
.map(|i| (format!("f{i:03}"), json!({"type":"integer","minimum":0})))
.collect();
json!({"type":"object","required":properties.keys().collect::<Vec<_>>(),"properties":properties,"additionalProperties":false})
}
fn api() -> Value {
json!({"openapi":"3.1.0","info":{"title":"Package regression","version":"1"},"components":{"schemas":{
"Customer":{"type":"object","properties":{"id":{"type":"integer","minimum":0},"orders":{"type":"array","items":{"$ref":"#/components/schemas/Order"}}},"required":["id","orders"],"additionalProperties":false},
"Order":{"type":"object","properties":{"customer":{"$ref":"#/components/schemas/Customer"},"total":{"type":"integer","minimum":0}},"required":["customer","total"],"additionalProperties":false},
"Wide":record(200), "Other":record(5), "Unused": {"type":"boolean"}
}}})
}
fn contents(directory: &Path) -> BTreeMap<String, Vec<u8>> {
fs::read_dir(directory)
.unwrap()
.filter_map(|entry| {
let path = entry.unwrap().path();
(path
.extension()
.is_some_and(|ext| ext == "py" || ext == "pyi"))
.then(|| {
(
path.file_name().unwrap().to_string_lossy().into_owned(),
fs::read(path).unwrap(),
)
})
})
.collect()
}
#[test]
fn package_builds_preserve_identity_recursion_laziness_and_incremental_outputs() {
let work = Work::new();
let mut input = api();
work.write(&input);
assert!(success(work.generate(&[])).contains("5 models in 3 modules (3 rebuilt)"));
let before = contents(&work.0.join("models"));
assert!(success(work.generate(&[])).contains("(0 rebuilt)"));
assert_eq!(contents(&work.0.join("models")), before);
let output = python_env::python_command().arg("-c").arg(r#"
import builtins, compileall, dataclasses, inspect, json, sys, zlib
from unittest.mock import patch
from pathlib import Path
import jsoncompat
from jsoncompat.codegen import dataclasses as dc
sys.path.insert(0, sys.argv[1])
assert compileall.compile_dir(str(Path(sys.argv[1]) / 'models'), quiet=1)
def forbidden(*a, **k): raise AssertionError('runtime compilation or reflection')
with patch.object(builtins, 'compile', forbidden), patch.object(dataclasses, 'dataclass', forbidden), patch.object(inspect, 'get_annotations', forbidden), patch.object(jsoncompat, 'validator_for', forbidden), patch.object(zlib, 'decompress', forbidden):
import models
assert not any(name.startswith('models.models_') for name in sys.modules)
from models import Order, Customer
value = {'customer': {'id': 7, 'orders': []}, 'total': 9}
order = Order.deserialize(json.dumps(value))
assert type(order.customer) is Customer
assert type(Customer(id=8, orders=[order]).orders[0]) is Order
assert json.loads(order.serialize()) == value
assert 'Wide' not in vars(models)
from models import Wide
payload = {f'f{i:03}': i for i in range(200)}
assert Wide.deserialize(json.dumps(payload)).to_value() == payload
try: Wide.deserialize(json.dumps(dict(payload, f199=-1)))
except ValueError: pass
else: raise AssertionError('wide constraint was omitted')
assert len(models.__all__) == 5
assert set(models.__all__) <= set(dir(models))
try: models.NoSuchModel
except AttributeError: pass
else: raise AssertionError('unknown export')
"#).arg(&work.0).output().unwrap();
success(output);
input["components"]["schemas"]["Wide"]["properties"]["f199"]["minimum"] = json!(1);
work.write(&input);
assert!(success(work.generate(&[])).contains("(1 rebuilt)"));
let after = contents(&work.0.join("models"));
assert_eq!(
before
.iter()
.filter(|(name, data)| after.get(*name) != Some(*data))
.count(),
1,
"only the generated companion's constraint should change"
);
fs::write(work.0.join("models/notes.txt"), "keep me").unwrap();
success(work.generate(&["--component", "Order"]));
let manifest: Value =
serde_json::from_slice(&fs::read(work.0.join("models/.jsoncompat-manifest.json")).unwrap())
.unwrap();
assert_eq!(manifest["modules"].as_object().unwrap().len(), 1);
assert_eq!(
fs::read_to_string(work.0.join("models/notes.txt")).unwrap(),
"keep me"
);
assert!(!work.0.join("models/models_0002.py").exists());
}
#[test]
fn component_names_resources_and_failed_regeneration_are_explicit() {
let work = Work::new();
let document = json!({"openapi":"3.1.0","info":{"title":"Resources","version":"1"},"components":{"schemas":{
"foo-bar":{"$ref":"https://example.test/record"}, "FooBar":{"type":"integer"}
}}});
work.write(&document);
let collision = work.generate(&[]);
assert!(!collision.status.success());
assert!(String::from_utf8_lossy(&collision.stderr).contains("collide"));
fs::write(
work.0.join("options.json"),
json!({"resources":{"https://example.test/record":record(5)}}).to_string(),
)
.unwrap();
success(work.generate(&[
"--rename",
"foo-bar=RemoteRecord",
"--schema-options",
"options.json",
]));
let before = contents(&work.0.join("models"));
let invalid = work.generate(&["--component", "Absent"]);
assert!(!invalid.status.success());
assert_eq!(contents(&work.0.join("models")), before);
let output = python_env::python_command().arg("-c").arg("import sys; sys.path.insert(0,sys.argv[1]); from models import RemoteRecord; assert RemoteRecord.deserialize('{\"f000\":0,\"f001\":1,\"f002\":2,\"f003\":3,\"f004\":4}').serialize()")
.arg(&work.0).output().unwrap();
success(output);
}