use crate::error::Error;
use serde_json::Value;
use std::sync::OnceLock;
fn registry() -> &'static Value {
static SCHEMAS: OnceLock<Value> = OnceLock::new();
SCHEMAS.get_or_init(|| {
serde_json::from_str(include_str!("runtime_schema.json"))
.expect("generated schema registry")
})
}
pub(crate) fn model(value: &Value, name: &str, request: bool) -> Result<(), Error> {
if !enabled(
registry(),
if request {
"validateRequests"
} else {
"validateResponses"
},
) {
return Ok(());
}
let schema = registry()["models"]
.get(name)
.ok_or_else(|| Error::Validation(format!("missing model schema: {name}")))?;
check(value, schema, registry(), "$", request, 0).map_err(Error::Validation)
}
pub(crate) fn operation(value: &Value, key: &str, request: bool) -> Result<(), Error> {
if !enabled(
registry(),
if request {
"validateRequests"
} else {
"validateResponses"
},
) {
return Ok(());
}
let direction = if request { "request" } else { "response" };
if let Some(schema) = registry()["operations"][key].get(direction) {
check(value, schema, registry(), "$", request, 0)
.map_err(|e| Error::Validation(format!("{direction} {e}")))?;
}
Ok(())
}
fn enabled(root: &Value, key: &str) -> bool {
root["policy"][key].as_bool().unwrap_or(true)
}
pub(crate) fn allow_unknown_union() -> bool {
enabled(registry(), "allowUnknownResponseUnionVariants")
}
pub(crate) fn union(value: &Value, descriptor: &str) -> Result<(), Error> {
if !enabled(registry(), "validateResponses") {
return Ok(());
}
let schema: Value =
serde_json::from_str(descriptor).map_err(|e| Error::Serialization(e.to_string()))?;
check(value, &schema, registry(), "$", false, 0).map_err(Error::Validation)
}
pub(crate) fn present_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
<Option<T> as serde::Deserialize>::deserialize(deserializer).map(Some)
}
pub(crate) fn retain_model_fields(value: &mut Value, name: &str) {
if !enabled(registry(), "preserveUnknownResponseFields")
&& let Some(obj) = value.as_object_mut()
{
obj.retain(|key, _| registry()["models"][name]["properties"].get(key).is_some());
}
}
fn check(
v: &Value,
s: &Value,
root: &Value,
path: &str,
request: bool,
depth: usize,
) -> Result<(), String> {
let fail = |message: &str| Err(format!("{path}: {message}"));
if depth > 128 {
return fail("schema nesting limit exceeded");
}
let next = |value: &Value, schema: &Value, at: &str| {
check(value, schema, root, at, request, depth + 1)
};
match s["kind"].as_str().unwrap_or("any") {
"any" => return Ok(()),
"ref" => {
let name = s["name"]
.as_str()
.ok_or_else(|| format!("{path}: missing schema reference"))?;
let target = root["models"]
.get(name)
.ok_or_else(|| format!("{path}: missing schema {name}"))?;
return next(v, target, path);
}
"nullable" => {
return if v.is_null() {
Ok(())
} else {
next(v, &s["inner"], path)
};
}
"literal" => {
if v != &s["value"] {
return fail("unexpected literal value");
}
}
"enum" => {
let values = s["values"]
.as_array()
.ok_or_else(|| format!("{path}: missing enum values"))?;
let same_type = values.iter().any(|known| {
(known.is_string() && v.is_string())
|| (known.is_number() && v.is_number())
|| (known.is_boolean() && v.is_boolean())
});
if !values.contains(v)
&& (request || !same_type || !enabled(root, "allowUnknownResponseEnumValues"))
{
return fail("unexpected enum value or type");
}
}
"primitive" => {
let valid = match s["type"].as_str().unwrap_or("unknown") {
"unknown" => true,
"string" if s["format"] == "binary" => v
.as_array()
.is_some_and(|a| a.iter().all(|b| b.as_u64().is_some_and(|n| n <= 255))),
"string" => v.is_string(),
"boolean" => v.is_boolean(),
"integer" => v.as_i64().is_some() || v.as_u64().is_some(),
"number" => v.is_number(),
_ => false,
};
if !valid {
return fail("incorrect primitive type");
}
}
"array" => {
let items = v
.as_array()
.ok_or_else(|| format!("{path}: expected array"))?;
for (i, item) in items.iter().enumerate() {
next(item, &s["items"], &format!("{path}[{i}]"))?;
}
}
"map" | "object" => {
let obj = v
.as_object()
.ok_or_else(|| format!("{path}: expected object"))?;
if let Some(required) = s["required"].as_array() {
for key in required.iter().filter_map(Value::as_str) {
if !obj.contains_key(key) {
return Err(format!("{path}.{key}: required field is missing"));
}
}
}
for (key, value) in obj {
let at = format!("{path}.{key}");
if s["kind"] == "map" {
next(value, &s["items"], &at)?;
} else if let Some(field) = s["properties"].get(key) {
next(value, field, &at)?;
} else if s["additionalProperties"].is_object() {
next(value, &s["additionalProperties"], &at)?;
} else if (request || !enabled(root, "preserveUnknownResponseFields"))
&& s["additionalProperties"] == false
{
return Err(format!("{at}: unknown field"));
}
}
if s["discriminator"].is_object() {
discriminator(v, s, root, path, request, depth)?;
}
}
"union" => {
if s["discriminator"].is_object() {
return discriminator(v, s, root, path, request, depth);
}
let variants = s["variants"]
.as_array()
.ok_or_else(|| format!("{path}: missing union variants"))?;
let matches = variants
.iter()
.filter(|variant| next(v, variant, path).is_ok())
.count();
let valid = match s["composition"].as_str().unwrap_or("oneOf") {
"allOf" => matches == variants.len(),
"anyOf" => matches > 0,
_ => matches == 1,
};
if !valid {
return fail("value does not match union schema");
}
}
_ => return fail("unsupported runtime schema"),
}
constraints(v, &s["constraints"], path)
}
fn discriminator(
v: &Value,
s: &Value,
root: &Value,
path: &str,
request: bool,
depth: usize,
) -> Result<(), String> {
let property = s["discriminator"]["property"]
.as_str()
.ok_or_else(|| format!("{path}: missing discriminator property"))?;
let tag = v
.get(property)
.and_then(Value::as_str)
.ok_or_else(|| format!("{path}.{property}: expected string discriminator"))?;
if let Some(variant) = s["discriminator"]["mapping"].get(tag) {
check(v, variant, root, path, request, depth + 1)
} else if request || !enabled(root, "allowUnknownResponseUnionVariants") {
Err(format!("{path}.{property}: unknown union variant"))
} else {
Ok(())
}
}
fn constraints(v: &Value, c: &Value, path: &str) -> Result<(), String> {
let fail = |key: &str| Err(format!("{path}: violates {key}"));
if let Some(n) = v.as_f64() {
if c["exclusiveMinimum"] == true && c["minimum"].as_f64().is_some_and(|x| n <= x) {
return fail("exclusiveMinimum");
}
if c["exclusiveMaximum"] == true && c["maximum"].as_f64().is_some_and(|x| n >= x) {
return fail("exclusiveMaximum");
}
for (key, valid) in [
("minimum", c["minimum"].as_f64().is_none_or(|x| n >= x)),
("maximum", c["maximum"].as_f64().is_none_or(|x| n <= x)),
(
"exclusiveMinimum",
c["exclusiveMinimum"].as_f64().is_none_or(|x| n > x),
),
(
"exclusiveMaximum",
c["exclusiveMaximum"].as_f64().is_none_or(|x| n < x),
),
(
"multipleOf",
c["multipleOf"]
.as_f64()
.is_none_or(|x| x > 0.0 && (n / x - (n / x).round()).abs() <= 1e-9),
),
] {
if !valid {
return fail(key);
}
}
}
if let Some(text) = v.as_str() {
length(text.chars().count(), c, "minLength", "maxLength", path)?;
if let Some(pattern) = c["pattern"].as_str() {
let regex = regex::Regex::new(pattern)
.map_err(|_| format!("{path}: invalid schema pattern"))?;
if !regex.is_match(text) {
return fail("pattern");
}
}
}
if let Some(items) = v.as_array() {
length(items.len(), c, "minItems", "maxItems", path)?;
if c["uniqueItems"] == true
&& items
.iter()
.enumerate()
.any(|(i, item)| items[..i].contains(item))
{
return fail("uniqueItems");
}
}
if let Some(obj) = v.as_object() {
length(obj.len(), c, "minProperties", "maxProperties", path)?;
}
Ok(())
}
fn length(n: usize, c: &Value, min: &str, max: &str, path: &str) -> Result<(), String> {
if c[min].as_u64().is_some_and(|x| (n as u64) < x) {
return Err(format!("{path}: violates {min}"));
}
if c[max].as_u64().is_some_and(|x| (n as u64) > x) {
return Err(format!("{path}: violates {max}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn validate(value: Value, schema: Value, request: bool) -> Result<(), String> {
check(&value, &schema, &json!({}), "$", request, 0)
}
#[test]
fn response_requiredness_and_primitives_are_enforced() {
let s = json!({"kind":"object","required":["name","nullable"],"properties":{"name":{"kind":"primitive","type":"string"},"nullable":{"kind":"nullable","inner":{"kind":"primitive","type":"integer"}}}});
assert!(validate(json!({"name":"ok","nullable":null}), s.clone(), false).is_ok());
for v in [
json!({"name":"ok"}),
json!({"nullable":null}),
json!({"name":false,"nullable":null}),
json!({"name":null,"nullable":1}),
] {
assert!(validate(v, s.clone(), false).is_err());
}
}
#[test]
fn response_evolution_and_request_enforcement_are_distinct() {
let s = json!({"kind":"object","additionalProperties":false,"properties":{"state":{"kind":"enum","values":["old"]}}});
assert!(
validate(
json!({"state":"new","added":{"complete":true}}),
s.clone(),
false
)
.is_ok()
);
assert!(validate(json!({"state":"new"}), s.clone(), true).is_err());
assert!(validate(json!({"added":1}), s.clone(), true).is_err());
assert!(validate(json!({"state":1}), s, false).is_err());
}
#[test]
fn numeric_string_and_collection_constraints() {
for request in [false, true] {
let number = json!({"kind":"primitive","type":"number","constraints":{"minimum":1,"exclusiveMinimum":true,"maximum":5,"exclusiveMaximum":true,"multipleOf":0.5}});
assert!(validate(json!(2.5), number.clone(), request).is_ok());
for n in [1.0, 5.0, 2.25] {
assert!(validate(json!(n), number.clone(), request).is_err());
}
let number = json!({"kind":"primitive","type":"number","constraints":{"exclusiveMinimum":1,"exclusiveMaximum":5}});
assert!(validate(json!(1), number.clone(), request).is_err());
assert!(validate(json!(5), number, request).is_err());
let string = json!({"kind":"primitive","type":"string","constraints":{"minLength":2,"maxLength":3,"pattern":"^a"}});
assert!(validate(json!("ab"), string.clone(), request).is_ok());
for text in ["a", "abcd", "bc"] {
assert!(validate(json!(text), string.clone(), request).is_err());
}
let array = json!({"kind":"array","items":{"kind":"primitive","type":"integer"},"constraints":{"minItems":1,"maxItems":2,"uniqueItems":true}});
assert!(validate(json!([1, 2]), array.clone(), request).is_ok());
for v in [json!([]), json!([1, 1]), json!([1, 2, 3]), json!(["x"])] {
assert!(validate(v, array.clone(), request).is_err());
}
let map = json!({"kind":"map","items":{"kind":"primitive","type":"string"},"constraints":{"minProperties":1,"maxProperties":2}});
assert!(validate(json!({}), map.clone(), request).is_err());
assert!(validate(json!({"x":1}), map.clone(), request).is_err());
assert!(validate(json!({"x":"a","y":"b","z":"c"}), map, request).is_err());
}
}
#[test]
fn recursive_models_and_known_union_shapes() {
let root = json!({"models":{"Leaf":{"kind":"object","required":["kind","count"],"properties":{"kind":{"kind":"literal","value":"leaf"},"count":{"kind":"primitive","type":"integer","constraints":{"minimum":1}}}},"Node":{"kind":"object","required":["label"],"properties":{"label":{"kind":"primitive","type":"string"},"child":{"kind":"ref","name":"Node"}}}}});
let union = json!({"kind":"union","discriminator":{"property":"kind","mapping":{"leaf":{"kind":"ref","name":"Leaf"}}}});
assert!(
check(
&json!({"kind":"leaf","count":2}),
&union,
&root,
"$",
false,
0
)
.is_ok()
);
for v in [
json!({"kind":"leaf"}),
json!({"kind":"leaf","count":0}),
json!({"count":2}),
json!({"kind":1}),
] {
assert!(check(&v, &union, &root, "$", false, 0).is_err());
}
let future = json!({"kind":"future","all":{"data":[1,2]}});
assert!(check(&future, &union, &root, "$", false, 0).is_ok());
assert!(check(&future, &union, &root, "$", true, 0).is_err());
assert!(
check(
&json!({"label":"root","child":{"label":7}}),
&json!({"kind":"ref","name":"Node"}),
&root,
"$",
false,
0
)
.is_err()
);
}
#[test]
fn composition_semantics_do_not_accept_malformed_untagged_values() {
let variants =
json!([{"kind":"primitive","type":"number"},{"kind":"primitive","type":"integer"}]);
assert!(
validate(
json!(1),
json!({"kind":"union","variants":variants,"composition":"oneOf"}),
false
)
.is_err()
);
assert!(
validate(
json!(1),
json!({"kind":"union","variants":variants,"composition":"anyOf"}),
false
)
.is_ok()
);
assert!(
validate(
json!(1.5),
json!({"kind":"union","variants":variants,"composition":"allOf"}),
false
)
.is_err()
);
assert!(
validate(
json!("unknown"),
json!({"kind":"union","variants":variants}),
false
)
.is_err()
);
}
}