feat: implement mapping engine

This commit is contained in:
a.tolmachev
2026-03-25 16:16:59 +03:00
parent ef128ac25a
commit 87639048ce
9 changed files with 1083 additions and 143 deletions
+5 -15
View File
@@ -2,22 +2,9 @@
## Current
### `feat/schema-engine`
Status: completed
DoD:
- schema model exists
- schema validation works
- JSON sample normalization works
- protobuf -> schema bridge contracts exist
## Next
### `feat/mapping-engine`
Status: pending
Status: completed
DoD:
@@ -25,9 +12,12 @@ DoD:
- input and output mapping work
- draft generation from samples works
## Backlog
## Next
- `feat/registry-storage`
## Backlog
- `feat/rest-vertical-slice`
- `feat/admin-api-v1`
- `feat/ui-v1`
+20
View File
@@ -0,0 +1,20 @@
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq, Error)]
pub enum MappingError {
#[error("invalid JSONPath: {path}")]
InvalidJsonPath { path: String },
#[error("unsupported source root {root} for {context} mapping")]
UnsupportedSourceRoot { root: String, context: &'static str },
#[error("unsupported target root {root} for {context} mapping")]
UnsupportedTargetRoot { root: String, context: &'static str },
#[error("mixed mapping target contexts are not allowed")]
MixedTargetContext,
#[error("missing required value at {path}")]
MissingRequiredValue { path: String },
#[error("transform {transform} cannot be applied to {actual}")]
InvalidTransformInput {
transform: &'static str,
actual: &'static str,
},
}
+385
View File
@@ -0,0 +1,385 @@
use serde_json::{Map, Number, Value};
use crate::{
JsonPath, JsonPathSegment, MappingError, MappingSet, MappingTargetContext, TransformKind,
};
impl MappingSet {
pub fn apply(&self, source: &Value) -> Result<Value, MappingError> {
self.validate_paths()?;
let mut target = empty_target_context(self.target_context());
for rule in &self.rules {
if let Some(condition) = &rule.condition {
let condition_path = JsonPath::parse(&condition.source)?;
let condition_value = read_path(source, &condition_path);
if condition_value != Some(&condition.equals) {
continue;
}
}
let source_path = JsonPath::parse(&rule.source)?;
let target_path = JsonPath::parse(&rule.target)?;
let Some(source_value) = read_path(source, &source_path)
.cloned()
.or(rule.default_value.clone())
else {
if rule.required {
return Err(MappingError::MissingRequiredValue {
path: source_path.to_string_path(),
});
}
continue;
};
let value = apply_transform(
source_value,
rule.transform.as_ref().map(|value| &value.kind),
)?;
write_path(&mut target, &target_path, value)?;
}
Ok(target)
}
}
fn empty_target_context(context: MappingTargetContext) -> Value {
match context {
MappingTargetContext::Input => Value::Object(Map::from_iter([(
"request".to_owned(),
Value::Object(Map::from_iter([
("path".to_owned(), Value::Object(Map::new())),
("query".to_owned(), Value::Object(Map::new())),
("headers".to_owned(), Value::Object(Map::new())),
("body".to_owned(), Value::Object(Map::new())),
("variables".to_owned(), Value::Object(Map::new())),
("grpc".to_owned(), Value::Object(Map::new())),
])),
)])),
MappingTargetContext::Output => Value::Object(Map::from_iter([(
"output".to_owned(),
Value::Object(Map::new()),
)])),
_ => Value::Object(Map::new()),
}
}
fn read_path<'a>(value: &'a Value, path: &JsonPath) -> Option<&'a Value> {
let mut current = value;
for field in path.root.root_fields() {
current = current.get(*field)?;
}
for segment in &path.segments {
current = match segment {
JsonPathSegment::Field(field) => current.get(field)?,
JsonPathSegment::Index(index) => current.get(*index)?,
};
}
Some(current)
}
fn write_path(target: &mut Value, path: &JsonPath, value: Value) -> Result<(), MappingError> {
let mut segments = path
.root
.root_fields()
.iter()
.map(|field| JsonPathSegment::Field((*field).to_owned()))
.collect::<Vec<_>>();
segments.extend(path.segments.clone());
write_segments(target, &segments, value)
}
fn write_segments(
target: &mut Value,
segments: &[JsonPathSegment],
value: Value,
) -> Result<(), MappingError> {
if segments.is_empty() {
*target = value;
return Ok(());
}
match &segments[0] {
JsonPathSegment::Field(field) => {
if !target.is_object() {
*target = Value::Object(Map::new());
}
let Some(object) = target.as_object_mut() else {
return Err(MappingError::InvalidJsonPath {
path: field.clone(),
});
};
let entry = object.entry(field.clone()).or_insert(Value::Null);
write_segments(entry, &segments[1..], value)
}
JsonPathSegment::Index(index) => {
if !target.is_array() {
*target = Value::Array(Vec::new());
}
let Some(array) = target.as_array_mut() else {
return Err(MappingError::InvalidJsonPath {
path: index.to_string(),
});
};
while array.len() <= *index {
array.push(Value::Null);
}
write_segments(&mut array[*index], &segments[1..], value)
}
}
}
fn apply_transform(value: Value, transform: Option<&TransformKind>) -> Result<Value, MappingError> {
let Some(transform) = transform else {
return Ok(value);
};
match transform {
TransformKind::Identity => Ok(value),
TransformKind::ToString => Ok(Value::String(to_string_value(&value))),
TransformKind::ToNumber => to_number(value),
TransformKind::ToBoolean => to_boolean(value),
TransformKind::Join => join_values(value),
TransformKind::Split => split_value(value),
TransformKind::WrapArray => Ok(match value {
Value::Array(values) => Value::Array(values),
other => Value::Array(vec![other]),
}),
TransformKind::UnwrapSingleton => unwrap_singleton(value),
}
}
fn to_string_value(value: &Value) -> String {
match value {
Value::Null => "null".to_owned(),
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => number.to_string(),
Value::String(text) => text.clone(),
Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_default(),
}
}
fn to_number(value: Value) -> Result<Value, MappingError> {
let number = match value {
Value::Number(number) => return Ok(Value::Number(number)),
Value::String(text) => {
if let Ok(integer) = text.parse::<i64>() {
Number::from(integer)
} else {
Number::from_f64(text.parse::<f64>().map_err(|_| {
MappingError::InvalidTransformInput {
transform: "to_number",
actual: "string",
}
})?)
.ok_or(MappingError::InvalidTransformInput {
transform: "to_number",
actual: "string",
})?
}
}
Value::Bool(boolean) => Number::from(if boolean { 1 } else { 0 }),
other => {
return Err(MappingError::InvalidTransformInput {
transform: "to_number",
actual: type_name(&other),
});
}
};
Ok(Value::Number(number))
}
fn to_boolean(value: Value) -> Result<Value, MappingError> {
let boolean = match value {
Value::Bool(boolean) => boolean,
Value::Number(number) => {
number.as_i64().unwrap_or_default() != 0 || number.as_f64().unwrap_or_default() != 0.0
}
Value::String(text) => match text.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
_ => {
return Err(MappingError::InvalidTransformInput {
transform: "to_boolean",
actual: "string",
});
}
},
other => {
return Err(MappingError::InvalidTransformInput {
transform: "to_boolean",
actual: type_name(&other),
});
}
};
Ok(Value::Bool(boolean))
}
fn join_values(value: Value) -> Result<Value, MappingError> {
let Value::Array(values) = value else {
return Err(MappingError::InvalidTransformInput {
transform: "join",
actual: type_name(&value),
});
};
Ok(Value::String(
values
.iter()
.map(to_string_value)
.collect::<Vec<_>>()
.join(","),
))
}
fn split_value(value: Value) -> Result<Value, MappingError> {
let Value::String(text) = value else {
return Err(MappingError::InvalidTransformInput {
transform: "split",
actual: type_name(&value),
});
};
Ok(Value::Array(
text.split(',')
.map(|part| Value::String(part.trim().to_owned()))
.collect(),
))
}
fn unwrap_singleton(value: Value) -> Result<Value, MappingError> {
match value {
Value::Array(mut values) if values.len() == 1 => Ok(values.remove(0)),
Value::Array(_) => Err(MappingError::InvalidTransformInput {
transform: "unwrap_singleton",
actual: "array",
}),
other => Err(MappingError::InvalidTransformInput {
transform: "unwrap_singleton",
actual: type_name(&other),
}),
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::{MappingRule, MappingSet, Transform, TransformKind};
#[test]
fn applies_input_mapping_into_request_context() {
let mapping = MappingSet {
rules: vec![
MappingRule {
source: "$.mcp.email".to_owned(),
target: "$.request.body.contact.email".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
},
MappingRule {
source: "$.mcp.tags".to_owned(),
target: "$.request.query.tags".to_owned(),
required: false,
default_value: None,
transform: Some(Transform {
kind: TransformKind::Join,
}),
condition: None,
notes: None,
},
],
};
let result = mapping
.apply(&json!({
"mcp": {
"email": "user@example.com",
"tags": ["warm", "priority"]
}
}))
.unwrap();
assert_eq!(
result["request"]["body"]["contact"]["email"],
"user@example.com"
);
assert_eq!(result["request"]["query"]["tags"], "warm,priority");
}
#[test]
fn applies_output_mapping_into_output_context() {
let mapping = MappingSet {
rules: vec![MappingRule {
source: "$.response.body.lead_id".to_owned(),
target: "$.output.id".to_owned(),
required: true,
default_value: None,
transform: Some(Transform {
kind: TransformKind::ToString,
}),
condition: None,
notes: None,
}],
};
let result = mapping
.apply(&json!({
"response": {
"body": {
"lead_id": 42
}
}
}))
.unwrap();
assert_eq!(result["output"]["id"], "42");
}
#[test]
fn uses_default_value_for_missing_optional_source() {
let mapping = MappingSet {
rules: vec![MappingRule {
source: "$.mcp.region".to_owned(),
target: "$.request.headers.X-Region".to_owned(),
required: false,
default_value: Some(json!("eu-central")),
transform: None,
condition: None,
notes: None,
}],
};
let result = mapping.apply(&json!({ "mcp": {} })).unwrap();
assert_eq!(result["request"]["headers"]["X-Region"], "eu-central");
}
}
+116
View File
@@ -0,0 +1,116 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::{JsonPathRoot, MappingRule, MappingSet};
pub fn infer_mapping_from_samples(
source_sample: &Value,
source_root: JsonPathRoot,
target_sample: &Value,
target_root: JsonPathRoot,
) -> MappingSet {
let source_paths = collect_leaf_paths(source_sample, source_root);
let target_paths = collect_leaf_paths(target_sample, target_root);
let mut rules = Vec::new();
for (leaf_name, source_path) in source_paths {
let Some(target_path) = target_paths.get(&leaf_name) else {
continue;
};
rules.push(MappingRule {
source: source_path,
target: target_path.clone(),
required: false,
default_value: None,
transform: None,
condition: None,
notes: None,
});
}
MappingSet { rules }
}
fn collect_leaf_paths(sample: &Value, root: JsonPathRoot) -> BTreeMap<String, String> {
let mut collected = BTreeMap::new();
let mut segments = Vec::new();
collect_leaf_paths_recursive(sample, &mut segments, &mut collected);
collected
.into_iter()
.map(|(leaf, path_segments)| {
let mut path = format!("$.{}", root.as_str());
for segment in path_segments {
path.push('.');
path.push_str(&segment);
}
(leaf, path)
})
.collect()
}
fn collect_leaf_paths_recursive(
sample: &Value,
segments: &mut Vec<String>,
collected: &mut BTreeMap<String, Vec<String>>,
) {
match sample {
Value::Object(object) => {
for (field, value) in object {
segments.push(field.clone());
collect_leaf_paths_recursive(value, segments, collected);
segments.pop();
}
}
Value::Array(values) => {
if let Some(first) = values.first() {
collect_leaf_paths_recursive(first, segments, collected);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
if let Some(leaf) = segments.last() {
collected
.entry(leaf.clone())
.or_insert_with(|| segments.clone());
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::{JsonPathRoot, infer_mapping_from_samples};
#[test]
fn infers_rules_from_matching_leaf_names() {
let mapping = infer_mapping_from_samples(
&json!({
"lead": {
"email": "user@example.com",
"name": "Ada"
}
}),
JsonPathRoot::Mcp,
&json!({
"contact": {
"email": "user@example.com"
},
"name": "Ada"
}),
JsonPathRoot::RequestBody,
);
assert_eq!(mapping.rules.len(), 2);
assert_eq!(mapping.rules[0].source, "$.mcp.lead.email");
assert_eq!(mapping.rules[0].target, "$.request.body.contact.email");
assert_eq!(mapping.rules[1].source, "$.mcp.lead.name");
assert_eq!(mapping.rules[1].target, "$.request.body.name");
}
}
+214
View File
@@ -0,0 +1,214 @@
use crate::MappingError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JsonPathRoot {
Mcp,
RequestPath,
RequestQuery,
RequestHeaders,
RequestBody,
RequestVariables,
RequestGrpc,
ResponseBody,
ResponseData,
ResponseGrpc,
Output,
}
impl JsonPathRoot {
pub fn as_str(&self) -> &'static str {
match self {
Self::Mcp => "mcp",
Self::RequestPath => "request.path",
Self::RequestQuery => "request.query",
Self::RequestHeaders => "request.headers",
Self::RequestBody => "request.body",
Self::RequestVariables => "request.variables",
Self::RequestGrpc => "request.grpc",
Self::ResponseBody => "response.body",
Self::ResponseData => "response.data",
Self::ResponseGrpc => "response.grpc",
Self::Output => "output",
}
}
pub fn root_fields(&self) -> &'static [&'static str] {
match self {
Self::Mcp => &["mcp"],
Self::RequestPath => &["request", "path"],
Self::RequestQuery => &["request", "query"],
Self::RequestHeaders => &["request", "headers"],
Self::RequestBody => &["request", "body"],
Self::RequestVariables => &["request", "variables"],
Self::RequestGrpc => &["request", "grpc"],
Self::ResponseBody => &["response", "body"],
Self::ResponseData => &["response", "data"],
Self::ResponseGrpc => &["response", "grpc"],
Self::Output => &["output"],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JsonPathSegment {
Field(String),
Index(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JsonPath {
pub root: JsonPathRoot,
pub segments: Vec<JsonPathSegment>,
}
impl JsonPath {
pub fn parse(path: &str) -> Result<Self, MappingError> {
let body = path
.strip_prefix("$.")
.ok_or_else(|| MappingError::InvalidJsonPath {
path: path.to_owned(),
})?;
let (root, rest) = match_root(body).ok_or_else(|| MappingError::InvalidJsonPath {
path: path.to_owned(),
})?;
if !(rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')) {
return Err(MappingError::InvalidJsonPath {
path: path.to_owned(),
});
}
let segments = parse_segments(rest, path)?;
Ok(Self { root, segments })
}
pub fn to_string_path(&self) -> String {
let mut path = format!("$.{}", self.root.as_str());
for segment in &self.segments {
match segment {
JsonPathSegment::Field(field) => {
path.push('.');
path.push_str(field);
}
JsonPathSegment::Index(index) => {
path.push('[');
path.push_str(&index.to_string());
path.push(']');
}
}
}
path
}
}
fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> {
const ROOTS: [(&str, JsonPathRoot); 11] = [
("request.variables", JsonPathRoot::RequestVariables),
("request.headers", JsonPathRoot::RequestHeaders),
("response.body", JsonPathRoot::ResponseBody),
("response.data", JsonPathRoot::ResponseData),
("response.grpc", JsonPathRoot::ResponseGrpc),
("request.path", JsonPathRoot::RequestPath),
("request.query", JsonPathRoot::RequestQuery),
("request.body", JsonPathRoot::RequestBody),
("request.grpc", JsonPathRoot::RequestGrpc),
("output", JsonPathRoot::Output),
("mcp", JsonPathRoot::Mcp),
];
for (prefix, root) in ROOTS {
if let Some(rest) = input.strip_prefix(prefix) {
return Some((root, rest));
}
}
None
}
fn parse_segments(mut input: &str, original: &str) -> Result<Vec<JsonPathSegment>, MappingError> {
let mut segments = Vec::new();
while !input.is_empty() {
if let Some(rest) = input.strip_prefix('.') {
let end = rest.find(['.', '[']).unwrap_or(rest.len());
let field = &rest[..end];
if field.is_empty() || !is_valid_field(field) {
return Err(MappingError::InvalidJsonPath {
path: original.to_owned(),
});
}
segments.push(JsonPathSegment::Field(field.to_owned()));
input = &rest[end..];
continue;
}
if let Some(rest) = input.strip_prefix('[') {
let Some(end) = rest.find(']') else {
return Err(MappingError::InvalidJsonPath {
path: original.to_owned(),
});
};
let index =
rest[..end]
.parse::<usize>()
.map_err(|_| MappingError::InvalidJsonPath {
path: original.to_owned(),
})?;
segments.push(JsonPathSegment::Index(index));
input = &rest[end + 1..];
continue;
}
return Err(MappingError::InvalidJsonPath {
path: original.to_owned(),
});
}
Ok(segments)
}
fn is_valid_field(value: &str) -> bool {
value
.chars()
.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-')
}
#[cfg(test)]
mod tests {
use super::{JsonPath, JsonPathRoot, JsonPathSegment};
#[test]
fn parses_nested_jsonpath_with_array_index() {
let path = JsonPath::parse("$.request.body.contacts[0].email").unwrap();
assert_eq!(path.root, JsonPathRoot::RequestBody);
assert_eq!(
path.segments,
vec![
JsonPathSegment::Field("contacts".to_owned()),
JsonPathSegment::Index(0),
JsonPathSegment::Field("email".to_owned())
]
);
}
#[test]
fn rejects_unknown_root() {
let error = JsonPath::parse("$.unknown.field").unwrap_err();
assert_eq!(
error,
crate::MappingError::InvalidJsonPath {
path: "$.unknown.field".to_owned(),
}
);
}
}
+11 -128
View File
@@ -1,129 +1,12 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
mod error;
mod execute;
mod infer;
mod jsonpath;
mod model;
#[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()
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{MappingRule, MappingSet, 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);
}
}
pub use error::MappingError;
pub use infer::infer_mapping_from_samples;
pub use jsonpath::{JsonPath, JsonPathRoot, JsonPathSegment};
pub use model::{
MappingCondition, MappingRule, MappingSet, MappingTargetContext, Transform, TransformKind,
};
+311
View File
@@ -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);
}
}
+6
View File
@@ -268,6 +268,12 @@ gRPC target:
Черновой mapping может генерироваться автоматически на основе загруженных примеров данных, но итоговая конфигурация всегда остается явной и редактируемой оператором.
Для MVP допустима простая стратегия draft generation:
- искать уникальные leaf-поля с одинаковыми именами;
- предлагать только однозначные соответствия;
- не пытаться автоматически разрешать конфликты и неоднозначности.
Каноническая логическая модель остается общей для runtime и БД, но система должна уметь сериализовать и десериализовать ее также в `YAML`.
## 9. Основные компоненты
+15
View File
@@ -383,6 +383,13 @@
- `$.response.grpc.*`
- `$.output.*`
Для MVP достаточно управляемого подмножества `JSONPath`:
- путь всегда начинается с фиксированного root context;
- дальше используются dot-separated поля;
- для массивов допускаются numeric indexes вида `[0]`;
- quoted selectors, filter expressions и произвольные функции не поддерживаются.
### `Transform`
Для MVP transformations должны быть ограничены:
@@ -404,6 +411,14 @@
}
```
### Черновая генерация mapping
Для MVP generation draft mapping может опираться на простое правило:
- система сопоставляет уникальные leaf-поля с одинаковыми именами в source sample и target sample;
- неоднозначные совпадения автоматически не связываются;
- результат всегда остается черновиком и требует ручной проверки оператором.
## 7. `ExecutionConfig`
`ExecutionConfig` задает параметры выполнения operation.