mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
//! Compose a node's full config JSON Schema from [`crate::node_config_base::NodeConfigBase`]'s
//! schema (`schemas/base.schema.json`) and that node's own `custom`-nested schema.
//!
//! # Design
//!
//! Per `docs/adr/0001-node-config-base-schema.md` (`LAB-4384`), a node's resolved config is
//! `NodeConfigBase`'s fields flattened at the top level plus a nested `custom` object holding
//! that node's own fields. The composed schema mirrors that shape exactly: one flat object
//! schema whose `properties` is the base schema's `properties` plus a `custom` property set to
//! the node's own schema, with `additionalProperties: false` applied once, at this composed
//! result's top level.
//!
//! This deliberately does not use `allOf: [base, custom]` -- an `allOf` branch with
//! `additionalProperties: false` only recognizes the properties declared in that same branch,
//! so a base-schema branch rejects every node-specific field and vice versa, and a validator
//! has to satisfy every branch simultaneously. Flattening into one object's `properties`
//! sidesteps that entirely: there is only one schema, with one combined view of what's allowed.
//!
//! [`compose_node_schema`] is this composition, usable on its own by any caller that already
//! has a base and a custom schema `Value` in hand. `mecha10_schema_registry::composed_node_schema`
//! (`LAB-4388`) is the caller that actually uses it for built-in node types, writing the result
//! to each built-in node's own `<name>.schema.json` file -- the same on-disk convention a custom
//! node already used, so `super::validate::resolve_declared_node_schema`'s existing
//! `<name>.schema.json` file lookup picks it up without any resolution-path change of its own.
//! `LAB-4389` deleted the hardcoded per-built-in-type dispatch that lookup used to fall back on
//! first, leaving that file lookup as the only node-config resolution path left, for every node.

use serde_json::{Map, Value};

/// The key a node's own (non-base) fields are nested under in both the composed schema this
/// builds and the config JSON it validates -- see the module docs for the design this is part
/// of.
pub const CUSTOM_SCHEMA_KEY: &str = "custom";

/// Compose a node's full config schema from `base` (`NodeConfigBase`'s schema, e.g.
/// `schemas/base.schema.json`) and `custom` (that node's own schema for its
/// [`CUSTOM_SCHEMA_KEY`]-nested fields).
///
/// The result is a flat object schema: `base`'s `properties` plus a [`CUSTOM_SCHEMA_KEY`]
/// property set to `custom` verbatim, `base`'s `required` list (if any) unchanged, and
/// `additionalProperties: false` applied once at this top level -- see the module docs for why
/// this is a flat merge rather than an `allOf` of the two schemas.
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());
    }

    // `base`'s properties may contain `$ref`s (e.g. `model` refs `NodeModelConfig`, `provider`
    // refs `Provider`) pointing into `base`'s own `$defs` -- schemars' (1.x) definitions
    // keyword. Those refs are copied verbatim into `composed["properties"]` above, so the
    // referenced definitions have to come along too, or a validator built from `composed` alone
    // fails to resolve them (`jsonschema::ValidationError::PointerToNowhere`). `custom`'s own
    // `$defs` (if any) are merged in the same way, so a custom schema with its own nested
    // `$ref`s keeps resolving too.
    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);

        // `exposure` belongs under `custom`, per the composed shape -- placing it at the top
        // level instead must be rejected, proving the base/custom separation is enforced and
        // not just present in the schema's shape.
        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);

        // The top level's `additionalProperties: false` must only govern the top level --
        // `custom`'s own sub-schema (also `additionalProperties: false`) governs its nested
        // fields independently, so a legitimate custom field must still validate.
        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());
    }

    /// Regression test (LAB-4536): `NodeConfigBase`'s schema `$ref`s into its own root `$defs`
    /// for `model`/`provider` (`NodeModelConfig`/`Provider`), carried verbatim into
    /// `composed["properties"]` -- so `composed` must also carry over the `$defs` those refs
    /// point at, or building a validator from `composed` alone fails to resolve them (exactly
    /// the failure every other test in this module hit before this fix: a `PointerToNowhere`
    /// error for `/$defs/NodeModelConfig`, not an assertion failure).
    #[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}");
        }

        // The whole point: a validator built from `composed` alone (no external schema
        // resolution) must actually resolve every $ref it contains.
        validator_for(&composed);
    }

    /// Regression test (LAB-4391) for the decoupling `docs/adr/0001-node-config-base-schema.md`
    /// promises: a hypothetical future field added to `NodeConfigBase` must never require
    /// touching any individual node's own schema. Simulated here by composing an existing
    /// `custom` schema against both the real base schema and a modified copy of it (with an
    /// extra property added, standing in for a hypothetical new `NodeConfigBase` field) --
    /// without needing to actually add a field to the real struct -- and asserting the `custom`
    /// portion of the composed output is byte-for-byte identical either way.
    #[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);

        // The `custom` portion is exactly `custom`, verbatim, regardless of which base it's
        // composed with -- `compose_node_schema` never reaches into it.
        assert_eq!(composed_with_original_base["properties"][CUSTOM_SCHEMA_KEY], custom);
        assert_eq!(composed_with_modified_base["properties"][CUSTOM_SCHEMA_KEY], custom);

        // Only the base-derived portion of the composed schema changed.
        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());
    }
}