use serde_json::Value;
pub const X_GTS_TRAITS_SCHEMA: &str = "x-gts-traits-schema";
pub const X_GTS_TRAITS: &str = "x-gts-traits";
const MAX_RECURSION_DEPTH: usize = 64;
pub trait GtsTraitsSchema: schemars::JsonSchema {}
#[must_use]
#[allow(clippy::expect_used)]
pub fn inline_traits_schema_of<T: schemars::JsonSchema>() -> Value {
let mut generator = schemars::generate::SchemaSettings::default()
.with(|s| s.inline_subschemas = true)
.into_generator();
let schema = <T as schemars::JsonSchema>::json_schema(&mut generator);
let mut value = serde_json::to_value(&schema)
.expect("schemars JsonSchema serialization to a JSON value is infallible for valid types");
if let Some(obj) = value.as_object_mut() {
obj.remove("$schema");
}
value
}
#[cfg(test)]
pub fn validate_traits_chain(chain_schemas: &[(String, Value)]) -> Result<(), Vec<String>> {
let mut trait_schemas = Vec::new();
let mut merged = serde_json::Map::new();
for (_id, content) in chain_schemas {
collect_trait_schema_from_value(content, &mut trait_schemas);
collect_traits_from_value(content, &mut merged);
}
let dialect = chain_schemas
.last()
.and_then(|(_, content)| content.get("$schema").and_then(Value::as_str));
validate_effective_traits(&trait_schemas, &Value::Object(merged), true, dialect)
}
pub fn validate_effective_traits(
resolved_trait_schemas: &[Value],
merged_traits: &Value,
check_unresolved: bool,
dialect: Option<&str>,
) -> Result<(), Vec<String>> {
let has_trait_values = merged_traits.as_object().is_some_and(|m| !m.is_empty());
if resolved_trait_schemas.is_empty() {
if has_trait_values {
return Err(vec![format!(
"{X_GTS_TRAITS} values provided but no {X_GTS_TRAITS_SCHEMA} is defined in the \
inheritance chain"
)]);
}
return Ok(());
}
for (i, ts) in resolved_trait_schemas.iter().enumerate() {
match ts {
Value::Bool(_) => {}
Value::Object(_) => {
if let Err(e) = jsonschema::validator_for(ts) {
return Err(vec![format!(
"x-gts-traits-schema[{i}] is not a valid JSON Schema: {e}"
)]);
}
}
_ => {
return Err(vec![format!(
"x-gts-traits-schema[{i}] must be an object subschema or a boolean; got {ts}"
)]);
}
}
}
let mut effective_trait_schema = build_effective_trait_schema(resolved_trait_schemas);
if effective_schema_is_false(&effective_trait_schema) {
if has_trait_values {
return Err(vec![format!(
"{X_GTS_TRAITS_SCHEMA} resolves to `false` in the chain — \
{X_GTS_TRAITS} values are prohibited"
)]);
}
return Ok(());
}
if let Some(dialect) = dialect
&& let Some(obj) = effective_trait_schema.as_object_mut()
{
obj.insert("$schema".to_owned(), Value::String(dialect.to_owned()));
}
let effective_traits = apply_defaults(&effective_trait_schema, merged_traits);
let mut errors = match validate_traits_against_schema(
&effective_trait_schema,
&effective_traits,
check_unresolved,
) {
Ok(()) => Vec::new(),
Err(e) => e,
};
let xref = crate::x_gts_ref::XGtsRefValidator::new();
for err in xref.validate_instance(&effective_traits, &effective_trait_schema, "") {
errors.push(format!("trait x-gts-ref: {err}"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn effective_schema_is_false(schema: &Value) -> bool {
effective_schema_is_false_recursive(schema, 0)
}
fn effective_schema_is_false_recursive(schema: &Value, depth: usize) -> bool {
if depth >= MAX_RECURSION_DEPTH {
return false;
}
match schema {
Value::Bool(false) => true,
Value::Object(obj) => {
if let Some(Value::Array(items)) = obj.get("allOf") {
items
.iter()
.any(|item| effective_schema_is_false_recursive(item, depth + 1))
} else {
false
}
}
_ => false,
}
}
pub(crate) fn collect_trait_schema_from_value(value: &Value, out: &mut Vec<Value>) {
collect_trait_schema_recursive(value, out, 0);
}
fn collect_trait_schema_recursive(value: &Value, out: &mut Vec<Value>, depth: usize) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
let Some(obj) = value.as_object() else {
return;
};
if let Some(ts) = obj.get(X_GTS_TRAITS_SCHEMA) {
out.push(ts.clone());
}
if let Some(Value::Array(all_of)) = obj.get("allOf") {
for item in all_of {
collect_trait_schema_recursive(item, out, depth + 1);
}
}
}
pub(crate) fn collect_traits_from_value(
value: &Value,
merged: &mut serde_json::Map<String, Value>,
) {
collect_traits_recursive(value, merged, 0);
}
fn collect_traits_recursive(
value: &Value,
merged: &mut serde_json::Map<String, Value>,
depth: usize,
) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
let Some(obj) = value.as_object() else {
return;
};
if let Some(Value::Object(traits)) = obj.get(X_GTS_TRAITS) {
for (k, v) in traits {
merged.insert(k.clone(), v.clone());
}
}
if let Some(Value::Array(all_of)) = obj.get("allOf") {
for item in all_of {
collect_traits_recursive(item, merged, depth + 1);
}
}
}
pub(crate) fn merge_rfc7396_into(
target: &mut serde_json::Map<String, Value>,
patch: &serde_json::Map<String, Value>,
) {
merge_rfc7396_recursive(target, patch, 0);
}
fn merge_rfc7396_recursive(
target: &mut serde_json::Map<String, Value>,
patch: &serde_json::Map<String, Value>,
depth: usize,
) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
for (k, v) in patch {
match v {
Value::Null => {
target.remove(k);
}
Value::Object(patch_obj) => {
if let Some(Value::Object(existing)) = target.get_mut(k) {
merge_rfc7396_recursive(existing, patch_obj, depth + 1);
} else {
let mut fresh = serde_json::Map::new();
merge_rfc7396_recursive(&mut fresh, patch_obj, depth + 1);
target.insert(k.clone(), Value::Object(fresh));
}
}
other => {
target.insert(k.clone(), other.clone());
}
}
}
}
fn build_effective_trait_schema(schemas: &[Value]) -> Value {
match schemas.len() {
0 => Value::Object(serde_json::Map::new()),
1 => schemas[0].clone(),
_ => {
let mut wrapper = serde_json::Map::new();
wrapper.insert("type".to_owned(), Value::String("object".to_owned()));
wrapper.insert("allOf".to_owned(), Value::Array(schemas.to_vec()));
Value::Object(wrapper)
}
}
}
fn apply_defaults(trait_schema: &Value, traits: &Value) -> Value {
apply_defaults_recursive(trait_schema, traits, 0)
}
fn apply_defaults_recursive(trait_schema: &Value, traits: &Value, depth: usize) -> Value {
if depth >= MAX_RECURSION_DEPTH {
return traits.clone();
}
let mut result = match traits {
Value::Object(m) => m.clone(),
_ => serde_json::Map::new(),
};
let props = collect_all_properties(trait_schema);
for (prop_name, prop_schema) in &props {
if let Some(prop_obj) = prop_schema.as_object() {
if !result.contains_key(prop_name.as_str()) {
if let Some(default_val) = prop_obj.get("default") {
result.insert(prop_name.clone(), default_val.clone());
}
} else if prop_obj.get("type") == Some(&Value::String("object".to_owned()))
&& prop_obj.contains_key("properties")
{
let nested = apply_defaults_recursive(
prop_schema,
result.get(prop_name.as_str()).unwrap_or(&Value::Null),
depth + 1,
);
result.insert(prop_name.clone(), nested);
}
}
}
Value::Object(result)
}
fn collect_all_properties(schema: &Value) -> Vec<(String, Value)> {
let mut props = Vec::new();
collect_props_recursive(schema, &mut props, 0);
let mut seen = std::collections::HashSet::new();
let mut deduped = Vec::with_capacity(props.len());
for (name, schema) in props.into_iter().rev() {
if seen.insert(name.clone()) {
deduped.push((name, schema));
}
}
deduped.reverse();
deduped
}
fn collect_props_recursive(schema: &Value, props: &mut Vec<(String, Value)>, depth: usize) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
let Some(obj) = schema.as_object() else {
return;
};
if let Some(Value::Object(p)) = obj.get("properties") {
for (k, v) in p {
props.push((k.clone(), v.clone()));
}
}
if let Some(Value::Array(all_of)) = obj.get("allOf") {
for item in all_of {
collect_props_recursive(item, props, depth + 1);
}
}
}
fn collect_all_required(schema: &Value) -> std::collections::HashSet<String> {
let mut req = std::collections::HashSet::new();
collect_required_recursive(schema, &mut req, 0);
req
}
fn collect_required_recursive(
schema: &Value,
req: &mut std::collections::HashSet<String>,
depth: usize,
) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
let Some(obj) = schema.as_object() else {
return;
};
if let Some(Value::Array(required)) = obj.get("required") {
for item in required {
if let Some(name) = item.as_str() {
req.insert(name.to_owned());
}
}
}
if let Some(Value::Array(all_of)) = obj.get("allOf") {
for item in all_of {
collect_required_recursive(item, req, depth + 1);
}
}
}
fn validate_traits_against_schema(
trait_schema: &Value,
effective_traits: &Value,
check_unresolved: bool,
) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
match jsonschema::validator_for(trait_schema) {
Ok(validator) => {
for error in validator.iter_errors(effective_traits) {
errors.push(format!("trait validation: {error}"));
}
}
Err(e) => {
errors.push(format!("failed to compile trait schema: {e}"));
}
}
if !check_unresolved {
return if errors.is_empty() {
Ok(())
} else {
Err(errors)
};
}
let all_props = collect_all_properties(trait_schema);
let required = collect_all_required(trait_schema);
let traits_obj = effective_traits.as_object();
for (prop_name, prop_schema) in &all_props {
if !required.contains(prop_name.as_str()) {
continue;
}
let has_value = traits_obj.is_some_and(|m| m.contains_key(prop_name.as_str()));
let has_default = prop_schema
.as_object()
.is_some_and(|m| m.contains_key("default"));
if !has_value && !has_default {
let expected_type = prop_schema
.as_object()
.and_then(|m| m.get("type"))
.and_then(Value::as_str)
.unwrap_or("any");
errors.push(format!(
"trait property '{prop_name}' (type: {expected_type}) is not resolved: \
no value provided and no default defined in the trait schema. \
All traits must be resolved (via a {X_GTS_TRAITS} value in the chain \
or a `default` in the trait schema) on non-abstract types; otherwise \
mark the type abstract (x-gts-abstract: true)"
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_no_traits_schema_passes() {
let chain = vec![(
"gts.x.test.base.v1~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}}),
)];
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_traits_without_schema_in_derived_fails() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"retention": "P30D"}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter().any(|e| e.contains("no x-gts-traits-schema")),
"should fail when traits provided without schema: {err:?}"
);
}
#[test]
fn test_traits_without_schema_in_base_fails() {
let chain = vec![(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"retention": "P30D"},
"properties": {"id": {"type": "string"}}
}),
)];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter().any(|e| e.contains("no x-gts-traits-schema")),
"should fail when base has traits but no schema: {err:?}"
);
}
#[test]
fn test_all_traits_resolved() {
let chain = vec![
(
"gts.x.test.base.v1~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retention": {"type": "string"},
"topicRef": {"type": "string"}
}
}
}),
),
(
"gts.x.test.base.v1~x.test._.derived.v1~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"retention": "P90D",
"topicRef": "gts.x.core.events.topic.v1~x.test._.orders.v1"
}
}),
),
];
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_defaults_fill_traits() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P30D"},
"topicRef": {"type": "string", "default": "default_topic"}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}),
),
];
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_missing_required_trait_fails() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"topicRef": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
},
"required": ["topicRef"]
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"retention": "P90D"
}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter().any(|e| e.contains("topicRef")),
"should mention missing topicRef: {err:?}"
);
}
#[test]
fn test_optional_unresolved_trait_passes() {
let chain = vec![(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"topicRef": {"type": "string"},
"note": {"type": "string"}
},
"required": ["topicRef"]
},
"x-gts-traits": {"topicRef": "events.orders"}
}),
)];
assert!(
validate_traits_chain(&chain).is_ok(),
"optional unresolved trait property must not fail completeness"
);
}
#[test]
fn test_wrong_type_fails() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"maxRetries": {"type": "integer", "minimum": 0, "default": 3}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"maxRetries": "not_a_number"
}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(!err.is_empty(), "wrong type should fail");
}
#[test]
fn test_unknown_property_fails() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"retention": {"type": "string", "default": "P30D"}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"retention": "P90D",
"unknownTrait": "some_value"
}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter()
.any(|e| e.contains("additional") || e.contains("unknownTrait")),
"unknown property should fail: {err:?}"
);
}
#[test]
fn test_override_in_chain() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retention": {"type": "string"}
}
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"retention": "P30D"}
}),
),
(
"leaf~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"retention": "P365D"}
}),
),
];
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_both_keywords_in_same_schema() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"topicRef": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
}
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"auditRetention": {"type": "string", "default": "P365D"}
}
},
"x-gts-traits": {
"topicRef": "gts.x.core.events.topic.v1~x.test._.audit.v1"
}
}),
),
];
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_three_level_chain_missing_in_leaf() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P30D"}
}
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {"type": "string"}
},
"required": ["priority"]
}
}),
),
(
"leaf~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"retention": "P90D"}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter().any(|e| e.contains("priority")),
"should mention missing priority: {err:?}"
);
}
#[test]
fn test_enum_constraint_violation() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"],
"default": "medium"
}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"priority": "ultra_high"}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(!err.is_empty(), "enum violation should fail");
}
#[test]
fn test_minimum_violation() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"maxRetries": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 3
}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"maxRetries": -1}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(!err.is_empty(), "minimum violation should fail");
}
#[test]
fn test_narrowing_valid() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
}
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
}
}
},
"x-gts-traits": {"priority": "high"}
}),
),
];
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_narrowing_violation() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
}
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
}
}
},
"x-gts-traits": {"priority": "high"}
}),
),
(
"leaf~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"priority": "ultra_high"}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(!err.is_empty(), "narrowing violation should fail");
}
#[test]
fn test_deep_inheritance_chain() {
let mut chain = vec![(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P30D"}
}
}
}),
)];
for i in 1..super::MAX_RECURSION_DEPTH {
chain.push((
format!("level{i}~"),
json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}),
));
}
assert!(validate_traits_chain(&chain).is_ok());
}
#[test]
fn test_malformed_trait_schema_not_object() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": "not_an_object"
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"foo": "bar"}
}),
),
];
let result = validate_traits_chain(&chain);
assert!(
result.is_err(),
"malformed trait schema should fail: {result:?}"
);
}
#[test]
fn test_trait_values_as_object() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retry": {
"type": "object",
"properties": {
"maxAttempts": {"type": "integer", "default": 3},
"backoff": {"type": "string", "default": "exponential"}
}
}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"retry": {"maxAttempts": 5}
}
}),
),
];
assert!(
validate_traits_chain(&chain).is_ok(),
"object trait values should be accepted"
);
}
#[test]
fn test_trait_values_as_array() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"default": []
}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"tags": ["audit", "compliance"]
}
}),
),
];
assert!(
validate_traits_chain(&chain).is_ok(),
"array trait values should be accepted"
);
}
#[test]
fn test_x_gts_keys_inside_trait_schema_are_tolerated() {
let chain = vec![(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"x-gts-traits-schema": {"type": "object"},
"x-gts-traits": {"foo": "bar"},
"properties": {
"retention": {"type": "string"}
}
},
"x-gts-traits": {"retention": "P30D"}
}),
)];
assert!(
validate_traits_chain(&chain).is_ok(),
"x-gts-traits / x-gts-traits-schema nested inside a trait-schema body \
should be tolerated as unknown JSON Schema keywords"
);
}
#[test]
fn test_nested_object_defaults_applied() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"retry": {
"type": "object",
"properties": {
"maxAttempts": {"type": "integer", "default": 3},
"backoff": {"type": "string", "default": "exponential"}
}
}
}
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {
"retry": {"maxAttempts": 5}
}
}),
),
];
assert!(
validate_traits_chain(&chain).is_ok(),
"nested defaults should fill in missing sub-properties"
);
}
#[test]
fn test_improved_error_message_includes_type() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"topicRef": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
},
"required": ["topicRef"]
}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"retention": "P90D"}
}),
),
];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter().any(|e| e.contains("type: string")),
"error message should include expected type: {err:?}"
);
}
#[test]
fn test_empty_trait_schema_permits_any_traits() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {}
}),
),
(
"derived~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"anything": "goes", "count": 42}
}),
),
];
assert!(
validate_traits_chain(&chain).is_ok(),
"empty trait schema should permit any traits"
);
}
#[test]
fn test_duplicate_property_dedup_rightmost_wins() {
let chain = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
}
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": ["low", "medium", "high"]
}
}
}
}),
),
(
"leaf~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits": {"priority": "high"}
}),
),
];
assert!(
validate_traits_chain(&chain).is_ok(),
"dedup should keep rightmost definition"
);
let chain_missing = vec![
(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {"type": "string"},
"retention": {"type": "string", "default": "P30D"}
},
"required": ["priority"]
}
}),
),
(
"mid~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": ["low", "medium", "high"]
}
}
}
}),
),
(
"leaf~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}),
),
];
let err = validate_traits_chain(&chain_missing).unwrap_err();
let unresolved_priority: Vec<_> = err
.iter()
.filter(|e| e.contains("priority") && e.contains("is not resolved"))
.collect();
assert_eq!(
unresolved_priority.len(),
1,
"priority should be reported as unresolved exactly once, got: {unresolved_priority:?}"
);
}
#[test]
fn test_invalid_trait_schema_caught_early() {
let chain = vec![(
"base~".to_owned(),
json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"x-gts-traits-schema": {
"type": "invalid_type_value"
}
}),
)];
let err = validate_traits_chain(&chain).unwrap_err();
assert!(
err.iter()
.any(|e| e.contains("not a valid JSON Schema") || e.contains("failed to compile")),
"should report invalid JSON Schema early: {err:?}"
);
}
#[test]
fn test_chain_default_leaf_wins_over_ancestor() {
let base_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P30D"}
}
});
let mid_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P90D"}
}
});
let leaf_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P365D"}
}
});
let effective = build_effective_trait_schema(&[base_ts, mid_ts, leaf_ts]);
let materialized = apply_defaults(&effective, &Value::Object(serde_json::Map::new()));
let retention = materialized
.as_object()
.and_then(|m| m.get("retention"))
.and_then(Value::as_str)
.expect("retention should be present after materialization");
assert_eq!(
retention, "P365D",
"leaf-most default must win; got {retention}"
);
}
#[test]
fn test_chain_default_explicit_value_wins_over_defaults() {
let base_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P30D"}
}
});
let mid_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P90D"}
}
});
let leaf_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P365D"}
}
});
let effective = build_effective_trait_schema(&[base_ts, mid_ts, leaf_ts]);
let mut merged = serde_json::Map::new();
merged.insert("retention".to_owned(), Value::String("P42D".to_owned()));
let materialized = apply_defaults(&effective, &Value::Object(merged));
let retention = materialized
.as_object()
.and_then(|m| m.get("retention"))
.and_then(Value::as_str)
.expect("retention should be present after materialization");
assert_eq!(
retention, "P42D",
"explicit chain-merged value must override all defaults; got {retention}"
);
}
#[test]
fn test_chain_default_null_delete_restores_leaf_default() {
let base_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P30D"}
}
});
let leaf_ts = json!({"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"retention": {"type": "string", "default": "P365D"}
}
});
let mut merged = serde_json::Map::new();
merge_rfc7396_into(
&mut merged,
json!({"retention": "P7D"}).as_object().unwrap(),
);
merge_rfc7396_into(&mut merged, json!({"retention": null}).as_object().unwrap());
assert!(
!merged.contains_key("retention"),
"null patch should remove the key from merged"
);
let effective = build_effective_trait_schema(&[base_ts, leaf_ts]);
let materialized = apply_defaults(&effective, &Value::Object(merged));
let retention = materialized
.as_object()
.and_then(|m| m.get("retention"))
.and_then(Value::as_str)
.expect("retention should be restored from the leaf default");
assert_eq!(
retention, "P365D",
"after null delete, materialization must use the leaf-most default; got {retention}"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod inline_traits_schema_tests {
use super::*;
use crate::gts::GtsInstanceId;
use schemars::JsonSchema;
fn collect_refs(v: &Value, out: &mut Vec<String>) {
match v {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get("$ref") {
out.push(r.clone());
}
for val in map.values() {
collect_refs(val, out);
}
}
Value::Array(arr) => {
for val in arr {
collect_refs(val, out);
}
}
_ => {}
}
}
#[derive(JsonSchema)]
#[allow(dead_code)]
enum SeverityLevel {
Low,
High,
}
#[derive(JsonSchema)]
#[allow(dead_code)]
struct EnumFieldTraits {
level: SeverityLevel,
}
#[test]
fn enum_field_is_inlined_with_no_dangling_refs() {
let schema = inline_traits_schema_of::<EnumFieldTraits>();
assert!(
schema.get("$defs").is_none(),
"embedded fragment must not carry a $defs block: {schema}"
);
let mut refs = Vec::new();
collect_refs(&schema, &mut refs);
assert!(
refs.is_empty(),
"embedded fragment has dangling refs: {refs:?} in {schema}"
);
let serialized = schema.to_string();
assert!(
serialized.contains("Low") && serialized.contains("High"),
"enum variants should be inlined into the fragment: {schema}"
);
}
#[derive(JsonSchema)]
#[allow(dead_code)]
struct InstanceIdTraits {
topic_ref: GtsInstanceId,
}
#[test]
fn gts_instance_id_field_keeps_canonical_inline_form() {
let schema = inline_traits_schema_of::<InstanceIdTraits>();
let mut refs = Vec::new();
collect_refs(&schema, &mut refs);
assert!(
refs.is_empty(),
"unexpected dangling refs: {refs:?} in {schema}"
);
let prop = &schema["properties"]["topic_ref"];
assert_eq!(prop["type"], "string");
assert_eq!(prop["format"], "gts-instance-id");
assert_eq!(prop["x-gts-ref"], "gts.*");
}
}