diff --git a/TASKS.md b/TASKS.md index a873576..fa10302 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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` diff --git a/crates/mcpaas-mapping/src/error.rs b/crates/mcpaas-mapping/src/error.rs new file mode 100644 index 0000000..22c30c9 --- /dev/null +++ b/crates/mcpaas-mapping/src/error.rs @@ -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, + }, +} diff --git a/crates/mcpaas-mapping/src/execute.rs b/crates/mcpaas-mapping/src/execute.rs new file mode 100644 index 0000000..0a91aaf --- /dev/null +++ b/crates/mcpaas-mapping/src/execute.rs @@ -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 { + 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::>(); + 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 { + 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 { + let number = match value { + Value::Number(number) => return Ok(Value::Number(number)), + Value::String(text) => { + if let Ok(integer) = text.parse::() { + Number::from(integer) + } else { + Number::from_f64(text.parse::().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 { + 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 { + 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::>() + .join(","), + )) +} + +fn split_value(value: Value) -> Result { + 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 { + 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"); + } +} diff --git a/crates/mcpaas-mapping/src/infer.rs b/crates/mcpaas-mapping/src/infer.rs new file mode 100644 index 0000000..557da5b --- /dev/null +++ b/crates/mcpaas-mapping/src/infer.rs @@ -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 { + 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, + collected: &mut BTreeMap>, +) { + 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"); + } +} diff --git a/crates/mcpaas-mapping/src/jsonpath.rs b/crates/mcpaas-mapping/src/jsonpath.rs new file mode 100644 index 0000000..91a40f4 --- /dev/null +++ b/crates/mcpaas-mapping/src/jsonpath.rs @@ -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, +} + +impl JsonPath { + pub fn parse(path: &str) -> Result { + 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, 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::() + .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(), + } + ); + } +} diff --git a/crates/mcpaas-mapping/src/lib.rs b/crates/mcpaas-mapping/src/lib.rs index 893031e..5646e1e 100644 --- a/crates/mcpaas-mapping/src/lib.rs +++ b/crates/mcpaas-mapping/src/lib.rs @@ -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, - #[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() - } -} - -#[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, +}; diff --git a/crates/mcpaas-mapping/src/model.rs b/crates/mcpaas-mapping/src/model.rs new file mode 100644 index 0000000..ca73a96 --- /dev/null +++ b/crates/mcpaas-mapping/src/model.rs @@ -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, + #[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); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 1cb14f1..c8c2d5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -268,6 +268,12 @@ gRPC target: Черновой mapping может генерироваться автоматически на основе загруженных примеров данных, но итоговая конфигурация всегда остается явной и редактируемой оператором. +Для MVP допустима простая стратегия draft generation: + +- искать уникальные leaf-поля с одинаковыми именами; +- предлагать только однозначные соответствия; +- не пытаться автоматически разрешать конфликты и неоднозначности. + Каноническая логическая модель остается общей для runtime и БД, но система должна уметь сериализовать и десериализовать ее также в `YAML`. ## 9. Основные компоненты diff --git a/docs/data-model.md b/docs/data-model.md index 9dff2e7..d9a805d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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.