feat: implement schema validation and sample normalization
This commit is contained in:
@@ -2,21 +2,9 @@
|
|||||||
|
|
||||||
## Current
|
## 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`
|
### `feat/schema-engine`
|
||||||
|
|
||||||
Status: pending
|
Status: in_progress
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
|
|
||||||
@@ -24,6 +12,8 @@ DoD:
|
|||||||
- schema validation works
|
- schema validation works
|
||||||
- JSON sample normalization works
|
- JSON sample normalization works
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
### `feat/mapping-engine`
|
### `feat/mapping-engine`
|
||||||
|
|
||||||
Status: pending
|
Status: pending
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
|
#[error("no matching oneof variant at {path}")]
|
||||||
|
OneofMismatch { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SchemaError {
|
||||||
|
pub fn missing_field(path: impl Into<String>) -> Self {
|
||||||
|
Self::MissingField { path: path.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalid_type(
|
||||||
|
path: impl Into<String>,
|
||||||
|
expected: &'static str,
|
||||||
|
actual: &'static str,
|
||||||
|
) -> Self {
|
||||||
|
Self::InvalidType {
|
||||||
|
path: path.into(),
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalid_enum_value(
|
||||||
|
path: impl Into<String>,
|
||||||
|
expected: Vec<String>,
|
||||||
|
actual: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::InvalidEnumValue {
|
||||||
|
path: path.into(),
|
||||||
|
expected,
|
||||||
|
actual: actual.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oneof_mismatch(path: impl Into<String>) -> Self {
|
||||||
|
Self::OneofMismatch { path: path.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,164 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
mod error;
|
||||||
|
mod model;
|
||||||
|
mod normalize;
|
||||||
|
mod validate;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
pub use error::SchemaError;
|
||||||
use serde_json::Value;
|
pub use model::{Schema, SchemaKind};
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub required: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub nullable: bool,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub default_value: Option<Value>,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub fields: BTreeMap<String, Schema>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub items: Option<Box<Schema>>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub enum_values: Vec<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub variants: Vec<Schema>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub required: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nullable: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_value: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub fields: BTreeMap<String, Schema>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub items: Option<Box<Schema>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub enum_values: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub variants: Vec<Schema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Value>) -> 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(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user