//! Parameter validation module //! //! Validates trigger and action parameters against their declared schemas. //! Schemas use the flat StackStorm-style format: //! { "param_name": { "type": "string", "required": true, "secret": true, ... }, ... } //! //! Before validation, flat schemas are converted to standard JSON Schema so we //! can reuse the `jsonschema` crate. Template-aware: values containing `{{ }}` //! template expressions are replaced with schema-appropriate placeholders before //! validation, so template expressions pass type checks while literal values are //! still validated normally. use attune_common::models::{action::Action, trigger::Trigger}; use jsonschema::Validator; use serde_json::Value; use crate::middleware::ApiError; /// Convert a flat StackStorm-style parameter schema into a standard JSON Schema /// object suitable for `jsonschema::Validator`. /// /// Input (flat): /// ```json /// { "url": { "type": "string", "required": true }, "timeout": { "type": "integer", "default": 30 } } /// ``` /// /// Output (JSON Schema): /// ```json /// { "type": "object", "properties": { "url": { "type": "string" }, "timeout": { "type": "integer", "default": 30 } }, "required": ["url"] } /// ``` fn flat_to_json_schema(flat: &Value) -> Value { let Some(map) = flat.as_object() else { // Not an object — return a permissive schema return serde_json::json!({}); }; // If it already looks like a JSON Schema (has "type": "object" + "properties"), // pass it through unchanged for backward tolerance. if map.get("type").and_then(|v| v.as_str()) == Some("object") && map.contains_key("properties") { return flat.clone(); } let mut properties = serde_json::Map::new(); let mut required: Vec = Vec::new(); for (key, prop_def) in map { let Some(prop_obj) = prop_def.as_object() else { // Skip non-object entries (shouldn't happen in valid schemas) continue; }; // Clone the property definition, stripping `required` and `secret` // (they are not valid JSON Schema keywords). let mut clean = prop_obj.clone(); let is_required = clean .remove("required") .and_then(|v| v.as_bool()) .unwrap_or(false); clean.remove("secret"); // `position` is also an Attune extension, not JSON Schema clean.remove("position"); if is_required { required.push(Value::String(key.clone())); } properties.insert(key.clone(), Value::Object(clean)); } let mut schema = serde_json::Map::new(); schema.insert("type".to_string(), Value::String("object".to_string())); schema.insert("properties".to_string(), Value::Object(properties)); if !required.is_empty() { schema.insert("required".to_string(), Value::Array(required)); } Value::Object(schema) } /// Check if a JSON value is (or contains) a template expression. fn is_template_expression(value: &Value) -> bool { match value { Value::String(s) => s.contains("{{") && s.contains("}}"), _ => false, } } /// Given a JSON Schema property definition, produce a placeholder value that /// satisfies the schema's type constraint. This is used to replace template /// expressions so that JSON Schema validation passes for the remaining /// (non-template) parts of the parameters. fn placeholder_for_schema(property_schema: &Value) -> Value { // Handle anyOf / oneOf by picking the first variant if let Some(any_of) = property_schema.get("anyOf").and_then(|v| v.as_array()) { if let Some(first) = any_of.first() { return placeholder_for_schema(first); } } if let Some(one_of) = property_schema.get("oneOf").and_then(|v| v.as_array()) { if let Some(first) = one_of.first() { return placeholder_for_schema(first); } } let type_value = property_schema.get("type").and_then(|t| t.as_str()); match type_value { Some("integer") => { // Use minimum if set, else default if set, else 0 if let Some(default) = property_schema.get("default") { return default.clone(); } if let Some(min) = property_schema.get("minimum").and_then(|v| v.as_i64()) { return Value::Number(min.into()); } Value::Number(0.into()) } Some("number") => { if let Some(default) = property_schema.get("default") { return default.clone(); } if let Some(min) = property_schema.get("minimum").and_then(|v| v.as_f64()) { return serde_json::Number::from_f64(min) .map(Value::Number) .unwrap_or(Value::Number(0.into())); } serde_json::Number::from_f64(0.0) .map(Value::Number) .unwrap_or(Value::Number(0.into())) } Some("boolean") => { if let Some(default) = property_schema.get("default") { return default.clone(); } Value::Bool(true) } Some("array") => { if let Some(default) = property_schema.get("default") { return default.clone(); } Value::Array(vec![]) } Some("object") => { if let Some(default) = property_schema.get("default") { return default.clone(); } Value::Object(serde_json::Map::new()) } Some("string") | None => { // For enum fields, use the first valid value so enum validation passes if let Some(enum_values) = property_schema.get("enum").and_then(|v| v.as_array()) { if let Some(first) = enum_values.first() { return first.clone(); } } if let Some(default) = property_schema.get("default") { return default.clone(); } Value::String("__template_placeholder__".to_string()) } Some(_) => Value::Null, } } /// Walk a parameters object and replace any template expression values with /// schema-appropriate placeholders. Only replaces leaf values that match /// `{{ ... }}`; non-template values are left untouched for normal validation. /// /// `schema` must be a standard JSON Schema object (with `properties`, `type`, etc). /// Call `flat_to_json_schema` first if starting from flat format. fn replace_templates_with_placeholders(params: &Value, schema: &Value) -> Value { match params { Value::Object(map) => { let properties = schema.get("properties").and_then(|p| p.as_object()); let mut result = serde_json::Map::new(); for (key, value) in map { let prop_schema = properties.and_then(|p| p.get(key)); if is_template_expression(value) { // Replace with a type-appropriate placeholder if let Some(ps) = prop_schema { result.insert(key.clone(), placeholder_for_schema(ps)); } else { // No schema for this property — keep as string placeholder result.insert( key.clone(), Value::String("__template_placeholder__".to_string()), ); } } else if value.is_object() { // Recurse into nested objects let empty_schema = Value::Object(serde_json::Map::new()); let nested_schema = prop_schema.unwrap_or(&empty_schema); result.insert( key.clone(), replace_templates_with_placeholders(value, nested_schema), ); } else if value.is_array() { // Recurse into arrays — check each element if let Some(arr) = value.as_array() { let empty_items_schema = Value::Object(serde_json::Map::new()); let item_schema = prop_schema .and_then(|ps| ps.get("items")) .unwrap_or(&empty_items_schema); let new_arr: Vec = arr .iter() .map(|item| { if is_template_expression(item) { placeholder_for_schema(item_schema) } else if item.is_object() || item.is_array() { replace_templates_with_placeholders(item, item_schema) } else { item.clone() } }) .collect(); result.insert(key.clone(), Value::Array(new_arr)); } else { result.insert(key.clone(), value.clone()); } } else { result.insert(key.clone(), value.clone()); } } Value::Object(result) } other => other.clone(), } } /// Validate trigger parameters against the trigger's parameter schema. /// Template expressions (`{{ ... }}`) are accepted for any field type. /// /// The schema is expected in flat StackStorm format and is converted to /// JSON Schema internally for validation. pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(), ApiError> { // If no schema is defined, accept any parameters let Some(flat_schema) = &trigger.param_schema else { return Ok(()); }; // Convert flat format to JSON Schema for validation let schema = flat_to_json_schema(flat_schema); // Replace template expressions with schema-appropriate placeholders let sanitized = replace_templates_with_placeholders(params, &schema); // Compile the JSON schema let compiled_schema = Validator::new(&schema).map_err(|e| { ApiError::InternalServerError(format!( "Invalid parameter schema for trigger '{}': {}", trigger.r#ref, e )) })?; // Validate the sanitized parameters let errors: Vec = compiled_schema .iter_errors(&sanitized) .map(|e| { let path = e.instance_path().to_string(); if path.is_empty() { e.to_string() } else { format!("{} at {}", e, path) } }) .collect(); if !errors.is_empty() { return Err(ApiError::ValidationError(format!( "Invalid parameters for trigger '{}': {}", trigger.r#ref, errors.join(", ") ))); } Ok(()) } /// Validate action parameters against the action's parameter schema. /// Template expressions (`{{ ... }}`) are accepted for any field type. /// /// The schema is expected in flat StackStorm format and is converted to /// JSON Schema internally for validation. pub fn validate_action_params(action: &Action, params: &Value) -> Result<(), ApiError> { // If no schema is defined, accept any parameters let Some(flat_schema) = &action.param_schema else { return Ok(()); }; // Convert flat format to JSON Schema for validation let schema = flat_to_json_schema(flat_schema); // Replace template expressions with schema-appropriate placeholders let sanitized = replace_templates_with_placeholders(params, &schema); // Compile the JSON schema let compiled_schema = Validator::new(&schema).map_err(|e| { ApiError::InternalServerError(format!( "Invalid parameter schema for action '{}': {}", action.r#ref, e )) })?; // Validate the sanitized parameters let errors: Vec = compiled_schema .iter_errors(&sanitized) .map(|e| { let path = e.instance_path().to_string(); if path.is_empty() { e.to_string() } else { format!("{} at {}", e, path) } }) .collect(); if !errors.is_empty() { return Err(ApiError::ValidationError(format!( "Invalid parameters for action '{}': {}", action.r#ref, errors.join(", ") ))); } Ok(()) } #[cfg(test)] mod tests { use super::*; use serde_json::json; // ── Helper builders ────────────────────────────────────────────── fn make_trigger(schema: Option) -> Trigger { Trigger { id: 1, r#ref: "test.trigger".to_string(), pack: Some(1), pack_ref: Some("test".to_string()), label: "Test Trigger".to_string(), description: None, enabled: true, param_schema: schema, out_schema: None, webhook_enabled: false, webhook_key: None, webhook_config: None, is_adhoc: false, created: chrono::Utc::now(), updated: chrono::Utc::now(), } } fn make_action(schema: Option) -> Action { Action { id: 1, r#ref: "test.action".to_string(), pack: 1, pack_ref: "test".to_string(), label: "Test Action".to_string(), description: Some("Test action".to_string()), entrypoint: "test.sh".to_string(), runtime: Some(1), runtime_version_constraint: None, param_schema: schema, out_schema: None, workflow_def: None, is_adhoc: false, parameter_delivery: attune_common::models::ParameterDelivery::default(), parameter_format: attune_common::models::ParameterFormat::default(), output_format: attune_common::models::OutputFormat::default(), created: chrono::Utc::now(), updated: chrono::Utc::now(), } } // ── No schema ──────────────────────────────────────────────────── #[test] fn test_validate_trigger_params_with_no_schema() { let trigger = make_trigger(None); let params = json!({ "any": "value" }); assert!(validate_trigger_params(&trigger, ¶ms).is_ok()); } // ── Basic trigger validation (no templates) ────────────────────── // ── flat_to_json_schema unit tests ─────────────────────────────── #[test] fn test_flat_to_json_schema_basic() { let flat = json!({ "url": { "type": "string", "required": true }, "timeout": { "type": "integer", "default": 30 } }); let result = flat_to_json_schema(&flat); assert_eq!(result["type"], "object"); assert_eq!(result["properties"]["url"]["type"], "string"); // `required` should be stripped from individual properties assert!(result["properties"]["url"].get("required").is_none()); assert_eq!(result["properties"]["timeout"]["default"], 30); // Top-level required array should contain "url" let req = result["required"].as_array().unwrap(); assert!(req.contains(&json!("url"))); assert!(!req.contains(&json!("timeout"))); } #[test] fn test_flat_to_json_schema_strips_secret_and_position() { let flat = json!({ "token": { "type": "string", "secret": true, "position": 0, "required": true } }); let result = flat_to_json_schema(&flat); let token = &result["properties"]["token"]; assert!(token.get("secret").is_none()); assert!(token.get("position").is_none()); assert!(token.get("required").is_none()); } #[test] fn test_flat_to_json_schema_empty() { let flat = json!({}); let result = flat_to_json_schema(&flat); assert_eq!(result["type"], "object"); assert!(result.get("required").is_none()); } #[test] fn test_flat_to_json_schema_passthrough_json_schema() { // If already JSON Schema format, pass through unchanged let js = json!({ "type": "object", "properties": { "x": { "type": "string" } }, "required": ["x"] }); let result = flat_to_json_schema(&js); assert_eq!(result, js); } // ── Basic trigger validation (flat format) ────────────────────── #[test] fn test_validate_trigger_params_with_valid_params() { let schema = json!({ "unit": { "type": "string", "enum": ["seconds", "minutes", "hours"], "required": true }, "delta": { "type": "integer", "minimum": 1, "required": true } }); let trigger = make_trigger(Some(schema)); let params = json!({ "unit": "seconds", "delta": 10 }); assert!(validate_trigger_params(&trigger, ¶ms).is_ok()); } #[test] fn test_validate_trigger_params_with_invalid_params() { let schema = json!({ "unit": { "type": "string", "enum": ["seconds", "minutes", "hours"], "required": true }, "delta": { "type": "integer", "minimum": 1, "required": true } }); let trigger = make_trigger(Some(schema)); // Missing required field 'delta' let params = json!({ "unit": "seconds" }); assert!(validate_trigger_params(&trigger, ¶ms).is_err()); // Invalid enum value for 'unit' let params = json!({ "unit": "days", "delta": 10 }); assert!(validate_trigger_params(&trigger, ¶ms).is_err()); // Invalid type for 'delta' let params = json!({ "unit": "seconds", "delta": "10" }); assert!(validate_trigger_params(&trigger, ¶ms).is_err()); } // ── Basic action validation (flat format) ─────────────────────── #[test] fn test_validate_action_params_with_valid_params() { let schema = json!({ "message": { "type": "string", "required": true } }); let action = make_action(Some(schema)); let params = json!({ "message": "Hello, world!" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_validate_action_params_with_empty_params_but_required_fields() { let schema = json!({ "message": { "type": "string", "required": true } }); let action = make_action(Some(schema)); let params = json!({}); assert!(validate_action_params(&action, ¶ms).is_err()); } // ── Template-aware validation (flat format) ────────────────────── #[test] fn test_template_in_integer_field_passes() { let schema = json!({ "counter": { "type": "integer", "required": true } }); let action = make_action(Some(schema)); let params = json!({ "counter": "{{ event.payload.counter }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_template_in_boolean_field_passes() { let schema = json!({ "verbose": { "type": "boolean", "required": true } }); let action = make_action(Some(schema)); let params = json!({ "verbose": "{{ event.payload.debug }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_template_in_number_field_passes() { let schema = json!({ "threshold": { "type": "number", "minimum": 0.0, "required": true } }); let action = make_action(Some(schema)); let params = json!({ "threshold": "{{ event.payload.threshold }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_template_in_enum_field_passes() { let schema = json!({ "level": { "type": "string", "enum": ["info", "warn", "error"], "required": true } }); let action = make_action(Some(schema)); let params = json!({ "level": "{{ event.payload.severity }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_template_in_array_field_passes() { let schema = json!({ "recipients": { "type": "array", "items": { "type": "string" }, "required": true } }); let action = make_action(Some(schema)); let params = json!({ "recipients": "{{ event.payload.emails }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_template_in_object_field_passes() { let schema = json!({ "metadata": { "type": "object", "required": true } }); let action = make_action(Some(schema)); let params = json!({ "metadata": "{{ event.payload.meta }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_mixed_template_and_literal_values() { let schema = json!({ "message": { "type": "string", "required": true }, "count": { "type": "integer", "required": true }, "verbose": { "type": "boolean", "required": true } }); let action = make_action(Some(schema)); // Mix of literal and template values let params = json!({ "message": "Hello", "count": "{{ event.payload.count }}", "verbose": true }); assert!(validate_action_params(&action, ¶ms).is_ok()); } // ── Secret fields are ignored during validation ────────────────── #[test] fn test_secret_field_validated_normally() { let schema = json!({ "api_key": { "type": "string", "required": true, "secret": true }, "endpoint": { "type": "string" } }); let action = make_action(Some(schema)); // Valid: secret field provided let params = json!({ "api_key": "sk-1234", "endpoint": "https://api.example.com" }); assert!(validate_action_params(&action, ¶ms).is_ok()); // Invalid: secret field missing but required let params = json!({ "endpoint": "https://api.example.com" }); assert!(validate_action_params(&action, ¶ms).is_err()); } #[test] fn test_literal_values_still_validated() { let schema = json!({ "type": "object", "properties": { "message": { "type": "string" }, "count": { "type": "integer" } }, "required": ["message", "count"] }); let action = make_action(Some(schema)); // Template for message is fine, but literal "not_a_number" for integer is not let params = json!({ "message": "{{ event.payload.msg }}", "count": "not_a_number" }); assert!(validate_action_params(&action, ¶ms).is_err()); } #[test] fn test_required_field_still_enforced_with_templates() { let schema = json!({ "type": "object", "properties": { "message": { "type": "string" }, "count": { "type": "integer" } }, "required": ["message", "count"] }); let action = make_action(Some(schema)); // Only message provided (even as template), count is missing let params = json!({ "message": "{{ event.payload.msg }}" }); assert!(validate_action_params(&action, ¶ms).is_err()); } #[test] fn test_pack_config_template_passes() { let schema = json!({ "type": "object", "properties": { "api_key": { "type": "string" }, "timeout": { "type": "integer" } }, "required": ["api_key", "timeout"] }); let action = make_action(Some(schema)); let params = json!({ "api_key": "{{ pack.config.api_key }}", "timeout": "{{ pack.config.default_timeout }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_system_template_passes() { let schema = json!({ "type": "object", "properties": { "timestamp": { "type": "string" }, "rule_id": { "type": "integer" } }, "required": ["timestamp", "rule_id"] }); let action = make_action(Some(schema)); let params = json!({ "timestamp": "{{ system.timestamp }}", "rule_id": "{{ system.rule.id }}" }); assert!(validate_action_params(&action, ¶ms).is_ok()); } #[test] fn test_trigger_params_template_aware() { let schema = json!({ "type": "object", "properties": { "unit": { "type": "string", "enum": ["seconds", "minutes", "hours"] }, "delta": { "type": "integer", "minimum": 1 } }, "required": ["unit", "delta"] }); let trigger = make_trigger(Some(schema)); // Both fields as templates let params = json!({ "unit": "{{ pack.config.timer_unit }}", "delta": "{{ pack.config.timer_delta }}" }); assert!(validate_trigger_params(&trigger, ¶ms).is_ok()); } // ── Placeholder generation ─────────────────────────────────────── #[test] fn test_is_template_expression() { assert!(is_template_expression(&json!("{{ event.payload.x }}"))); assert!(is_template_expression(&json!("{{ pack.config.key }}"))); assert!(is_template_expression(&json!( "prefix {{ system.ts }} suffix" ))); assert!(!is_template_expression(&json!("no braces here"))); assert!(!is_template_expression(&json!(42))); assert!(!is_template_expression(&json!(true))); assert!(!is_template_expression(&json!("{ single braces }"))); } #[test] fn test_placeholder_for_schema_types() { assert_eq!( placeholder_for_schema(&json!({"type": "integer"})), json!(0) ); assert_eq!( placeholder_for_schema(&json!({"type": "number"})), json!(0.0) ); assert_eq!( placeholder_for_schema(&json!({"type": "boolean"})), json!(true) ); assert_eq!(placeholder_for_schema(&json!({"type": "array"})), json!([])); assert_eq!( placeholder_for_schema(&json!({"type": "object"})), json!({}) ); assert_eq!( placeholder_for_schema(&json!({"type": "string"})), json!("__template_placeholder__") ); } #[test] fn test_placeholder_respects_enum() { let schema = json!({"type": "string", "enum": ["a", "b", "c"]}); assert_eq!(placeholder_for_schema(&schema), json!("a")); } #[test] fn test_placeholder_respects_default() { let schema = json!({"type": "integer", "default": 42}); assert_eq!(placeholder_for_schema(&schema), json!(42)); } #[test] fn test_placeholder_respects_minimum() { let schema = json!({"type": "integer", "minimum": 5}); assert_eq!(placeholder_for_schema(&schema), json!(5)); } #[test] fn test_nested_object_template_replacement() { let schema = json!({ "type": "object", "properties": { "outer": { "type": "object", "properties": { "inner_count": { "type": "integer" } } } } }); let params = json!({ "outer": { "inner_count": "{{ event.payload.count }}" } }); let sanitized = replace_templates_with_placeholders(¶ms, &schema); // The inner template should be replaced with an integer placeholder assert!(sanitized["outer"]["inner_count"].is_number()); } #[test] fn test_array_element_template_replacement() { let schema = json!({ "type": "object", "properties": { "tags": { "type": "array", "items": { "type": "string" } } } }); let params = json!({ "tags": ["literal", "{{ event.payload.tag }}"] }); let sanitized = replace_templates_with_placeholders(¶ms, &schema); let tags = sanitized["tags"].as_array().unwrap(); assert_eq!(tags[0], "literal"); assert!(tags[1].is_string()); assert_ne!(tags[1], "{{ event.payload.tag }}"); } }