312 lines
9.1 KiB
Rust
312 lines
9.1 KiB
Rust
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<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub transform: Option<Transform>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub condition: Option<MappingCondition>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
|
pub struct MappingSet {
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub rules: Vec<MappingRule>,
|
|
}
|
|
|
|
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<MappingTargetContext> {
|
|
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);
|
|
}
|
|
}
|