chore: publish clean community baseline
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user