systemprompt-ai 0.2.2

Provider-agnostic LLM integration for systemprompt.io AI governance — Anthropic, OpenAI, Gemini, and local models unified behind one governed pipeline with cost tracking and audit.
Documentation
use anyhow::{Result, anyhow};
use serde_json::Value as JsonValue;

#[derive(Debug, Copy, Clone)]
pub struct SchemaValidator;

impl SchemaValidator {
    pub fn validate(value: &JsonValue, schema: &JsonValue, strict: bool) -> Result<()> {
        Self::validate_value(value, schema, strict, "root")
    }

    fn validate_value(
        value: &JsonValue,
        schema: &JsonValue,
        strict: bool,
        path: &str,
    ) -> Result<()> {
        if let Some(type_value) = schema.get("type") {
            Self::validate_type(value, type_value, path)?;
        }

        match schema.get("type").and_then(|t| t.as_str()) {
            Some("object") => Self::validate_object(value, schema, strict, path)?,
            Some("array") => Self::validate_array(value, schema, strict, path)?,
            Some("string") => Self::validate_string(value, schema, path)?,
            Some("number" | "integer") => Self::validate_number(value, schema, path)?,
            Some("boolean") => Self::validate_boolean(value, path)?,
            Some("null") => Self::validate_null(value, path)?,
            _ => {},
        }

        if let Some(enum_values) = schema.get("enum").and_then(|e| e.as_array())
            && !enum_values.contains(value)
        {
            return Err(anyhow!("Value at {path} must be one of: {enum_values:?}"));
        }

        Ok(())
    }

    fn validate_type(value: &JsonValue, type_schema: &JsonValue, path: &str) -> Result<()> {
        let valid_type = match type_schema {
            JsonValue::String(type_str) => Self::check_single_type(value, type_str),
            JsonValue::Array(types) => types.iter().any(|t| {
                t.as_str()
                    .is_some_and(|type_str| Self::check_single_type(value, type_str))
            }),
            _ => true,
        };

        if !valid_type {
            return Err(anyhow!(
                "Type mismatch at {}: expected {:?}, got {:?}",
                path,
                type_schema,
                Self::get_json_type(value)
            ));
        }

        Ok(())
    }

    fn check_single_type(value: &JsonValue, type_str: &str) -> bool {
        match type_str {
            "null" => value.is_null(),
            "boolean" => value.is_boolean(),
            "object" => value.is_object(),
            "array" => value.is_array(),
            "number" => value.is_number(),
            "integer" => value.is_i64() || value.is_u64(),
            "string" => value.is_string(),
            _ => true,
        }
    }

    fn validate_object(
        value: &JsonValue,
        schema: &JsonValue,
        strict: bool,
        path: &str,
    ) -> Result<()> {
        let obj = value
            .as_object()
            .ok_or_else(|| anyhow!("{path} must be an object"))?;

        if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
            for req_prop in required {
                if let Some(prop_name) = req_prop.as_str()
                    && !obj.contains_key(prop_name)
                {
                    return Err(anyhow!("Missing required property '{prop_name}' at {path}"));
                }
            }
        }

        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
            for (key, value) in obj {
                let prop_path = format!("{path}.{key}");

                if let Some(prop_schema) = properties.get(key) {
                    Self::validate_value(value, prop_schema, strict, &prop_path)?;
                } else if strict {
                    let allow_additional = schema
                        .get("additionalProperties")
                        .and_then(serde_json::Value::as_bool)
                        .unwrap_or(true);

                    if !allow_additional {
                        return Err(anyhow!("Unexpected property '{key}' at {path}"));
                    }
                }
            }
        }

        Ok(())
    }

    fn validate_array(
        value: &JsonValue,
        schema: &JsonValue,
        strict: bool,
        path: &str,
    ) -> Result<()> {
        let arr = value
            .as_array()
            .ok_or_else(|| anyhow!("{path} must be an array"))?;

        if let Some(min_items) = schema.get("minItems").and_then(serde_json::Value::as_u64)
            && arr.len() < min_items as usize
        {
            return Err(anyhow!("{path} must have at least {min_items} items"));
        }

        if let Some(max_items) = schema.get("maxItems").and_then(serde_json::Value::as_u64)
            && arr.len() > max_items as usize
        {
            return Err(anyhow!("{path} must have at most {max_items} items"));
        }

        if let Some(items_schema) = schema.get("items") {
            for (idx, item) in arr.iter().enumerate() {
                let item_path = format!("{path}[{idx}]");
                Self::validate_value(item, items_schema, strict, &item_path)?;
            }
        }

        Ok(())
    }

    fn validate_string(value: &JsonValue, schema: &JsonValue, path: &str) -> Result<()> {
        let str_val = value
            .as_str()
            .ok_or_else(|| anyhow!("{path} must be a string"))?;

        if let Some(min_length) = schema.get("minLength").and_then(serde_json::Value::as_u64)
            && str_val.len() < min_length as usize
        {
            return Err(anyhow!("{path} must have at least {min_length} characters"));
        }

        if let Some(max_length) = schema.get("maxLength").and_then(serde_json::Value::as_u64)
            && str_val.len() > max_length as usize
        {
            return Err(anyhow!("{path} must have at most {max_length} characters"));
        }

        if let Some(pattern) = schema.get("pattern").and_then(|p| p.as_str())
            && !regex::Regex::new(pattern)?.is_match(str_val)
        {
            return Err(anyhow!("{path} does not match pattern: {pattern}"));
        }

        Ok(())
    }

    fn validate_number(value: &JsonValue, schema: &JsonValue, path: &str) -> Result<()> {
        let num_val = value
            .as_f64()
            .ok_or_else(|| anyhow!("{path} must be a number"))?;

        if let Some(minimum) = schema.get("minimum").and_then(serde_json::Value::as_f64)
            && num_val < minimum
        {
            return Err(anyhow!("{path} must be >= {minimum}"));
        }

        if let Some(maximum) = schema.get("maximum").and_then(serde_json::Value::as_f64)
            && num_val > maximum
        {
            return Err(anyhow!("{path} must be <= {maximum}"));
        }

        Ok(())
    }

    fn validate_boolean(value: &JsonValue, path: &str) -> Result<()> {
        if !value.is_boolean() {
            return Err(anyhow!("{path} must be a boolean"));
        }
        Ok(())
    }

    fn validate_null(value: &JsonValue, path: &str) -> Result<()> {
        if !value.is_null() {
            return Err(anyhow!("{path} must be null"));
        }
        Ok(())
    }

    const fn get_json_type(value: &JsonValue) -> &'static str {
        match value {
            JsonValue::Null => "null",
            JsonValue::Bool(_) => "boolean",
            JsonValue::Number(_) => "number",
            JsonValue::String(_) => "string",
            JsonValue::Array(_) => "array",
            JsonValue::Object(_) => "object",
        }
    }
}