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"}"#)));
}
}