use std::collections::HashMap;
use serde_json::Value;
use crate::ast::{Declaration, Program, TypeDefinition};
#[derive(Debug, Clone, PartialEq)]
pub struct TypeSchema {
pub name: String,
pub fields: Vec<FieldSchema>,
pub range: Option<(f64, f64)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FieldSchema {
pub name: String,
pub type_name: String,
pub generic_param: String,
pub optional: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct BodyValidationError {
pub expected_type: String,
pub field_path: String,
pub expected: String,
pub got: String,
pub hint: String,
}
impl std::fmt::Display for BodyValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.hint)
}
}
impl std::error::Error for BodyValidationError {}
pub const BUILTIN_PRIMITIVES: &[&str] = &[
"String",
"Integer",
"Float",
"Boolean",
"Duration",
"Any",
];
pub fn builtin_range(name: &str) -> Option<(f64, f64)> {
match name {
"RiskScore" | "ConfidenceScore" => Some((0.0, 1.0)),
"SentimentScore" => Some((-1.0, 1.0)),
_ => None,
}
}
pub fn collect_type_table(program: &Program) -> HashMap<String, TypeSchema> {
let mut table = HashMap::new();
for decl in &program.declarations {
if let Declaration::Type(td) = decl {
table.insert(td.name.clone(), type_schema_from(td));
}
}
table
}
fn type_schema_from(td: &TypeDefinition) -> TypeSchema {
let fields = td
.fields
.iter()
.map(|f| FieldSchema {
name: f.name.clone(),
type_name: f.type_expr.name.clone(),
generic_param: f.type_expr.generic_param.clone(),
optional: f.type_expr.optional,
})
.collect();
let range = td
.range_constraint
.as_ref()
.map(|rc| (rc.min_value, rc.max_value));
TypeSchema {
name: td.name.clone(),
fields,
range,
}
}
fn json_tag(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(n) => {
if n.is_i64() || n.is_u64() {
"integer"
} else {
"number"
}
}
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub fn validate_body(
body: &Value,
type_name: &str,
table: &HashMap<String, TypeSchema>,
) -> Result<(), BodyValidationError> {
if type_name.is_empty() {
return Ok(());
}
validate_value(body, type_name, "", "", table, type_name)
}
fn validate_value(
v: &Value,
type_name: &str,
generic_param: &str,
field_path: &str,
table: &HashMap<String, TypeSchema>,
body_type: &str,
) -> Result<(), BodyValidationError> {
if BUILTIN_PRIMITIVES.contains(&type_name) {
return validate_primitive(v, type_name, field_path, body_type);
}
if let Some((lo, hi)) = builtin_range(type_name) {
return validate_ranged_number(v, type_name, lo, hi, field_path, body_type);
}
if type_name == "List" {
return validate_list(v, generic_param, field_path, table, body_type);
}
if let Some(schema) = table.get(type_name) {
if let Some((lo, hi)) = schema.range {
return validate_ranged_number(v, type_name, lo, hi, field_path, body_type);
}
return validate_struct(v, schema, field_path, table, body_type);
}
Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: field_path.to_string(),
expected: type_name.to_string(),
got: json_tag(v).to_string(),
hint: format!(
"axonendpoint declared an unknown body type `{type_name}` for field \
`{field_path}` — neither a built-in primitive nor a declared \
`type` in the deployed source. Add `type {type_name} {{ … }}` to \
the source or correct the spelling."
),
})
}
fn validate_primitive(
v: &Value,
type_name: &str,
field_path: &str,
body_type: &str,
) -> Result<(), BodyValidationError> {
let ok = match (type_name, v) {
("String", Value::String(_)) => true,
("Integer", Value::Number(n)) => n.is_i64() || n.is_u64(),
("Float", Value::Number(_)) => true,
("Boolean", Value::Bool(_)) => true,
("Duration", Value::String(_)) => true,
("Any", _) => true,
_ => false,
};
if ok {
return Ok(());
}
Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: field_path.to_string(),
expected: type_name.to_string(),
got: json_tag(v).to_string(),
hint: format!(
"Body field `{field_path}` must be a `{type_name}` but received a \
{got}. Adjust the request body or the axonendpoint's `body:` \
declaration.",
field_path = if field_path.is_empty() { "<body>" } else { field_path },
type_name = type_name,
got = json_tag(v),
),
})
}
pub fn fmt_f64(n: f64) -> String {
if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e16 {
return format!("{}", n as i64);
}
format!("{n}")
}
fn validate_ranged_number(
v: &Value,
type_name: &str,
lo: f64,
hi: f64,
field_path: &str,
body_type: &str,
) -> Result<(), BodyValidationError> {
let n = match (v, v.as_f64()) {
(Value::Number(_), Some(n)) => n,
_ => {
return Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: field_path.to_string(),
expected: type_name.to_string(),
got: json_tag(v).to_string(),
hint: format!(
"Body field `{path}` must be a `{type_name}` (numeric in \
[{lo}, {hi}]) but received a {got}.",
path = if field_path.is_empty() { "<body>" } else { field_path },
type_name = type_name,
got = json_tag(v),
lo = fmt_f64(lo),
hi = fmt_f64(hi),
),
});
}
};
if n < lo || n > hi {
let lo_s = fmt_f64(lo);
let hi_s = fmt_f64(hi);
let n_s = fmt_f64(n);
return Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: field_path.to_string(),
expected: format!("{type_name} ∈ [{lo_s}, {hi_s}]"),
got: n_s.clone(),
hint: format!(
"Body field `{path}` must satisfy `{type_name} ∈ [{lo_s}, \
{hi_s}]` but received `{n_s}`.",
path = if field_path.is_empty() { "<body>" } else { field_path },
),
});
}
Ok(())
}
fn validate_list(
v: &Value,
element_type: &str,
field_path: &str,
table: &HashMap<String, TypeSchema>,
body_type: &str,
) -> Result<(), BodyValidationError> {
let arr = match v.as_array() {
Some(a) => a,
None => {
return Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: field_path.to_string(),
expected: format!("List<{element_type}>"),
got: json_tag(v).to_string(),
hint: format!(
"Body field `{path}` must be a `List<{element_type}>` \
(JSON array) but received a {got}.",
path = if field_path.is_empty() { "<body>" } else { field_path },
got = json_tag(v),
),
});
}
};
if element_type.is_empty() {
return Ok(());
}
for (idx, elem) in arr.iter().enumerate() {
let elem_path = if field_path.is_empty() {
format!("[{idx}]")
} else {
format!("{field_path}[{idx}]")
};
validate_value(elem, element_type, "", &elem_path, table, body_type)?;
}
Ok(())
}
fn validate_struct(
v: &Value,
schema: &TypeSchema,
field_path: &str,
table: &HashMap<String, TypeSchema>,
body_type: &str,
) -> Result<(), BodyValidationError> {
let obj = match v.as_object() {
Some(o) => o,
None => {
return Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: field_path.to_string(),
expected: schema.name.clone(),
got: json_tag(v).to_string(),
hint: format!(
"Body field `{path}` must be a `{type_name}` (JSON object) \
but received a {got}.",
path = if field_path.is_empty() { "<body>" } else { field_path },
type_name = schema.name,
got = json_tag(v),
),
});
}
};
for field in &schema.fields {
let child_path = if field_path.is_empty() {
field.name.clone()
} else {
format!("{field_path}.{}", field.name)
};
match obj.get(&field.name) {
None => {
if field.optional {
continue;
}
return Err(BodyValidationError {
expected_type: body_type.to_string(),
field_path: child_path.clone(),
expected: field.type_name.clone(),
got: "missing".to_string(),
hint: format!(
"Body field `{child_path}` is required (declared as \
`{type_name}` on `{struct_name}`) but is absent from \
the request body.",
type_name = field.type_name,
struct_name = schema.name,
),
});
}
Some(child) => {
if field.optional && child.is_null() {
continue;
}
validate_value(
child,
&field.type_name,
&field.generic_param,
&child_path,
table,
body_type,
)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn t_string() -> TypeSchema {
TypeSchema {
name: "String".to_string(),
fields: vec![],
range: None,
}
}
fn person_schema() -> TypeSchema {
TypeSchema {
name: "Person".to_string(),
fields: vec![
FieldSchema {
name: "name".to_string(),
type_name: "String".to_string(),
generic_param: String::new(),
optional: false,
},
FieldSchema {
name: "age".to_string(),
type_name: "Integer".to_string(),
generic_param: String::new(),
optional: true,
},
],
range: None,
}
}
#[test]
fn empty_body_type_passes_any_body() {
let table = HashMap::new();
let body = serde_json::json!({"anything": "goes"});
assert!(validate_body(&body, "", &table).is_ok());
}
#[test]
fn primitive_string_ok() {
let table = HashMap::new();
let body = serde_json::json!("hello");
assert!(validate_body(&body, "String", &table).is_ok());
}
#[test]
fn primitive_string_rejects_number() {
let table = HashMap::new();
let body = serde_json::json!(42);
let err = validate_body(&body, "String", &table).unwrap_err();
assert_eq!(err.expected, "String");
assert_eq!(err.got, "integer");
}
#[test]
fn integer_rejects_float() {
let table = HashMap::new();
let body = serde_json::json!(3.14);
let err = validate_body(&body, "Integer", &table).unwrap_err();
assert_eq!(err.expected, "Integer");
assert_eq!(err.got, "number");
}
#[test]
fn float_accepts_integer_json() {
let table = HashMap::new();
let body = serde_json::json!(42);
assert!(validate_body(&body, "Float", &table).is_ok());
let body = serde_json::json!(3.14);
assert!(validate_body(&body, "Float", &table).is_ok());
}
#[test]
fn structured_missing_required_field() {
let mut table = HashMap::new();
table.insert("Person".to_string(), person_schema());
let body = serde_json::json!({"age": 30});
let err = validate_body(&body, "Person", &table).unwrap_err();
assert_eq!(err.field_path, "name");
assert_eq!(err.got, "missing");
}
#[test]
fn structured_optional_field_can_be_absent() {
let mut table = HashMap::new();
table.insert("Person".to_string(), person_schema());
let body = serde_json::json!({"name": "alice"});
assert!(validate_body(&body, "Person", &table).is_ok());
}
#[test]
fn structured_optional_field_can_be_null() {
let mut table = HashMap::new();
table.insert("Person".to_string(), person_schema());
let body = serde_json::json!({"name": "alice", "age": null});
assert!(validate_body(&body, "Person", &table).is_ok());
}
#[test]
fn structured_unknown_extra_fields_accepted() {
let mut table = HashMap::new();
table.insert("Person".to_string(), person_schema());
let body = serde_json::json!({"name": "alice", "extra": "data"});
assert!(validate_body(&body, "Person", &table).is_ok());
}
#[test]
fn list_validates_each_element() {
let mut table = HashMap::new();
table.insert("String".to_string(), t_string());
let body = serde_json::json!(["a", "b", "c"]);
let err = validate_body(&body, "List", &table);
assert!(err.is_ok());
}
#[test]
fn list_rejects_non_array() {
let table = HashMap::new();
let body = serde_json::json!({"not": "array"});
let r = validate_value(&body, "List", "String", "", &table, "List");
let err = r.unwrap_err();
assert!(err.expected.contains("List"));
assert_eq!(err.got, "object");
}
#[test]
fn list_element_violation_reports_indexed_path() {
let table = HashMap::new();
let body = serde_json::json!(["a", 42, "c"]);
let r = validate_value(&body, "List", "String", "", &table, "List");
let err = r.unwrap_err();
assert_eq!(err.field_path, "[1]");
assert_eq!(err.got, "integer");
}
#[test]
fn range_type_rejects_out_of_bounds() {
let table = HashMap::new();
let body = serde_json::json!(1.5);
let err = validate_body(&body, "RiskScore", &table).unwrap_err();
assert!(err.expected.contains("RiskScore"));
}
#[test]
fn range_type_accepts_in_bounds() {
let table = HashMap::new();
let body = serde_json::json!(0.7);
assert!(validate_body(&body, "RiskScore", &table).is_ok());
}
#[test]
fn unknown_type_returns_diagnostic() {
let table = HashMap::new();
let body = serde_json::json!({});
let err = validate_body(&body, "NotDeclared", &table).unwrap_err();
assert!(err.hint.contains("NotDeclared"));
}
#[test]
fn nested_struct_field_path_is_dotted() {
let mut table = HashMap::new();
table.insert("Person".to_string(), person_schema());
table.insert(
"Loan".to_string(),
TypeSchema {
name: "Loan".to_string(),
fields: vec![FieldSchema {
name: "applicant".to_string(),
type_name: "Person".to_string(),
generic_param: String::new(),
optional: false,
}],
range: None,
},
);
let body = serde_json::json!({"applicant": {"age": 30}});
let err = validate_body(&body, "Loan", &table).unwrap_err();
assert_eq!(err.field_path, "applicant.name");
assert_eq!(err.expected_type, "Loan");
}
#[test]
fn json_tag_distinguishes_integer_and_number() {
assert_eq!(json_tag(&serde_json::json!(42)), "integer");
assert_eq!(json_tag(&serde_json::json!(3.14)), "number");
}
}