chore: rebrand project to crank
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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> {
|
||||
path.read(value)
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user