diff --git a/TASKS.md b/TASKS.md index 8476257..2b9f295 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,21 +2,9 @@ ## Current -### `feat/domain-model` - -Status: completed - -DoD: - -- core domain types from `docs/data-model.md` exist -- basic JSON and YAML serialization works -- domain unit tests pass - -## Next - ### `feat/schema-engine` -Status: pending +Status: in_progress DoD: @@ -24,6 +12,8 @@ DoD: - schema validation works - JSON sample normalization works +## Next + ### `feat/mapping-engine` Status: pending diff --git a/crates/mcpaas-schema/src/error.rs b/crates/mcpaas-schema/src/error.rs new file mode 100644 index 0000000..2bc176c --- /dev/null +++ b/crates/mcpaas-schema/src/error.rs @@ -0,0 +1,55 @@ +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum SchemaError { + #[error("missing required field at {path}")] + MissingField { path: String }, + #[error("invalid value at {path}: expected {expected}, got {actual}")] + InvalidType { + path: String, + expected: &'static str, + actual: &'static str, + }, + #[error("invalid enum value at {path}: expected one of {expected:?}, got {actual}")] + InvalidEnumValue { + path: String, + expected: Vec, + actual: String, + }, + #[error("no matching oneof variant at {path}")] + OneofMismatch { path: String }, +} + +impl SchemaError { + pub fn missing_field(path: impl Into) -> Self { + Self::MissingField { path: path.into() } + } + + pub fn invalid_type( + path: impl Into, + expected: &'static str, + actual: &'static str, + ) -> Self { + Self::InvalidType { + path: path.into(), + expected, + actual, + } + } + + pub fn invalid_enum_value( + path: impl Into, + expected: Vec, + actual: impl Into, + ) -> Self { + Self::InvalidEnumValue { + path: path.into(), + expected, + actual: actual.into(), + } + } + + pub fn oneof_mismatch(path: impl Into) -> Self { + Self::OneofMismatch { path: path.into() } + } +} diff --git a/crates/mcpaas-schema/src/lib.rs b/crates/mcpaas-schema/src/lib.rs index 8d9a4de..6b4962c 100644 --- a/crates/mcpaas-schema/src/lib.rs +++ b/crates/mcpaas-schema/src/lib.rs @@ -1,164 +1,7 @@ -use std::collections::BTreeMap; +mod error; +mod model; +mod normalize; +mod validate; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SchemaKind { - Object, - Array, - String, - Integer, - Number, - Boolean, - Enum, - #[serde(rename = "null")] - Null, - Oneof, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Schema { - #[serde(rename = "type")] - pub kind: SchemaKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default)] - pub required: bool, - #[serde(default)] - pub nullable: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_value: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub fields: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub items: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub enum_values: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub variants: Vec, -} - -impl Schema { - pub fn is_object(&self) -> bool { - self.kind == SchemaKind::Object - } - - pub fn field(&self, name: &str) -> Option<&Schema> { - self.fields.get(name) - } - - pub fn has_required_fields(&self) -> bool { - self.fields.values().any(|field| field.required) - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::{Schema, SchemaKind}; - - #[test] - fn object_schema_exposes_fields() { - let mut fields = BTreeMap::new(); - fields.insert( - "email".to_owned(), - Schema { - kind: SchemaKind::String, - description: None, - required: true, - nullable: false, - default_value: None, - fields: BTreeMap::new(), - items: None, - enum_values: Vec::new(), - variants: Vec::new(), - }, - ); - - let schema = Schema { - kind: SchemaKind::Object, - description: Some("User input".to_owned()), - required: true, - nullable: false, - default_value: None, - fields, - items: None, - enum_values: Vec::new(), - variants: Vec::new(), - }; - - assert!(schema.is_object()); - assert!(schema.has_required_fields()); - assert_eq!( - schema.field("email").map(|field| field.required), - Some(true) - ); - } - - #[test] - fn schema_serializes_type_field() { - let schema = Schema { - kind: SchemaKind::Array, - description: None, - required: false, - nullable: false, - default_value: None, - fields: BTreeMap::new(), - items: Some(Box::new(Schema { - kind: SchemaKind::String, - description: None, - required: false, - nullable: false, - default_value: None, - fields: BTreeMap::new(), - items: None, - enum_values: Vec::new(), - variants: Vec::new(), - })), - enum_values: Vec::new(), - variants: Vec::new(), - }; - - let value = serde_json::to_value(schema).unwrap(); - - assert_eq!(value["type"], "array"); - assert_eq!(value["items"]["type"], "string"); - } - - #[test] - fn schema_roundtrips_through_yaml() { - let schema = Schema { - kind: SchemaKind::Object, - description: Some("Lead output".to_owned()), - required: true, - nullable: false, - default_value: None, - fields: BTreeMap::from([( - "id".to_owned(), - Schema { - kind: SchemaKind::String, - description: None, - required: true, - nullable: false, - default_value: None, - fields: BTreeMap::new(), - items: None, - enum_values: Vec::new(), - variants: Vec::new(), - }, - )]), - items: None, - enum_values: Vec::new(), - variants: Vec::new(), - }; - - let yaml = serde_yaml::to_string(&schema).unwrap(); - let restored: Schema = serde_yaml::from_str(&yaml).unwrap(); - - assert!(yaml.contains("type: object")); - assert_eq!(restored, schema); - } -} +pub use error::SchemaError; +pub use model::{Schema, SchemaKind}; diff --git a/crates/mcpaas-schema/src/model.rs b/crates/mcpaas-schema/src/model.rs new file mode 100644 index 0000000..7486bc1 --- /dev/null +++ b/crates/mcpaas-schema/src/model.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SchemaKind { + Object, + Array, + String, + Integer, + Number, + Boolean, + Enum, + #[serde(rename = "null")] + Null, + Oneof, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Schema { + #[serde(rename = "type")] + pub kind: SchemaKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub nullable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_value: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub fields: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub items: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enum_values: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variants: Vec, +} + +impl Schema { + pub fn is_object(&self) -> bool { + self.kind == SchemaKind::Object + } + + pub fn field(&self, name: &str) -> Option<&Schema> { + self.fields.get(name) + } + + pub fn has_required_fields(&self) -> bool { + self.fields.values().any(|field| field.required) + } + + pub fn expected_type_name(&self) -> &'static str { + match self.kind { + SchemaKind::Object => "object", + SchemaKind::Array => "array", + SchemaKind::String => "string", + SchemaKind::Integer => "integer", + SchemaKind::Number => "number", + SchemaKind::Boolean => "boolean", + SchemaKind::Enum => "enum", + SchemaKind::Null => "null", + SchemaKind::Oneof => "oneof", + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{Schema, SchemaKind}; + + #[test] + fn object_schema_exposes_fields() { + let mut fields = BTreeMap::new(); + fields.insert( + "email".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + ); + + let schema = Schema { + kind: SchemaKind::Object, + description: Some("User input".to_owned()), + required: true, + nullable: false, + default_value: None, + fields, + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }; + + assert!(schema.is_object()); + assert!(schema.has_required_fields()); + assert_eq!( + schema.field("email").map(|field| field.required), + Some(true) + ); + } + + #[test] + fn schema_serializes_type_field() { + let schema = Schema { + kind: SchemaKind::Array, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: Some(Box::new(Schema { + kind: SchemaKind::String, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + })), + enum_values: Vec::new(), + variants: Vec::new(), + }; + + let value = serde_json::to_value(schema).unwrap(); + + assert_eq!(value["type"], "array"); + assert_eq!(value["items"]["type"], "string"); + } + + #[test] + fn schema_roundtrips_through_yaml() { + let schema = Schema { + kind: SchemaKind::Object, + description: Some("Lead output".to_owned()), + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + "id".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }; + + let yaml = serde_yaml::to_string(&schema).unwrap(); + let restored: Schema = serde_yaml::from_str(&yaml).unwrap(); + + assert!(yaml.contains("type: object")); + assert_eq!(restored, schema); + } +} diff --git a/crates/mcpaas-schema/src/normalize.rs b/crates/mcpaas-schema/src/normalize.rs new file mode 100644 index 0000000..8247296 --- /dev/null +++ b/crates/mcpaas-schema/src/normalize.rs @@ -0,0 +1,163 @@ +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +use crate::{Schema, SchemaKind}; + +impl Schema { + pub fn from_json_sample(value: &Value) -> Self { + match value { + Value::Null => Self { + kind: SchemaKind::Null, + description: None, + required: true, + nullable: true, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + Value::Bool(_) => scalar_schema(SchemaKind::Boolean), + Value::String(_) => scalar_schema(SchemaKind::String), + Value::Number(number) if number.is_i64() || number.is_u64() => { + scalar_schema(SchemaKind::Integer) + } + Value::Number(_) => scalar_schema(SchemaKind::Number), + Value::Array(items) => array_schema(items), + Value::Object(fields) => object_schema(fields), + } + } +} + +fn scalar_schema(kind: SchemaKind) -> Schema { + Schema { + kind, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } +} + +fn array_schema(items: &[Value]) -> Schema { + Schema { + kind: SchemaKind::Array, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: items + .first() + .map(|item| Box::new(Schema::from_json_sample(item))), + enum_values: Vec::new(), + variants: Vec::new(), + } +} + +fn object_schema(fields: &Map) -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: fields + .iter() + .map(|(name, value)| (name.clone(), Schema::from_json_sample(value))) + .collect(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use serde_json::json; + + use crate::{Schema, SchemaKind}; + + #[test] + fn infers_object_schema_from_json_sample() { + let schema = Schema::from_json_sample(&json!({ + "lead": { + "email": "user@example.com", + "active": true + }, + "score": 42 + })); + + assert_eq!(schema.kind, SchemaKind::Object); + assert_eq!( + schema.field("lead").map(|field| field.kind.clone()), + Some(SchemaKind::Object) + ); + assert_eq!( + schema + .field("lead") + .and_then(|field| field.field("email")) + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + assert_eq!( + schema.field("score").map(|field| field.kind.clone()), + Some(SchemaKind::Integer) + ); + } + + #[test] + fn infers_array_item_schema_from_first_sample_item() { + let schema = Schema::from_json_sample(&json!([ + { + "id": "lead_01", + "tags": ["warm", "priority"] + } + ])); + + assert_eq!(schema.kind, SchemaKind::Array); + assert_eq!( + schema + .items + .as_deref() + .and_then(|item| item.field("id")) + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + assert_eq!( + schema + .items + .as_deref() + .and_then(|item| item.field("tags")) + .map(|field| field.kind.clone()), + Some(SchemaKind::Array) + ); + } + + #[test] + fn infers_null_schema_for_null_sample() { + let schema = Schema::from_json_sample(&serde_json::Value::Null); + + assert_eq!( + schema, + Schema { + kind: SchemaKind::Null, + description: None, + required: true, + nullable: true, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + ); + } +} diff --git a/crates/mcpaas-schema/src/validate.rs b/crates/mcpaas-schema/src/validate.rs new file mode 100644 index 0000000..0075f92 --- /dev/null +++ b/crates/mcpaas-schema/src/validate.rs @@ -0,0 +1,260 @@ +use serde_json::Value; + +use crate::{Schema, SchemaError, SchemaKind}; + +impl Schema { + pub fn validate_shape(&self, value: &Value) -> Result<(), SchemaError> { + validate_value(self, value, "$") + } +} + +fn validate_value(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { + if value.is_null() { + if schema.nullable || schema.kind == SchemaKind::Null { + return Ok(()); + } + + return Err(SchemaError::invalid_type( + path, + schema.expected_type_name(), + "null", + )); + } + + match schema.kind { + SchemaKind::Object => validate_object(schema, value, path), + SchemaKind::Array => validate_array(schema, value, path), + SchemaKind::String => validate_scalar(schema, value, path, Value::is_string), + SchemaKind::Integer => validate_scalar(schema, value, path, Value::is_i64_or_u64), + SchemaKind::Number => validate_scalar(schema, value, path, Value::is_number), + SchemaKind::Boolean => validate_scalar(schema, value, path, Value::is_boolean), + SchemaKind::Enum => validate_enum(schema, value, path), + SchemaKind::Null => Err(SchemaError::invalid_type( + path, + "null", + actual_type_name(value), + )), + SchemaKind::Oneof => validate_oneof(schema, value, path), + } +} + +fn validate_object(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { + let Some(object) = value.as_object() else { + return Err(SchemaError::invalid_type( + path, + "object", + actual_type_name(value), + )); + }; + + for (field_name, field_schema) in &schema.fields { + let field_path = format!("{path}.{field_name}"); + + match object.get(field_name) { + Some(field_value) => validate_value(field_schema, field_value, &field_path)?, + None if field_schema.required => return Err(SchemaError::missing_field(field_path)), + None => {} + } + } + + Ok(()) +} + +fn validate_array(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { + let Some(items) = value.as_array() else { + return Err(SchemaError::invalid_type( + path, + "array", + actual_type_name(value), + )); + }; + + if let Some(item_schema) = schema.items.as_deref() { + for (index, item) in items.iter().enumerate() { + let item_path = format!("{path}[{index}]"); + validate_value(item_schema, item, &item_path)?; + } + } + + Ok(()) +} + +fn validate_scalar( + schema: &Schema, + value: &Value, + path: &str, + is_valid: impl Fn(&Value) -> bool, +) -> Result<(), SchemaError> { + if is_valid(value) { + return Ok(()); + } + + Err(SchemaError::invalid_type( + path, + schema.expected_type_name(), + actual_type_name(value), + )) +} + +fn validate_enum(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { + let Some(actual) = value.as_str() else { + return Err(SchemaError::invalid_type( + path, + "enum", + actual_type_name(value), + )); + }; + + if schema.enum_values.iter().any(|expected| expected == actual) { + return Ok(()); + } + + Err(SchemaError::invalid_enum_value( + path, + schema.enum_values.clone(), + actual, + )) +} + +fn validate_oneof(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { + if schema + .variants + .iter() + .any(|variant| validate_value(variant, value, path).is_ok()) + { + return Ok(()); + } + + Err(SchemaError::oneof_mismatch(path)) +} + +fn actual_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(number) if number.is_i64() || number.is_u64() => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +trait JsonValueExt { + fn is_i64_or_u64(&self) -> bool; +} + +impl JsonValueExt for Value { + fn is_i64_or_u64(&self) -> bool { + self.as_i64().is_some() || self.as_u64().is_some() + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use serde_json::json; + + use crate::{Schema, SchemaError, SchemaKind}; + + #[test] + fn validates_nested_object_shape() { + let schema = Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + "lead".to_owned(), + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + "email".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }; + + let result = schema.validate_shape(&json!({ + "lead": { + "email": "user@example.com" + } + })); + + assert!(result.is_ok()); + } + + #[test] + fn rejects_missing_required_field() { + let schema = Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + "email".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }; + + let error = schema.validate_shape(&json!({})).unwrap_err(); + + assert_eq!(error, SchemaError::missing_field("$.email")); + } + + #[test] + fn rejects_invalid_scalar_type() { + let schema = Schema { + kind: SchemaKind::Boolean, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }; + + let error = schema.validate_shape(&json!("true")).unwrap_err(); + + assert_eq!(error, SchemaError::invalid_type("$", "boolean", "string")); + } +}