greentic-flow 0.4.65

Generic YGTC flow schema/loader/IR for self-describing component nodes.
Documentation
use ciborium::value::Value as CborValue;
use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    Error,
    Warning,
}

#[derive(Debug, Clone)]
pub struct SchemaDiagnostic {
    pub code: &'static str,
    pub severity: Severity,
    pub message: String,
    pub path: String,
}

pub fn validate_value_against_schema(
    schema: &SchemaIr,
    value: &CborValue,
) -> Vec<SchemaDiagnostic> {
    let mut diags = Vec::new();
    validate_inner(schema, value, "$", &mut diags);
    diags
}

fn validate_inner(
    schema: &SchemaIr,
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    match schema {
        SchemaIr::Object {
            properties,
            required,
            additional,
        } => validate_object(properties, required, additional, value, path, diags),
        SchemaIr::Array {
            items,
            min_items,
            max_items,
        } => validate_array(items, *min_items, *max_items, value, path, diags),
        SchemaIr::String {
            min_len,
            max_len,
            regex,
            format,
        } => validate_string(
            *min_len,
            *max_len,
            regex.as_deref(),
            format.as_deref(),
            value,
            path,
            diags,
        ),
        SchemaIr::Int { min, max } => validate_int(*min, *max, value, path, diags),
        SchemaIr::Float { min, max } => validate_float(*min, *max, value, path, diags),
        SchemaIr::Bool => require_kind("boolean", matches!(value, CborValue::Bool(_)), path, diags),
        SchemaIr::Null => require_kind("null", matches!(value, CborValue::Null), path, diags),
        SchemaIr::Bytes => require_kind("bytes", matches!(value, CborValue::Bytes(_)), path, diags),
        SchemaIr::Enum { values } => validate_enum(values, value, path, diags),
        SchemaIr::OneOf { variants } => validate_one_of(variants, value, path, diags),
        SchemaIr::Ref { id } => {
            diags.push(SchemaDiagnostic {
                code: "SCHEMA_REF_UNSUPPORTED",
                severity: Severity::Error,
                message: format!("schema ref '{}' is not supported", id),
                path: path.to_string(),
            });
        }
    }
}

fn require_kind(kind: &str, ok: bool, path: &str, diags: &mut Vec<SchemaDiagnostic>) {
    if !ok {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_TYPE_MISMATCH",
            severity: Severity::Error,
            message: format!("expected {kind} at {path}"),
            path: path.to_string(),
        });
    }
}

fn validate_object(
    properties: &std::collections::BTreeMap<String, SchemaIr>,
    required: &[String],
    additional: &AdditionalProperties,
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    let map = match value {
        CborValue::Map(entries) => entries,
        _ => {
            require_kind("object", false, path, diags);
            return;
        }
    };

    let mut values: std::collections::BTreeMap<String, &CborValue> =
        std::collections::BTreeMap::new();
    for (k, v) in map {
        match k {
            CborValue::Text(s) => {
                values.insert(s.clone(), v);
            }
            _ => {
                diags.push(SchemaDiagnostic {
                    code: "SCHEMA_INVALID_KEY",
                    severity: Severity::Error,
                    message: format!("non-string object key at {path}"),
                    path: path.to_string(),
                });
            }
        }
    }

    for key in required {
        if !values.contains_key(key) {
            diags.push(SchemaDiagnostic {
                code: "SCHEMA_REQUIRED_MISSING",
                severity: Severity::Error,
                message: format!("missing required field '{key}' at {path}"),
                path: format!("{path}.{key}"),
            });
        }
    }

    for (key, val) in values {
        if let Some(prop_schema) = properties.get(&key) {
            validate_inner(prop_schema, val, &format!("{path}.{key}"), diags);
            continue;
        }
        match additional {
            AdditionalProperties::Allow => {}
            AdditionalProperties::Forbid => {
                diags.push(SchemaDiagnostic {
                    code: "SCHEMA_ADDITIONAL_FORBIDDEN",
                    severity: Severity::Error,
                    message: format!("additional property '{key}' not allowed at {path}"),
                    path: format!("{path}.{key}"),
                });
            }
            AdditionalProperties::Schema(schema) => {
                validate_inner(schema, val, &format!("{path}.{key}"), diags);
            }
        }
    }
}

fn validate_array(
    items: &SchemaIr,
    min_items: Option<u64>,
    max_items: Option<u64>,
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    let items_val = match value {
        CborValue::Array(items) => items,
        _ => {
            require_kind("array", false, path, diags);
            return;
        }
    };
    let len = items_val.len() as u64;
    if let Some(min) = min_items
        && len < min
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_ARRAY_MIN_ITEMS",
            severity: Severity::Error,
            message: format!("array length {len} < min_items {min} at {path}"),
            path: path.to_string(),
        });
    }
    if let Some(max) = max_items
        && len > max
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_ARRAY_MAX_ITEMS",
            severity: Severity::Error,
            message: format!("array length {len} > max_items {max} at {path}"),
            path: path.to_string(),
        });
    }
    for (idx, item) in items_val.iter().enumerate() {
        validate_inner(items, item, &format!("{path}[{idx}]"), diags);
    }
}

fn validate_string(
    min_len: Option<u64>,
    max_len: Option<u64>,
    regex: Option<&str>,
    format: Option<&str>,
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    let text = match value {
        CborValue::Text(s) => s,
        _ => {
            require_kind("string", false, path, diags);
            return;
        }
    };
    let len = text.chars().count() as u64;
    if let Some(min) = min_len
        && len < min
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_STRING_MIN_LEN",
            severity: Severity::Error,
            message: format!("string length {len} < min_len {min} at {path}"),
            path: path.to_string(),
        });
    }
    if let Some(max) = max_len
        && len > max
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_STRING_MAX_LEN",
            severity: Severity::Error,
            message: format!("string length {len} > max_len {max} at {path}"),
            path: path.to_string(),
        });
    }
    if regex.is_some() {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_REGEX_UNSUPPORTED",
            severity: Severity::Warning,
            message: format!("regex constraint not enforced at {path}"),
            path: path.to_string(),
        });
    }
    if format.is_some() {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_FORMAT_UNSUPPORTED",
            severity: Severity::Warning,
            message: format!("format constraint not enforced at {path}"),
            path: path.to_string(),
        });
    }
}

fn validate_int(
    min: Option<i64>,
    max: Option<i64>,
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    let num = match value {
        CborValue::Integer(i) => i128::from(*i),
        _ => {
            require_kind("integer", false, path, diags);
            return;
        }
    };
    if let Some(min) = min
        && num < min as i128
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_INT_MIN",
            severity: Severity::Error,
            message: format!("integer {num} < min {min} at {path}"),
            path: path.to_string(),
        });
    }
    if let Some(max) = max
        && num > max as i128
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_INT_MAX",
            severity: Severity::Error,
            message: format!("integer {num} > max {max} at {path}"),
            path: path.to_string(),
        });
    }
}

fn validate_float(
    min: Option<f64>,
    max: Option<f64>,
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    let num = match value {
        CborValue::Float(f) => *f,
        CborValue::Integer(i) => i128::from(*i) as f64,
        _ => {
            require_kind("number", false, path, diags);
            return;
        }
    };
    if let Some(min) = min
        && num < min
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_FLOAT_MIN",
            severity: Severity::Error,
            message: format!("number {num} < min {min} at {path}"),
            path: path.to_string(),
        });
    }
    if let Some(max) = max
        && num > max
    {
        diags.push(SchemaDiagnostic {
            code: "SCHEMA_FLOAT_MAX",
            severity: Severity::Error,
            message: format!("number {num} > max {max} at {path}"),
            path: path.to_string(),
        });
    }
}

fn validate_enum(
    values: &[CborValue],
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    if values.iter().any(|candidate| candidate == value) {
        return;
    }
    diags.push(SchemaDiagnostic {
        code: "SCHEMA_ENUM",
        severity: Severity::Error,
        message: format!("value is not in enum at {path}"),
        path: path.to_string(),
    });
}

fn validate_one_of(
    variants: &[SchemaIr],
    value: &CborValue,
    path: &str,
    diags: &mut Vec<SchemaDiagnostic>,
) {
    for variant in variants {
        let mut local = Vec::new();
        validate_inner(variant, value, path, &mut local);
        if local.iter().all(|d| d.severity != Severity::Error) {
            return;
        }
    }
    diags.push(SchemaDiagnostic {
        code: "SCHEMA_ONE_OF",
        severity: Severity::Error,
        message: format!("value does not match any oneOf variant at {path}"),
        path: path.to_string(),
    });
}