use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
#[cfg(feature = "ts-bindings")]
use ts_rs::TS;
use crate::schema::SchemaError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
pub struct JsonTopology {
pub root: TopologyNode,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[serde(tag = "type")]
pub enum TopologyNode {
#[serde(rename = "Primitive")]
Primitive {
value: PrimitiveValueType,
#[serde(skip_serializing_if = "Option::is_none")]
classifications: Option<Vec<String>>,
},
Object {
value: HashMap<String, TopologyNode>,
},
Array { value: Box<TopologyNode> },
Any,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
pub enum PrimitiveValueType {
String,
Number,
Boolean,
Null,
}
pub type PrimitiveType = PrimitiveValueType;
impl JsonTopology {
pub fn new(root: TopologyNode) -> Self {
Self { root }
}
pub fn any() -> Self {
Self {
root: TopologyNode::Any,
}
}
pub fn validate(&self, value: &JsonValue) -> Result<(), SchemaError> {
self.root.validate(value, "root")
}
pub fn infer_from_value(value: &JsonValue) -> Self {
Self {
root: TopologyNode::infer_from_value(value),
}
}
pub fn compute_hash(&self) -> String {
let canonical = serde_json::to_string(&self.root).unwrap_or_else(|_| "{}".to_string());
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
format!("{:x}", hasher.finalize())
}
}
impl TopologyNode {
pub fn validate(&self, value: &JsonValue, path: &str) -> Result<(), SchemaError> {
match self {
TopologyNode::Any => Ok(()),
TopologyNode::Primitive {
value: prim_type, ..
} => {
match (prim_type, value) {
(PrimitiveValueType::String, JsonValue::String(_)) => Ok(()),
(PrimitiveValueType::Number, JsonValue::Number(_)) => Ok(()),
(PrimitiveValueType::Boolean, JsonValue::Bool(_)) => Ok(()),
(_, JsonValue::Null) => Ok(()),
_ => Err(SchemaError::InvalidData(format!(
"Topology validation failed at '{}': expected {:?}, got {:?}",
path,
prim_type,
value_type_name(value)
))),
}
}
TopologyNode::Object {
value: expected_fields,
} => {
if let JsonValue::Object(obj) = value {
for (field_name, field_topology) in expected_fields {
if let Some(field_value) = obj.get(field_name) {
let field_path = format!("{}.{}", path, field_name);
field_topology.validate(field_value, &field_path)?;
}
}
Ok(())
} else {
Err(SchemaError::InvalidData(format!(
"Topology validation failed at '{}': expected object, got {:?}",
path,
value_type_name(value)
)))
}
}
TopologyNode::Array {
value: element_topology,
} => {
if let JsonValue::Array(arr) = value {
for (idx, element) in arr.iter().enumerate() {
let element_path = format!("{}[{}]", path, idx);
element_topology.validate(element, &element_path)?;
}
Ok(())
} else {
Err(SchemaError::InvalidData(format!(
"Topology validation failed at '{}': expected array, got {:?}",
path,
value_type_name(value)
)))
}
}
}
}
pub fn infer_from_value(value: &JsonValue) -> Self {
match value {
JsonValue::String(_) => TopologyNode::Primitive {
value: PrimitiveValueType::String,
classifications: None,
},
JsonValue::Number(_) => TopologyNode::Primitive {
value: PrimitiveValueType::Number,
classifications: None,
},
JsonValue::Bool(_) => TopologyNode::Primitive {
value: PrimitiveValueType::Boolean,
classifications: None,
},
JsonValue::Null => TopologyNode::Any,
JsonValue::Array(arr) => {
let element_topology = arr
.first()
.map(Self::infer_from_value)
.unwrap_or(TopologyNode::Any);
TopologyNode::Array {
value: Box::new(element_topology),
}
}
JsonValue::Object(obj) => {
let mut fields = HashMap::new();
for (key, val) in obj {
fields.insert(key.clone(), Self::infer_from_value(val));
}
TopologyNode::Object { value: fields }
}
}
}
}
fn value_type_name(value: &JsonValue) -> &'static str {
match value {
JsonValue::String(_) => "string",
JsonValue::Number(_) => "number",
JsonValue::Bool(_) => "boolean",
JsonValue::Null => "null",
JsonValue::Array(_) => "array",
JsonValue::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_primitive_validation() {
let topology = JsonTopology::new(TopologyNode::Primitive {
value: PrimitiveValueType::String,
classifications: None,
});
assert!(topology.validate(&json!("hello")).is_ok());
assert!(topology.validate(&json!(42)).is_err());
assert!(topology.validate(&json!(true)).is_err());
}
#[test]
fn test_object_validation() {
let mut fields = HashMap::new();
fields.insert(
"name".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::String,
classifications: None,
},
);
fields.insert(
"age".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::Number,
classifications: None,
},
);
let topology = JsonTopology::new(TopologyNode::Object { value: fields });
assert!(topology
.validate(&json!({"name": "Alice", "age": 30}))
.is_ok());
assert!(topology.validate(&json!({"name": "Bob"})).is_ok());
assert!(topology
.validate(&json!({"name": "Alice", "age": "thirty"}))
.is_err());
assert!(topology.validate(&json!("string")).is_err());
}
#[test]
fn test_array_validation() {
let topology = JsonTopology::new(TopologyNode::Array {
value: Box::new(TopologyNode::Primitive {
value: PrimitiveValueType::Number,
classifications: None,
}),
});
assert!(topology.validate(&json!([1, 2, 3])).is_ok());
assert!(topology.validate(&json!([])).is_ok());
assert!(topology.validate(&json!([1, "two", 3])).is_err());
}
#[test]
fn test_nested_validation() {
let mut user_fields = HashMap::new();
user_fields.insert(
"id".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::Number,
classifications: None,
},
);
user_fields.insert(
"name".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::String,
classifications: None,
},
);
let mut root_fields = HashMap::new();
root_fields.insert(
"user".to_string(),
TopologyNode::Object { value: user_fields },
);
root_fields.insert(
"active".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::Boolean,
classifications: None,
},
);
let topology = JsonTopology::new(TopologyNode::Object { value: root_fields });
assert!(topology
.validate(&json!({
"user": {"id": 1, "name": "Alice"},
"active": true
}))
.is_ok());
assert!(topology
.validate(&json!({
"user": {"id": "not a number", "name": "Alice"},
"active": true
}))
.is_err());
}
#[test]
fn test_any_topology() {
let topology = JsonTopology::any();
assert!(topology.validate(&json!("string")).is_ok());
assert!(topology.validate(&json!(42)).is_ok());
assert!(topology.validate(&json!({"any": "structure"})).is_ok());
assert!(topology.validate(&json!([1, "two", true])).is_ok());
}
#[test]
fn test_infer_from_value() {
let value = json!({
"name": "Alice",
"age": 30,
"active": true,
"tags": ["rust", "database"]
});
let topology = JsonTopology::infer_from_value(&value);
assert!(topology.validate(&value).is_ok());
assert!(topology
.validate(&json!({
"name": "Bob",
"age": 25,
"active": false,
"tags": ["python"]
}))
.is_ok());
assert!(topology
.validate(&json!({
"name": "Charlie",
"age": "thirty"
}))
.is_err());
}
#[test]
fn test_nullable_primitives() {
let string_topology = JsonTopology::new(TopologyNode::Primitive {
value: PrimitiveValueType::String,
classifications: None,
});
assert!(string_topology.validate(&json!(null)).is_ok());
assert!(string_topology.validate(&json!("hello")).is_ok());
let number_topology = JsonTopology::new(TopologyNode::Primitive {
value: PrimitiveValueType::Number,
classifications: None,
});
assert!(number_topology.validate(&json!(null)).is_ok());
assert!(number_topology.validate(&json!(42)).is_ok());
let bool_topology = JsonTopology::new(TopologyNode::Primitive {
value: PrimitiveValueType::Boolean,
classifications: None,
});
assert!(bool_topology.validate(&json!(null)).is_ok());
assert!(bool_topology.validate(&json!(true)).is_ok());
}
#[test]
fn test_nullable_fields_in_object() {
let mut fields = HashMap::new();
fields.insert(
"thread_position".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::Number,
classifications: None,
},
);
fields.insert(
"reply_to".to_string(),
TopologyNode::Primitive {
value: PrimitiveValueType::String,
classifications: None,
},
);
let topology = JsonTopology::new(TopologyNode::Object { value: fields });
assert!(topology
.validate(&json!({"thread_position": null, "reply_to": "tweet_123"}))
.is_ok());
assert!(topology
.validate(&json!({"thread_position": 1, "reply_to": null}))
.is_ok());
assert!(topology
.validate(&json!({"thread_position": 1, "reply_to": "tweet_123"}))
.is_ok());
}
#[test]
fn test_infer_from_null_uses_any() {
let topology = JsonTopology::infer_from_value(&json!(null));
assert!(topology.validate(&json!(null)).is_ok());
assert!(topology.validate(&json!("string")).is_ok());
assert!(topology.validate(&json!(42)).is_ok());
assert!(topology.validate(&json!(true)).is_ok());
assert!(topology.validate(&json!({"key": "value"})).is_ok());
assert!(topology.validate(&json!([1, 2, 3])).is_ok());
}
#[test]
fn test_infer_from_object_with_null_fields() {
let sample = json!({
"name": "Alice",
"optional_field": null
});
let topology = JsonTopology::infer_from_value(&sample);
assert!(topology.validate(&sample).is_ok());
assert!(topology
.validate(&json!({
"name": "Bob",
"optional_field": "now a string"
}))
.is_ok());
assert!(topology
.validate(&json!({
"name": "Charlie",
"optional_field": 42
}))
.is_ok());
}
}