use serde_json::{Map, Value};
pub const CUSTOM_SCHEMA_KEY: &str = "custom";
pub fn compose_node_schema(base: &Value, custom: &Value) -> Value {
let mut properties = base
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
properties.insert(CUSTOM_SCHEMA_KEY.to_string(), custom.clone());
let mut composed = Map::new();
composed.insert("type".to_string(), Value::String("object".to_string()));
composed.insert("properties".to_string(), Value::Object(properties));
composed.insert("additionalProperties".to_string(), Value::Bool(false));
if let Some(required) = base.get("required") {
composed.insert("required".to_string(), required.clone());
}
let mut defs = Map::new();
for schema in [base, custom] {
if let Some(schema_defs) = schema.get("$defs").and_then(Value::as_object) {
defs.extend(schema_defs.clone());
}
}
if !defs.is_empty() {
composed.insert("$defs".to_string(), Value::Object(defs));
}
Value::Object(composed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node_config_base::NodeConfigBase;
use serde_json::json;
fn base_schema() -> Value {
serde_json::to_value(schemars::schema_for!(NodeConfigBase)).unwrap()
}
fn custom_schema() -> Value {
json!({
"type": "object",
"properties": {
"exposure": { "type": "number" }
},
"additionalProperties": false
})
}
fn validator_for(schema: &Value) -> jsonschema::Validator {
jsonschema::options()
.with_draft(jsonschema::Draft::Draft7)
.build(schema)
.unwrap()
}
#[test]
fn composed_properties_include_every_base_property_plus_custom() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
let properties = composed["properties"].as_object().unwrap();
assert!(properties.contains_key("relay_topics"));
assert!(properties.contains_key(CUSTOM_SCHEMA_KEY));
}
#[test]
fn custom_property_schema_is_exactly_the_schema_passed_in() {
let custom = custom_schema();
let composed = compose_node_schema(&base_schema(), &custom);
assert_eq!(composed["properties"][CUSTOM_SCHEMA_KEY], custom);
}
#[test]
fn valid_config_with_base_fields_and_nested_custom_object_passes() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
let validator = validator_for(&composed);
let config = json!({
"relay_topics": ["/camera/image"],
"custom": { "exposure": 1.5 }
});
assert!(
validator.is_valid(&config),
"{:?}",
validator.iter_errors(&config).collect::<Vec<_>>()
);
}
#[test]
fn config_missing_optional_base_and_custom_fields_still_passes() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
let validator = validator_for(&composed);
assert!(validator.is_valid(&json!({})));
}
#[test]
fn node_specific_field_flattened_at_top_level_instead_of_nested_under_custom_fails() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
let validator = validator_for(&composed);
let config = json!({
"relay_topics": ["/camera/image"],
"exposure": 1.5
});
assert!(!validator.is_valid(&config));
}
#[test]
fn unknown_top_level_key_is_rejected_by_additional_properties_false() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
let validator = validator_for(&composed);
let config = json!({
"relay_topics": ["/camera/image"],
"custom": { "exposure": 1.5 },
"not_a_real_field": true
});
assert!(!validator.is_valid(&config));
}
#[test]
fn additional_properties_false_does_not_reject_legitimate_nested_custom_fields() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
let validator = validator_for(&composed);
let config = json!({ "custom": { "exposure": 42.0 } });
assert!(
validator.is_valid(&config),
"{:?}",
validator.iter_errors(&config).collect::<Vec<_>>()
);
}
#[test]
fn composed_schema_preserves_bases_required_list_when_present() {
let base = json!({
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
});
let composed = compose_node_schema(&base, &custom_schema());
assert_eq!(composed["required"], json!(["id"]));
}
#[test]
fn composed_schema_has_no_required_key_when_base_has_none() {
let composed = compose_node_schema(&base_schema(), &custom_schema());
assert!(composed.get("required").is_none());
}
#[test]
fn composed_schema_carries_over_bases_defs_so_its_own_refs_resolve() {
let base = base_schema();
assert!(
base.get("$defs")
.and_then(Value::as_object)
.is_some_and(|d| !d.is_empty()),
"expected NodeConfigBase's own schema to declare $defs for this test to be meaningful"
);
let composed = compose_node_schema(&base, &custom_schema());
let base_defs = base.get("$defs").and_then(Value::as_object).unwrap();
let composed_defs = composed
.get("$defs")
.and_then(Value::as_object)
.expect("composed schema must carry over base's $defs");
for key in base_defs.keys() {
assert!(composed_defs.contains_key(key), "missing $defs entry: {key}");
}
validator_for(&composed);
}
#[test]
fn a_base_schema_change_never_touches_an_unrelated_nodes_custom_portion() {
let original_base = base_schema();
let mut modified_base = original_base.clone();
modified_base["properties"]
.as_object_mut()
.unwrap()
.insert("hypothetical_new_base_field".to_string(), json!({ "type": "string" }));
let custom = custom_schema();
let composed_with_original_base = compose_node_schema(&original_base, &custom);
let composed_with_modified_base = compose_node_schema(&modified_base, &custom);
assert_eq!(composed_with_original_base["properties"][CUSTOM_SCHEMA_KEY], custom);
assert_eq!(composed_with_modified_base["properties"][CUSTOM_SCHEMA_KEY], custom);
assert!(composed_with_original_base["properties"]
.get("hypothetical_new_base_field")
.is_none());
assert!(composed_with_modified_base["properties"]
.get("hypothetical_new_base_field")
.is_some());
}
}