Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions examples/python/basic/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,43 @@ def main() -> None:
example = jsc.generate_value(old_schema, 3)
print("example value:", example)

print("\n=== Reusable validation ===")
validator = jsc.validator_for(old_schema)
assert validator.is_valid_json(example)
assert validator.is_valid_value({"name": "Robbie", "age": 37})
assert not validator.is_valid_value({"name": True})

try:
validator.is_valid_value({"age": float("nan")})
except ValueError:
pass
else:
raise AssertionError("non-finite JSON numbers must be rejected")

try:
validator.is_valid_value({1: "invalid"})
except TypeError:
pass
else:
raise AssertionError("JSON object keys must be strings")

integer_validator = jsc.validator_for('{"type": "integer"}')
assert integer_validator.is_valid_value(1)
assert not integer_validator.is_valid_value(True)

try:
integer_validator.is_valid_value(2**2000)
except ValueError:
pass
else:
raise AssertionError("oversized Python integers must be rejected")

print("generated JSON is valid:", validator.is_valid_json(example))
print(
"Python value is valid:",
validator.is_valid_value({"name": "Robbie", "age": 37}),
)


if __name__ == "__main__":
main()
11 changes: 10 additions & 1 deletion examples/wasm/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ <h1>json_schema_wasm - browser demo</h1>
<script type="module">
// The Wasm package is built into `pkg/` by `just wasm-demo` with
// wasm-pack build wasm --target web --out-dir examples/wasm/pkg
import init, { check_compat, generate_value } from "../../wasm/pkg/jsoncompat_wasm.js";
import init, { check_compat, generate_value, validator_for } from "../../wasm/pkg/jsoncompat_wasm.js";

const out = document.getElementById('out');

Expand Down Expand Up @@ -47,6 +47,15 @@ <h1>json_schema_wasm - browser demo</h1>
lines.push('\n=== Example value generation ===');
const sample = await generate_value(oldSchema, 3);
lines.push(sample);
const validator = validator_for(oldSchema);
if (!validator.is_valid(sample)) {
throw new Error("generated sample failed reusable validation");
}
if (validator.is_valid('{"name": 1}')) {
throw new Error("invalid sample passed reusable validation");
}
lines.push('\n=== Reusable validation ===');
lines.push(`generated JSON is valid: ${validator.is_valid(sample)}`);

out.textContent = lines.join('\n');
})();
Expand Down
9 changes: 9 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ print(is_compatible)

example = jsc.generate_value(old_schema, depth=5)
print(example)

validator = jsc.validator_for(old_schema)
print(validator.is_valid_json(example))
print(validator.is_valid_value("hello"))
```

## API
Expand All @@ -33,6 +37,11 @@ print(example)
- `generate_value(schema_json: str, depth: int = 5) -> str`
- Returns a JSON string for one generated value accepted by the schema.
- Raises `ValueError` when the schema is invalid, known to be unsatisfiable, or generation exhausts its retry budget.
- `validator_for(schema_json: str) -> Validator`
- Parses the schema once and returns a reusable validator.
- `Validator.is_valid_json(instance_json: str) -> bool` validates JSON strings against the parsed schema.
- `Validator.is_valid_value(instance: JsonValue) -> bool` validates Python JSON-compatible values: `None`, `bool`, finite `int`/`float`, `str`, `list`, `tuple`, and `dict[str, ...]`.
- `Validator.is_valid(instance_json: str) -> bool` remains a short compatibility alias for JSON-string validation.
- `Role.SERIALIZER`, `Role.DESERIALIZER`, and `Role.BOTH` are string constants accepted by `check_compat`.

Schemas are passed as JSON strings. `check_compat` returns a boolean verdict and raises `ValueError` for invalid JSON, invalid schemas, or hard unsupported compatibility cases.
Expand Down
18 changes: 17 additions & 1 deletion python/jsoncompat.pyi
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
"""Typing stubs for jsoncompat Python package"""

from typing import Final, Literal
from typing import Final, Literal, TypeAlias

RoleLiteral = Literal["serializer", "deserializer", "both"]
JsonValue: TypeAlias = (
None
| bool
| int
| float
| str
| list["JsonValue"]
| tuple["JsonValue", ...]
| dict[str, "JsonValue"]
)

class _Role:
SERIALIZER: Final[Literal["serializer"]]
Expand All @@ -11,9 +21,15 @@ class _Role:

Role: _Role

class Validator:
def is_valid(self, instance_json: str) -> bool: ...
def is_valid_json(self, instance_json: str) -> bool: ...
def is_valid_value(self, instance: JsonValue) -> bool: ...

def check_compat(
old_schema_json: str, new_schema_json: str, role: RoleLiteral = "both"
) -> bool: ...
def generate_value(schema_json: str, depth: int = 5) -> str: ...
def validator_for(schema_json: str) -> Validator: ...

__all__: list[str]
118 changes: 116 additions & 2 deletions python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,39 @@
//! constants module. Both functions accept JSON schemas as strings and report
//! invalid inputs or hard unsupported core-library cases as `ValueError`.

use pyo3::exceptions::PyValueError;
use pyo3::exceptions::{PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};

use ::jsoncompat::{Role, SchemaDocument, check_compat, validate_compatibility_input};
use json_schema_fuzz::{GenerateError, GenerationConfig, ValueGenerator};

use serde_json::Value as JsonValue;
use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};

#[pyclass(name = "Validator", module = "jsoncompat", unsendable)]
struct ValidatorPy {
schema: SchemaDocument,
}

#[pymethods]
impl ValidatorPy {
/// Check whether a JSON value encoded as a string satisfies this validator's schema.
fn is_valid(&self, instance_json: &str) -> PyResult<bool> {
self.is_valid_json(instance_json)
}

/// Check whether a JSON value encoded as a string satisfies this validator's schema.
fn is_valid_json(&self, instance_json: &str) -> PyResult<bool> {
let instance = parse_json(instance_json)?;
validate_value_for_schema(&self.schema, &instance)
}

/// Check whether a Python JSON-compatible value satisfies this validator's schema.
fn is_valid_value(&self, instance: &Bound<'_, PyAny>) -> PyResult<bool> {
let instance = py_to_json_value(instance)?;
validate_value_for_schema(&self.schema, &instance)
}
}

fn validated_schema(raw: &JsonValue) -> Result<SchemaDocument, String> {
let schema = SchemaDocument::from_json(raw).map_err(|error| error.to_string())?;
Expand All @@ -27,6 +53,82 @@ fn compatibility_schema(raw: &JsonValue) -> Result<SchemaDocument, String> {
Ok(schema)
}

fn validate_value_for_schema(schema: &SchemaDocument, instance: &JsonValue) -> PyResult<bool> {
schema
.is_valid(instance)
.map_err(|e| PyErr::new::<PyValueError, _>(format!("Validation failed: {e}")))
}

fn py_to_json_value(value: &Bound<'_, PyAny>) -> PyResult<JsonValue> {
if value.is_none() {
return Ok(JsonValue::Null);
}
if value.is_instance_of::<PyBool>() {
return Ok(JsonValue::Bool(value.extract::<bool>()?));
}
if value.is_instance_of::<PyInt>() {
return py_int_to_json_value(value);
}
if value.is_instance_of::<PyFloat>() {
let number = value.extract::<f64>()?;
if !number.is_finite() {
return Err(PyErr::new::<PyValueError, _>("JSON numbers must be finite"));
}
let Some(number) = JsonNumber::from_f64(number) else {
return Err(PyErr::new::<PyValueError, _>(
"failed to convert Python float to JSON number",
));
};
return Ok(JsonValue::Number(number));
}
if value.is_instance_of::<PyString>() {
return Ok(JsonValue::String(value.extract::<String>()?));
}
if let Ok(list) = value.cast::<PyList>() {
return list
.iter()
.map(|item| py_to_json_value(&item))
.collect::<PyResult<Vec<_>>>()
.map(JsonValue::Array);
}
if let Ok(tuple) = value.cast::<PyTuple>() {
return tuple
.iter()
.map(|item| py_to_json_value(&item))
.collect::<PyResult<Vec<_>>>()
.map(JsonValue::Array);
}
if let Ok(dict) = value.cast::<PyDict>() {
let mut object = JsonMap::with_capacity(dict.len());
for (key, item) in dict {
if !key.is_instance_of::<PyString>() {
return Err(PyErr::new::<PyTypeError, _>(
"JSON object keys must be strings",
));
}
object.insert(key.extract::<String>()?, py_to_json_value(&item)?);
}
return Ok(JsonValue::Object(object));
}

Err(PyErr::new::<PyTypeError, _>(format!(
"expected a JSON-compatible value, got {}",
value.get_type().name()?
)))
}

fn py_int_to_json_value(value: &Bound<'_, PyAny>) -> PyResult<JsonValue> {
if let Ok(number) = value.extract::<i64>() {
return Ok(JsonValue::Number(JsonNumber::from(number)));
}
if let Ok(number) = value.extract::<u64>() {
return Ok(JsonValue::Number(JsonNumber::from(number)));
}
Err(PyErr::new::<PyValueError, _>(
"JSON integer is outside the supported range",
))
}

/// Parse a JSON string into a serde_json::Value, converting any error into a Python ValueError.
fn parse_json(s: &str) -> PyResult<JsonValue> {
serde_json::from_str(s).map_err(|e| PyErr::new::<PyValueError, _>(format!("Invalid JSON: {e}")))
Expand Down Expand Up @@ -115,12 +217,24 @@ fn generate_value_py(schema_json: &str, depth: u8) -> PyResult<String> {
})
}

/// Build a reusable validator for one JSON Schema document.
#[pyfunction]
#[pyo3(signature = (schema_json), name = "validator_for")]
fn validator_for_py(schema_json: &str) -> PyResult<ValidatorPy> {
let raw = parse_json(schema_json)?;
let schema = validated_schema(&raw)
.map_err(|e| PyErr::new::<PyValueError, _>(format!("Invalid schema: {e}")))?;
Ok(ValidatorPy { schema })
}

/// Python module definition
#[pymodule]
#[pyo3(name = "jsoncompat")]
fn jsoncompat(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(check_compat_py, m)?)?;
m.add_function(wrap_pyfunction!(generate_value_py, m)?)?;
m.add_function(wrap_pyfunction!(validator_for_py, m)?)?;
m.add_class::<ValidatorPy>()?;

let role_constants = PyModule::new(py, "Role")?;
role_constants.add("SERIALIZER", "serializer")?;
Expand Down
8 changes: 7 additions & 1 deletion wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ npm install jsoncompat@0.3.1
## Quick start

```js
import init, { check_compat, generate_value } from "jsoncompat";
import init, { check_compat, generate_value, validator_for } from "jsoncompat";

await init();

Expand All @@ -21,6 +21,8 @@ const newSchema = '{"type":["string","null"]}';

const ok = check_compat(oldSchema, newSchema, "deserializer");
const valueJson = generate_value(newSchema, 5);
const validator = validator_for(newSchema);
const valueOk = validator.is_valid(valueJson);
```

## API
Expand All @@ -29,6 +31,10 @@ const valueJson = generate_value(newSchema, 5);
accepts `"serializer"`, `"deserializer"`, or `"both"` for `role`.
- `generate_value(schema_json, depth) -> string` returns one generated JSON value
encoded as a string.
- `validator_for(schema_json) -> Validator` parses a schema once and returns a
reusable validator.
- `Validator.is_valid(instance_json) -> boolean` validates a JSON string
against the parsed schema.

Both functions accept schemas as JSON strings and throw string-backed
`wasm-bindgen` errors for invalid JSON, invalid schemas, hard unsupported
Expand Down
40 changes: 37 additions & 3 deletions wasm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! WebAssembly bindings for the `jsoncompat` compatibility checker and value generator.
//!
//! JavaScript callers get two exported functions: `check_compat` and
//! `generate_value`. Both accept schemas as JSON strings and return JavaScript
//! values or string errors through `wasm-bindgen`.
//! JavaScript callers get exported compatibility helpers plus one-shot
//! generation and reusable validation. Public functions accept schemas as JSON
//! strings and return JavaScript values or string errors through `wasm-bindgen`.

use wasm_bindgen::prelude::*;

Expand Down Expand Up @@ -47,6 +47,28 @@ fn parse_role(role: &str) -> Result<Role, JsValue> {
}
}

fn validate_json_for_schema(schema: &SchemaDocument, instance_json: &str) -> Result<bool, JsValue> {
let instance = parse_json(instance_json)?;
schema
.is_valid(&instance)
.map_err(|e| JsValue::from_str(&format!("validation failed: {e}")))
}

/// Reusable validator for one JSON Schema document.
#[wasm_bindgen]
pub struct Validator {
schema: SchemaDocument,
}

#[wasm_bindgen]
impl Validator {
/// Check whether a JSON value encoded as a string satisfies this validator's schema.
#[wasm_bindgen(js_name = is_valid)]
pub fn is_valid_js(&self, instance_json: &str) -> Result<bool, JsValue> {
validate_json_for_schema(&self.schema, instance_json)
}
}

/// Check compatibility between two schemas.
///
/// * `old_schema_json` – original schema as JSON string
Expand Down Expand Up @@ -95,6 +117,18 @@ pub fn generate_value_js(schema_json: &str, depth: u8) -> Result<String, JsValue
serde_json::to_string(&v).map_err(|e| JsValue::from_str(&format!("serialization failure: {e}")))
}

/// Build a reusable validator for a JSON Schema.
///
/// * `schema_json` – schema as JSON string
/// Exported to JavaScript as `validator_for`.
#[wasm_bindgen(js_name = validator_for)]
pub fn validator_for_js(schema_json: &str) -> Result<Validator, JsValue> {
let raw = parse_json(schema_json)?;
let schema =
validated_schema(&raw).map_err(|e| JsValue::from_str(&format!("invalid schema: {e}")))?;
Ok(Validator { schema })
}

#[cfg(test)]
mod tests {
use super::{compatibility_schema, validated_schema};
Expand Down
Loading