AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/schema/src/schema.rs

Latest change baca1a74b0ca9dcd770206285f8e51230b34d130 - feat(schema): add akurai-schema crate — JSON-shape validator for tool-UI matching by Ólafur Búi Ólafsson

//! [`Schema`] — top-level object validator.

use akurai_json::Value;

use crate::error::SchemaError;
use crate::field_type::FieldType;

/// A field declaration: name, type, and whether it is required.
#[derive(Debug, Clone, PartialEq)]
pub struct FieldDecl {
    pub name: String,
    pub field_type: FieldType,
    pub required: bool,
}

/// A declarative schema over a [`Value`].
///
/// Schemas are always rooted at an *object* — the common shape of AI tool
/// payloads.  Nested objects and array elements are expressed via [`FieldType`].
///
/// # Building a schema
///
/// ```
/// use akurai_schema::{Schema, FieldType};
///
/// let schema = Schema::object(&[
///     ("id",    FieldType::Int,    true),
///     ("label", FieldType::String, true),
///     ("score", FieldType::Float,  false),
/// ]);
/// ```
///
/// # Validating a value
///
/// ```
/// use akurai_schema::{Schema, FieldType};
///
/// let schema = Schema::object(&[("x", FieldType::Int, true)]);
/// let v = akurai_json::parse(r#"{"x": 1}"#).unwrap();
/// assert!(schema.matches(&v));
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Schema {
    pub(crate) fields: Vec<FieldDecl>,
}

impl Schema {
    /// Build a schema from a slice of `(name, type, required)` triples.
    pub fn object(fields: &[(&str, FieldType, bool)]) -> Self {
        Self {
            fields: fields
                .iter()
                .map(|(name, ft, req)| FieldDecl {
                    name: (*name).to_owned(),
                    field_type: ft.clone(),
                    required: *req,
                })
                .collect(),
        }
    }

    /// Validate `value` against this schema.
    ///
    /// Returns `Ok(())` if all constraints pass, or `Err(errors)` with one
    /// entry per violation.  All violations are collected — the caller sees
    /// the complete picture in one call.
    pub fn validate(&self, value: &Value) -> Result<(), Vec<SchemaError>> {
        let mut errors = Vec::new();
        self.validate_into(value, &mut errors);
        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }

    /// Convenience — `true` iff [`validate`](Self::validate) returns `Ok`.
    pub fn matches(&self, value: &Value) -> bool {
        self.validate(value).is_ok()
    }

    /// Internal: collect errors into `errors`, prepending paths as we recurse.
    pub(crate) fn validate_into(&self, value: &Value, errors: &mut Vec<SchemaError>) {
        // Root must be an object.
        let pairs = match value {
            Value::Object(p) => p,
            _ => {
                errors.push(SchemaError::new("", "expected object"));
                return;
            }
        };

        for field in &self.fields {
            let found = pairs.iter().find(|(k, _)| k == &field.name);
            match found {
                None if field.required => {
                    errors.push(SchemaError::new(
                        &field.name,
                        format!("required field \"{}\" is missing", field.name),
                    ));
                }
                None => { /* optional and absent — fine */ }
                Some((_, v)) => {
                    field.field_type.validate_value(v, &field.name, errors);
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use akurai_json::parse;

    fn obj(json: &str) -> Value {
        parse(json).unwrap()
    }

    // ── primitive hits ──────────────────────────────────────────────────────

    #[test]
    fn valid_string_field() {
        let s = Schema::object(&[("name", FieldType::String, true)]);
        assert!(s.matches(&obj(r#"{"name":"Alice"}"#)));
    }

    #[test]
    fn valid_int_field() {
        let s = Schema::object(&[("count", FieldType::Int, true)]);
        assert!(s.matches(&obj(r#"{"count":7}"#)));
    }

    #[test]
    fn valid_float_field_accepts_int_literal() {
        let s = Schema::object(&[("score", FieldType::Float, true)]);
        assert!(s.matches(&obj(r#"{"score":3}"#)));
        assert!(s.matches(&obj(r#"{"score":3.14}"#)));
    }

    #[test]
    fn valid_bool_field() {
        let s = Schema::object(&[("active", FieldType::Bool, true)]);
        assert!(s.matches(&obj(r#"{"active":true}"#)));
    }

    // ── primitive misses ────────────────────────────────────────────────────

    #[test]
    fn wrong_type_string_vs_int() {
        let s = Schema::object(&[("id", FieldType::Int, true)]);
        let errs = s.validate(&obj(r#"{"id":"nope"}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "id");
    }

    #[test]
    fn wrong_type_bool_vs_string() {
        let s = Schema::object(&[("flag", FieldType::Bool, true)]);
        let errs = s.validate(&obj(r#"{"flag":"yes"}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "flag");
        assert!(errs[0].message.contains("boolean"));
    }

    // ── required vs optional ────────────────────────────────────────────────

    #[test]
    fn required_field_missing() {
        let s = Schema::object(&[("name", FieldType::String, true)]);
        let errs = s.validate(&obj("{}")).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(errs[0].message.contains("missing"));
    }

    #[test]
    fn optional_field_absent_is_ok() {
        let s = Schema::object(&[
            ("name", FieldType::String, true),
            ("label", FieldType::String, false),
        ]);
        assert!(s.matches(&obj(r#"{"name":"x"}"#)));
    }

    #[test]
    fn optional_field_present_is_validated() {
        let s = Schema::object(&[("label", FieldType::String, false)]);
        let errs = s.validate(&obj(r#"{"label":99}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "label");
    }

    // ── multiple errors collected ────────────────────────────────────────────

    #[test]
    fn multiple_errors_all_collected() {
        let s = Schema::object(&[
            ("a", FieldType::Int, true),
            ("b", FieldType::String, true),
            ("c", FieldType::Bool, true),
        ]);
        // all three wrong
        let errs = s
            .validate(&obj(r#"{"a":"bad","b":0,"c":"yes"}"#))
            .unwrap_err();
        assert_eq!(errs.len(), 3);
        let paths: Vec<&str> = errs.iter().map(|e| e.path.as_str()).collect();
        assert!(paths.contains(&"a"));
        assert!(paths.contains(&"b"));
        assert!(paths.contains(&"c"));
    }

    #[test]
    fn missing_and_wrong_type_both_reported() {
        let s = Schema::object(&[("x", FieldType::Int, true), ("y", FieldType::String, true)]);
        // x missing, y wrong type
        let errs = s.validate(&obj(r#"{"y":false}"#)).unwrap_err();
        assert_eq!(errs.len(), 2);
    }

    // ── nested objects ───────────────────────────────────────────────────────

    #[test]
    fn nested_object_valid() {
        let addr_schema = Schema::object(&[
            ("street", FieldType::String, true),
            ("city", FieldType::String, true),
        ]);
        let s = Schema::object(&[
            ("name", FieldType::String, true),
            ("address", FieldType::Object(Box::new(addr_schema)), true),
        ]);
        let v = obj(r#"{"name":"Bob","address":{"street":"Main","city":"Akureyri"}}"#);
        assert!(s.matches(&v));
    }

    #[test]
    fn nested_object_error_has_full_path() {
        let addr_schema = Schema::object(&[("city", FieldType::String, true)]);
        let s = Schema::object(&[("address", FieldType::Object(Box::new(addr_schema)), true)]);
        let errs = s.validate(&obj(r#"{"address":{"city":42}}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "address.city");
    }

    #[test]
    fn deeply_nested_error_path() {
        let inner = Schema::object(&[("val", FieldType::Bool, true)]);
        let mid = Schema::object(&[("inner", FieldType::Object(Box::new(inner)), true)]);
        let s = Schema::object(&[("mid", FieldType::Object(Box::new(mid)), true)]);
        let errs = s
            .validate(&obj(r#"{"mid":{"inner":{"val":"oops"}}}"#))
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "mid.inner.val");
    }

    // ── arrays ───────────────────────────────────────────────────────────────

    #[test]
    fn array_field_valid() {
        let s = Schema::object(&[("tags", FieldType::Array(Box::new(FieldType::String)), true)]);
        assert!(s.matches(&obj(r#"{"tags":["a","b","c"]}"#)));
    }

    #[test]
    fn array_field_element_error_path() {
        let s = Schema::object(&[("ids", FieldType::Array(Box::new(FieldType::Int)), true)]);
        let errs = s.validate(&obj(r#"{"ids":[1,"oops",3]}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "ids[1]");
    }

    #[test]
    fn array_of_objects_error_path() {
        let item = Schema::object(&[("x", FieldType::Int, true)]);
        let s = Schema::object(&[(
            "items",
            FieldType::Array(Box::new(FieldType::Object(Box::new(item)))),
            true,
        )]);
        let errs = s
            .validate(&obj(r#"{"items":[{"x":1},{"x":"bad"}]}"#))
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "items[1].x");
    }

    // ── enums ────────────────────────────────────────────────────────────────

    #[test]
    fn enum_field_valid() {
        let s = Schema::object(&[(
            "status",
            FieldType::Enum(vec!["active".into(), "inactive".into()]),
            true,
        )]);
        assert!(s.matches(&obj(r#"{"status":"active"}"#)));
        assert!(s.matches(&obj(r#"{"status":"inactive"}"#)));
    }

    #[test]
    fn enum_field_invalid_value() {
        let s = Schema::object(&[(
            "status",
            FieldType::Enum(vec!["active".into(), "inactive".into()]),
            true,
        )]);
        let errs = s.validate(&obj(r#"{"status":"deleted"}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "status");
        assert!(errs[0].message.contains("active"));
    }

    // ── any ──────────────────────────────────────────────────────────────────

    #[test]
    fn any_field_accepts_everything() {
        let s = Schema::object(&[("meta", FieldType::Any, true)]);
        assert!(s.matches(&obj(r#"{"meta":null}"#)));
        assert!(s.matches(&obj(r#"{"meta":42}"#)));
        assert!(s.matches(&obj(r#"{"meta":{"a":1}}"#)));
        assert!(s.matches(&obj(r#"{"meta":["x"]}"#)));
    }

    // ── non-object root ──────────────────────────────────────────────────────

    #[test]
    fn non_object_root_gives_error() {
        let s = Schema::object(&[("x", FieldType::Int, true)]);
        let errs = s.validate(&Value::Array(vec![])).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(errs[0].message.contains("expected object"));
    }

    // ── matches convenience ──────────────────────────────────────────────────

    #[test]
    fn matches_returns_bool() {
        let s = Schema::object(&[("n", FieldType::Int, true)]);
        assert!(s.matches(&obj(r#"{"n":1}"#)));
        assert!(!s.matches(&obj(r#"{"n":"x"}"#)));
    }
}