use std::collections::HashMap;
use serde_json::{Value, json};
use specta::Types;
use specta::datatype::{DataType, Field, NamedDataType, NamedReferenceType, Reference, Struct};
use crate::{ProcedureSet, ProcedureType};
#[must_use]
pub struct Validator {
commands: HashMap<String, jsonschema::Validator>,
}
impl Validator {
pub fn new() -> Self {
Self {
commands: HashMap::new(),
}
}
pub fn register<P: ProcedureSet>(mut self) -> Result<Self, ValidatorError> {
let mut types = Types::default();
let procedures = P::procedures(&mut types);
let schemas = build_command_schemas(
types,
&[SchemaSet {
namespace: P::NAMESPACE,
procedures: &procedures,
}],
)?;
for (wire, schema) in schemas {
let compiled = jsonschema::validator_for(&schema)
.map_err(|source| ValidatorError::Compile(source.to_string()))?;
self.commands.insert(wire, compiled);
}
Ok(self)
}
pub fn validate(&self, command: &str, args: &Value) -> Result<(), ValidationError> {
let Some(compiled) = self.commands.get(command) else {
return Ok(());
};
if compiled.is_valid(args) {
return Ok(());
}
let message = compiled.iter_errors(args).next().map_or_else(
|| "does not match the schema".to_string(),
|err| err.to_string(),
);
Err(ValidationError {
command: command.to_string(),
message,
})
}
}
impl Default for Validator {
fn default() -> Self {
Self::new()
}
}
pub(crate) struct SchemaSet<'a> {
pub namespace: Option<&'a str>,
pub procedures: &'a [ProcedureType],
}
pub(crate) fn build_command_schemas(
mut types: Types,
sets: &[SchemaSet<'_>],
) -> Result<Vec<(String, Value)>, ValidatorError> {
let mut wire_names = Vec::new();
for set in sets {
for procedure in set.procedures {
let def_name = format!("TtipcArgs{}", wire_names.len());
let fields: Vec<(String, DataType)> = procedure
.args
.iter()
.map(|(name, ty)| ((*name).to_string(), inline_leaf(ty.clone())))
.collect();
NamedDataType::new(def_name.clone(), &mut types, |_types, ndt| {
let mut builder = Struct::named();
for (name, ty) in &fields {
builder = builder.field(name.clone(), Field::new(ty.clone()));
}
ndt.ty = Some(builder.build());
});
let wire = match set.namespace {
Some(namespace) => format!("{namespace}.{}", procedure.name),
None => procedure.name.to_string(),
};
wire_names.push((wire, def_name));
}
}
let document = specta_jsonschema::JsonSchema::default()
.export(&types, specta_serde::Format)
.map_err(|source| ValidatorError::Schema(source.to_string()))?;
let document: Value = serde_json::from_str(&document)
.map_err(|source| ValidatorError::Schema(source.to_string()))?;
let defs_key = if document.get("$defs").is_some() {
"$defs"
} else {
"definitions"
};
let definitions = document.get(defs_key).cloned().unwrap_or_else(|| json!({}));
let dialect = document.get("$schema").cloned();
let mut schemas = Vec::with_capacity(wire_names.len());
for (wire, def_name) in wire_names {
let mut schema = serde_json::Map::new();
if let Some(dialect) = dialect.clone() {
schema.insert("$schema".to_string(), dialect);
}
schema.insert(
"$ref".to_string(),
Value::String(format!("#/{defs_key}/{def_name}")),
);
schema.insert(defs_key.to_string(), definitions.clone());
schemas.push((wire, Value::Object(schema)));
}
Ok(schemas)
}
fn inline_leaf(ty: DataType) -> DataType {
match ty {
DataType::Reference(Reference::Named(named)) => {
if let NamedReferenceType::Inline { dt, .. } = &named.inner {
inline_leaf((**dt).clone())
} else {
DataType::Reference(Reference::Named(named))
}
}
DataType::List(mut list) => {
list.ty = Box::new(inline_leaf(*list.ty));
DataType::List(list)
}
DataType::Map(mut map) => {
let key = inline_leaf(map.key_ty().clone());
let value = inline_leaf(map.value_ty().clone());
*map.key_ty_mut() = key;
*map.value_ty_mut() = value;
DataType::Map(map)
}
DataType::Tuple(mut tuple) => {
tuple.elements = tuple.elements.into_iter().map(inline_leaf).collect();
DataType::Tuple(tuple)
}
DataType::Nullable(inner) => DataType::Nullable(Box::new(inline_leaf(*inner))),
DataType::Intersection(parts) => {
DataType::Intersection(parts.into_iter().map(inline_leaf).collect())
}
other => other,
}
}
#[derive(Debug, thiserror::Error)]
pub enum ValidatorError {
#[error("building the JSON Schema contract failed: {0}")]
Schema(String),
#[error("compiling the validator failed: {0}")]
Compile(String),
}
#[derive(Debug, thiserror::Error)]
#[error("invalid payload for {command:?}: {message}")]
pub struct ValidationError {
pub command: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ProcedureSet, ProcedureType};
use specta::Type;
struct Fixture;
impl ProcedureSet for Fixture {
const OBJECT: &'static str = "fixture";
fn procedures(types: &mut Types) -> Vec<ProcedureType> {
vec![
ProcedureType {
name: "greet",
args: vec![("name", <String as Type>::definition(types))],
channels: vec![],
output: <String as Type>::definition(types),
error: None,
},
ProcedureType {
name: "sum",
args: vec![("values", <Vec<u32> as Type>::definition(types))],
channels: vec![],
output: <u32 as Type>::definition(types),
error: None,
},
]
}
}
fn validator() -> Validator {
Validator::new()
.register::<Fixture>()
.expect("fixture validator builds")
}
#[test]
fn accepts_well_typed_payloads() {
let v = validator();
assert!(v.validate("greet", &json!({ "name": "world" })).is_ok());
assert!(v.validate("sum", &json!({ "values": [1, 2, 3] })).is_ok());
}
#[test]
fn rejects_a_wrong_typed_primitive_argument() {
let v = validator();
let err = v
.validate("greet", &json!({ "name": 5 }))
.expect_err("a number is not a string");
assert_eq!(err.command, "greet");
}
#[test]
fn rejects_a_wrong_typed_container_element() {
let v = validator();
assert!(v.validate("sum", &json!({ "values": ["x"] })).is_err());
}
#[test]
fn rejects_a_missing_required_argument() {
let v = validator();
assert!(v.validate("greet", &json!({})).is_err());
}
#[test]
fn passes_unknown_commands_through() {
let v = validator();
assert!(v.validate("not_registered", &json!({})).is_ok());
}
}