AkurAI Build
Menu

Public repository / master

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

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

//! `Schema::from_json` — parse a schema from a JSON description.
//!
//! # JSON schema format
//!
//! A schema is a JSON object whose keys are field names and values are *field
//! descriptors*:
//!
//! ```json
//! {
//!   "name":   { "type": "string",  "required": true  },
//!   "count":  { "type": "int",     "required": true  },
//!   "score":  { "type": "float",   "required": false },
//!   "active": { "type": "bool",    "required": false },
//!   "tags":   { "type": "array",   "items": "string", "required": true  },
//!   "status": { "type": "enum",    "values": ["on","off"], "required": true },
//!   "addr":   { "type": "object",  "schema": { "city": { "type": "string", "required": true } }, "required": false },
//!   "meta":   { "type": "any",     "required": false }
//! }
//! ```
//!
//! Every descriptor must have a `"type"` key.  `"required"` defaults to
//! `false` when absent.  Unknown keys in a descriptor are silently ignored so
//! future extensions remain backward-compatible.

use akurai_json::Value;

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

/// Error returned when a JSON value cannot be interpreted as a schema.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(pub String);

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "schema parse error: {}", self.0)
    }
}

impl Schema {
    /// Parse a schema from a JSON description.
    ///
    /// `value` must be a JSON object whose keys are field names and values are
    /// field descriptors (see module-level docs for the format).
    pub fn from_json(value: &Value) -> Result<Schema, ParseError> {
        let pairs = match value {
            Value::Object(p) => p,
            _ => return Err(ParseError("schema root must be an object".into())),
        };

        let mut fields = Vec::with_capacity(pairs.len());

        for (name, descriptor) in pairs {
            let desc_obj = match descriptor {
                Value::Object(p) => p,
                _ => {
                    return Err(ParseError(format!(
                        "field \"{name}\": descriptor must be an object"
                    )))
                }
            };

            let type_str = desc_obj
                .iter()
                .find(|(k, _)| k == "type")
                .and_then(|(_, v)| v.as_str())
                .ok_or_else(|| ParseError(format!("field \"{name}\": missing \"type\" key")))?;

            let required = desc_obj
                .iter()
                .find(|(k, _)| k == "required")
                .and_then(|(_, v)| v.as_bool())
                .unwrap_or(false);

            let field_type = parse_field_type(type_str, desc_obj, name)?;

            fields.push(crate::schema::FieldDecl {
                name: name.clone(),
                field_type,
                required,
            });
        }

        Ok(Schema { fields })
    }
}

fn parse_field_type(
    type_str: &str,
    desc_obj: &[(String, Value)],
    field_name: &str,
) -> Result<FieldType, ParseError> {
    match type_str {
        "string" => Ok(FieldType::String),
        "int" => Ok(FieldType::Int),
        "float" => Ok(FieldType::Float),
        "bool" => Ok(FieldType::Bool),
        "any" => Ok(FieldType::Any),
        "array" => {
            // "items" key: a type string (primitive) or an object (nested schema descriptor)
            let items_val = desc_obj
                .iter()
                .find(|(k, _)| k == "items")
                .map(|(_, v)| v)
                .ok_or_else(|| {
                    ParseError(format!(
                        "field \"{field_name}\": array type requires \"items\""
                    ))
                })?;
            let elem = parse_field_type_value(items_val, field_name)?;
            Ok(FieldType::Array(Box::new(elem)))
        }
        "enum" => {
            let vals_val = desc_obj
                .iter()
                .find(|(k, _)| k == "values")
                .map(|(_, v)| v)
                .ok_or_else(|| {
                    ParseError(format!(
                        "field \"{field_name}\": enum type requires \"values\""
                    ))
                })?;
            let arr = match vals_val {
                Value::Array(a) => a,
                _ => {
                    return Err(ParseError(format!(
                        "field \"{field_name}\": \"values\" must be an array"
                    )))
                }
            };
            let mut variants = Vec::with_capacity(arr.len());
            for (i, v) in arr.iter().enumerate() {
                match v {
                    Value::Str(s) => variants.push(s.clone()),
                    _ => {
                        return Err(ParseError(format!(
                            "field \"{field_name}\": enum value at [{i}] must be a string"
                        )))
                    }
                }
            }
            Ok(FieldType::Enum(variants))
        }
        "object" => {
            let schema_val = desc_obj
                .iter()
                .find(|(k, _)| k == "schema")
                .map(|(_, v)| v)
                .ok_or_else(|| {
                    ParseError(format!(
                        "field \"{field_name}\": object type requires \"schema\""
                    ))
                })?;
            let nested = Schema::from_json(schema_val)?;
            Ok(FieldType::Object(Box::new(nested)))
        }
        other => Err(ParseError(format!(
            "field \"{field_name}\": unknown type \"{other}\""
        ))),
    }
}

/// Parse a FieldType from an inline value (used for array `items`).
/// The value can be:
/// - A string like `"string"` / `"int"` (primitive shorthand)
/// - An object descriptor `{"type":"..."}` (full descriptor)
fn parse_field_type_value(value: &Value, field_name: &str) -> Result<FieldType, ParseError> {
    match value {
        Value::Str(s) => parse_field_type(s, &[], field_name),
        Value::Object(pairs) => {
            let type_str = pairs
                .iter()
                .find(|(k, _)| k == "type")
                .and_then(|(_, v)| v.as_str())
                .ok_or_else(|| {
                    ParseError(format!(
                        "field \"{field_name}\": items descriptor missing \"type\""
                    ))
                })?;
            parse_field_type(type_str, pairs, field_name)
        }
        _ => Err(ParseError(format!(
            "field \"{field_name}\": \"items\" must be a string or object"
        ))),
    }
}

/// Validate errors from `SchemaError` — re-export alias so doc tests compile.
#[allow(dead_code)]
pub(crate) type ValidationErrors = Vec<SchemaError>;

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

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

    #[test]
    fn from_json_string_field() {
        let schema_json = v(r#"{"name":{"type":"string","required":true}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"name":"Alice"}"#)));
        assert!(!schema.matches(&v(r#"{"name":1}"#)));
        assert!(!schema.matches(&v("{}")));
    }

    #[test]
    fn from_json_int_field() {
        let schema_json = v(r#"{"count":{"type":"int","required":true}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"count":5}"#)));
        assert!(!schema.matches(&v(r#"{"count":"5"}"#)));
    }

    #[test]
    fn from_json_float_field() {
        let schema_json = v(r#"{"score":{"type":"float","required":false}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v("{}"))); // optional, absent
        assert!(schema.matches(&v(r#"{"score":3.14}"#)));
        assert!(schema.matches(&v(r#"{"score":3}"#))); // Int also accepted
    }

    #[test]
    fn from_json_bool_field() {
        let schema_json = v(r#"{"active":{"type":"bool","required":true}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"active":false}"#)));
        assert!(!schema.matches(&v(r#"{"active":0}"#)));
    }

    #[test]
    fn from_json_any_field() {
        let schema_json = v(r#"{"meta":{"type":"any","required":false}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"meta":null}"#)));
        assert!(schema.matches(&v(r#"{"meta":[1,2]}"#)));
    }

    #[test]
    fn from_json_array_with_string_items() {
        let schema_json = v(r#"{"tags":{"type":"array","items":"string","required":true}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"tags":["a","b"]}"#)));
        let errs = schema.validate(&v(r#"{"tags":["a",2]}"#)).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "tags[1]");
    }

    #[test]
    fn from_json_array_with_object_descriptor_items() {
        let schema_json = v(r#"{"items":{"type":"array","items":{"type":"int"},"required":true}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"items":[1,2,3]}"#)));
    }

    #[test]
    fn from_json_enum_field() {
        let schema_json = v(r#"{"status":{"type":"enum","values":["on","off"],"required":true}}"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"status":"on"}"#)));
        assert!(!schema.matches(&v(r#"{"status":"maybe"}"#)));
    }

    #[test]
    fn from_json_nested_object() {
        let schema_json = v(r#"{
            "address": {
                "type": "object",
                "required": true,
                "schema": {
                    "city": {"type":"string","required":true},
                    "zip":  {"type":"string","required":false}
                }
            }
        }"#);
        let schema = Schema::from_json(&schema_json).unwrap();
        assert!(schema.matches(&v(r#"{"address":{"city":"Akureyri"}}"#)));
        let errs = schema
            .validate(&v(r#"{"address":{"city":42}}"#))
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].path, "address.city");
    }

    #[test]
    fn from_json_round_trip_sample_schema() {
        // Build in Rust, serialize to JSON, parse back, validate same payload.
        use crate::field_type::FieldType;
        use akurai_json::parse;

        let original = Schema::object(&[
            ("id", FieldType::Int, true),
            ("name", FieldType::String, true),
            ("tags", FieldType::Array(Box::new(FieldType::String)), false),
            (
                "kind",
                FieldType::Enum(vec!["card".into(), "chart".into()]),
                true,
            ),
        ]);

        // Serialize to JSON descriptor format.
        let schema_json_str = r#"{
            "id":   {"type":"int",    "required":true},
            "name": {"type":"string", "required":true},
            "tags": {"type":"array",  "items":"string", "required":false},
            "kind": {"type":"enum",   "values":["card","chart"], "required":true}
        }"#;
        let schema_val = parse(schema_json_str).unwrap();
        let parsed = Schema::from_json(&schema_val).unwrap();

        let payload = parse(r#"{"id":1,"name":"widget","kind":"card"}"#).unwrap();
        assert!(original.matches(&payload));
        assert!(parsed.matches(&payload));

        let bad = parse(r#"{"id":"x","name":"widget","kind":"card"}"#).unwrap();
        assert!(!original.matches(&bad));
        assert!(!parsed.matches(&bad));
    }

    #[test]
    fn from_json_error_not_object() {
        let err = Schema::from_json(&Value::Array(vec![])).unwrap_err();
        assert!(err.0.contains("root must be an object"));
    }

    #[test]
    fn from_json_error_missing_type() {
        let v = parse(r#"{"x":{"required":true}}"#).unwrap();
        let err = Schema::from_json(&v).unwrap_err();
        assert!(err.0.contains("missing \"type\""));
    }

    #[test]
    fn from_json_error_unknown_type() {
        let v = parse(r#"{"x":{"type":"uuid"}}"#).unwrap();
        let err = Schema::from_json(&v).unwrap_err();
        assert!(err.0.contains("unknown type"));
    }

    #[test]
    fn from_json_required_defaults_to_false() {
        // No "required" key → optional.
        let schema_json = parse(r#"{"label":{"type":"string"}}"#).unwrap();
        let schema = Schema::from_json(&schema_json).unwrap();
        // absent optional field is fine
        assert!(schema.matches(&parse("{}").unwrap()));
    }
}