use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{JsonPath, JsonPathRoot, MappingError}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MappingTargetContext { Unknown, Input, Output, Mixed, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MappingCondition { pub source: String, pub equals: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TransformKind { Identity, ToString, ToNumber, ToBoolean, Join, Split, WrapArray, UnwrapSingleton, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Transform { pub kind: TransformKind, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MappingRule { pub source: String, pub target: String, #[serde(default)] pub required: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_value: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub transform: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub condition: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] pub struct MappingSet { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rules: Vec, } impl MappingSet { pub fn is_empty(&self) -> bool { self.rules.is_empty() } pub fn len(&self) -> usize { self.rules.len() } pub fn target_context(&self) -> MappingTargetContext { let mut detected = MappingTargetContext::Unknown; for rule in &self.rules { let Ok(path) = JsonPath::parse(&rule.target) else { return MappingTargetContext::Mixed; }; let current = match target_root_context(path.root) { Some(context) => context, None => return MappingTargetContext::Mixed, }; detected = match (detected, current) { (MappingTargetContext::Unknown, context) => context, (existing, current) if existing == current => existing, _ => MappingTargetContext::Mixed, }; } detected } pub fn validate_paths(&self) -> Result<(), MappingError> { let context = self.target_context(); if context == MappingTargetContext::Mixed { return Err(MappingError::MixedTargetContext); } for rule in &self.rules { let source = JsonPath::parse(&rule.source)?; let target = JsonPath::parse(&rule.target)?; validate_source_root(source.root, context)?; validate_target_root(target.root, context)?; if let Some(condition) = &rule.condition { let condition_path = JsonPath::parse(&condition.source)?; validate_source_root(condition_path.root, context)?; } } Ok(()) } } fn target_root_context(root: JsonPathRoot) -> Option { match root { JsonPathRoot::RequestPath | JsonPathRoot::RequestQuery | JsonPathRoot::RequestHeaders | JsonPathRoot::RequestBody | JsonPathRoot::RequestVariables | JsonPathRoot::RequestGrpc => Some(MappingTargetContext::Input), JsonPathRoot::Output => Some(MappingTargetContext::Output), _ => None, } } fn validate_source_root( root: JsonPathRoot, context: MappingTargetContext, ) -> Result<(), MappingError> { let valid = match context { MappingTargetContext::Unknown => true, MappingTargetContext::Input => root == JsonPathRoot::Mcp, MappingTargetContext::Output => { matches!( root, JsonPathRoot::ResponseBody | JsonPathRoot::ResponseData | JsonPathRoot::ResponseGrpc ) } MappingTargetContext::Mixed => false, }; if valid { return Ok(()); } Err(MappingError::UnsupportedSourceRoot { root: root.as_str().to_owned(), context: context_name(context), }) } fn validate_target_root( root: JsonPathRoot, context: MappingTargetContext, ) -> Result<(), MappingError> { let valid = match context { MappingTargetContext::Unknown => true, MappingTargetContext::Input => matches!( root, JsonPathRoot::RequestPath | JsonPathRoot::RequestQuery | JsonPathRoot::RequestHeaders | JsonPathRoot::RequestBody | JsonPathRoot::RequestVariables | JsonPathRoot::RequestGrpc ), MappingTargetContext::Output => root == JsonPathRoot::Output, MappingTargetContext::Mixed => false, }; if valid { return Ok(()); } Err(MappingError::UnsupportedTargetRoot { root: root.as_str().to_owned(), context: context_name(context), }) } fn context_name(context: MappingTargetContext) -> &'static str { match context { MappingTargetContext::Unknown => "unknown", MappingTargetContext::Input => "input", MappingTargetContext::Output => "output", MappingTargetContext::Mixed => "mixed", } } #[cfg(test)] mod tests { use serde_json::json; use super::{MappingRule, MappingSet, MappingTargetContext, Transform, TransformKind}; #[test] fn mapping_set_reports_non_empty_rules() { let mapping = MappingSet { rules: vec![MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.body.contact.email".to_owned(), required: true, default_value: None, transform: Some(Transform { kind: TransformKind::Identity, }), condition: None, notes: None, }], }; assert!(!mapping.is_empty()); assert_eq!(mapping.len(), 1); } #[test] fn mapping_rule_serializes_jsonpath_fields() { let mapping = MappingSet { rules: vec![MappingRule { source: "$.response.body.id".to_owned(), target: "$.output.id".to_owned(), required: false, default_value: Some(json!("lead_123")), transform: None, condition: None, notes: Some("map identifier".to_owned()), }], }; let value = serde_json::to_value(mapping).unwrap(); assert_eq!(value["rules"][0]["source"], "$.response.body.id"); assert_eq!(value["rules"][0]["target"], "$.output.id"); assert_eq!(value["rules"][0]["default_value"], "lead_123"); } #[test] fn mapping_set_roundtrips_through_yaml() { let mapping = MappingSet { rules: vec![MappingRule { source: "$.mcp.tags".to_owned(), target: "$.request.body.tags".to_owned(), required: false, default_value: None, transform: Some(Transform { kind: TransformKind::WrapArray, }), condition: None, notes: Some("normalize tags".to_owned()), }], }; let yaml = serde_yaml::to_string(&mapping).unwrap(); let restored: MappingSet = serde_yaml::from_str(&yaml).unwrap(); assert!(yaml.contains("source: $.mcp.tags")); assert_eq!(restored, mapping); } #[test] fn detects_input_context_from_target_roots() { let mapping = MappingSet { rules: vec![MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.body.email".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }; assert_eq!(mapping.target_context(), MappingTargetContext::Input); } #[test] fn rejects_mixed_target_contexts() { let mapping = MappingSet { rules: vec![ MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.body.email".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }, MappingRule { source: "$.response.body.id".to_owned(), target: "$.output.id".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }, ], }; let error = mapping.validate_paths().unwrap_err(); assert_eq!(error, crate::MappingError::MixedTargetContext); } }