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()));
}
}