Files
crank/crates/crank-mapping/src/model.rs
T
2026-03-28 00:58:56 +03:00

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);
}
}