use crate::common::apply::compile_path;
use crate::validation::{Context, ValidateWithContext};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct Action {
pub target: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub remove: Option<bool>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl Action {
#[must_use]
pub fn is_remove(&self) -> bool {
self.remove == Some(true)
}
}
fn validate_jsonpath(value: &str, ctx: &mut Context, field: &str) {
if value.is_empty() {
ctx.error_field(field, "must not be empty");
} else if let Err(msg) = compile_path(value) {
ctx.error_field(field, format!("invalid JSONPath query: {msg}"));
}
}
impl ValidateWithContext for Action {
fn validate_with_context(&self, ctx: &mut Context) {
validate_jsonpath(&self.target, ctx, "target");
if let Some(copy) = &self.copy {
validate_jsonpath(copy, ctx, "copy");
}
if self.is_remove() && self.update.is_some() {
ctx.error("`remove: true` and `update` are mutually exclusive");
}
if self.is_remove() && self.copy.is_some() {
ctx.error("`remove: true` and `copy` are mutually exclusive");
}
if self.update.is_some() && self.copy.is_some() {
ctx.error("`update` and `copy` are mutually exclusive");
}
if !self.is_remove() && self.update.is_none() && self.copy.is_none() {
ctx.error("action must specify one of `update`, `copy`, or `remove: true`");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use enumset::EnumSet;
use serde_json::json;
fn validate(action: &Action) -> Vec<String> {
let mut ctx = Context::with_path(EnumSet::empty(), "#.actions[0]");
action.validate_with_context(&mut ctx);
ctx.errors.iter().map(|e| e.to_string()).collect()
}
#[test]
fn deserialize_update_action_round_trips() {
let a: Action = serde_json::from_value(json!({
"target": "$.info",
"update": { "description": "patched" }
}))
.unwrap();
assert_eq!(a.target, "$.info");
assert!(a.copy.is_none());
let v = serde_json::to_value(&a).unwrap();
assert_eq!(
v,
json!({ "target": "$.info", "update": { "description": "patched" } })
);
}
#[test]
fn deserialize_copy_action_round_trips() {
let a: Action = serde_json::from_value(json!({
"target": "$.paths['/dst']",
"copy": "$.paths['/src']"
}))
.unwrap();
assert_eq!(a.target, "$.paths['/dst']");
assert_eq!(a.copy.as_deref(), Some("$.paths['/src']"));
assert!(a.update.is_none());
assert!(a.remove.is_none());
let v = serde_json::to_value(&a).unwrap();
assert_eq!(
v,
json!({
"target": "$.paths['/dst']",
"copy": "$.paths['/src']"
}),
);
}
#[test]
fn deserialize_remove_action() {
let a: Action =
serde_json::from_value(json!({ "target": "$.paths['/x']", "remove": true })).unwrap();
assert!(a.is_remove());
assert!(a.update.is_none());
assert!(a.copy.is_none());
}
#[test]
fn deserialize_keeps_x_dash_extensions() {
let a: Action = serde_json::from_value(json!({
"target": "$",
"update": {},
"x-author": "me",
}))
.unwrap();
assert!(a.extensions.as_ref().unwrap().contains_key("x-author"));
}
#[test]
fn validate_empty_target_errors() {
let errs = validate(&Action::default());
assert!(
errs.iter()
.any(|s| s == "#.actions[0].target: must not be empty")
);
}
#[test]
fn validate_invalid_target_jsonpath_errors() {
let a = Action {
target: "not a path".into(),
update: Some(json!({})),
..Default::default()
};
let errs = validate(&a);
assert!(errs.iter().any(|s| s.contains("invalid JSONPath")));
}
#[test]
fn validate_invalid_copy_jsonpath_errors() {
let a = Action {
target: "$.dst".into(),
copy: Some("not a path".into()),
..Default::default()
};
let errs = validate(&a);
assert!(
errs.iter()
.any(|s| s.contains("invalid JSONPath") && s.contains(".copy")),
"expected invalid-copy diagnostic, got: {errs:?}",
);
}
#[test]
fn validate_empty_copy_string_errors() {
let a = Action {
target: "$.dst".into(),
copy: Some(String::new()),
..Default::default()
};
let errs = validate(&a);
assert!(
errs.iter()
.any(|s| s == "#.actions[0].copy: must not be empty"),
"got: {errs:?}",
);
}
#[test]
fn validate_remove_and_update_together_errors() {
let a = Action {
target: "$.foo".into(),
update: Some(json!({})),
remove: Some(true),
..Default::default()
};
let errs = validate(&a);
assert!(
errs.iter()
.any(|s| s.contains("`remove: true` and `update`"))
);
}
#[test]
fn validate_remove_and_copy_together_errors() {
let a = Action {
target: "$.foo".into(),
copy: Some("$.src".into()),
remove: Some(true),
..Default::default()
};
let errs = validate(&a);
assert!(errs.iter().any(|s| s.contains("`remove: true` and `copy`")));
}
#[test]
fn validate_update_and_copy_together_errors() {
let a = Action {
target: "$.foo".into(),
update: Some(json!({})),
copy: Some("$.src".into()),
..Default::default()
};
let errs = validate(&a);
assert!(errs.iter().any(|s| s.contains("`update` and `copy`")));
}
#[test]
fn validate_no_effect_action_errors() {
let a = Action {
target: "$.foo".into(),
..Default::default()
};
let errs = validate(&a);
assert!(
errs.iter()
.any(|s| s.contains("must specify one of `update`, `copy`, or `remove: true`")),
"got: {errs:?}",
);
}
#[test]
fn validate_remove_false_with_update_is_ok() {
let a = Action {
target: "$.foo".into(),
update: Some(json!({})),
remove: Some(false),
..Default::default()
};
assert!(validate(&a).is_empty());
}
#[test]
fn validate_remove_false_with_copy_is_ok() {
let a = Action {
target: "$.foo".into(),
copy: Some("$.src".into()),
remove: Some(false),
..Default::default()
};
assert!(validate(&a).is_empty());
}
#[test]
fn is_remove_only_true_when_remove_set_to_true() {
let a = Action::default();
assert!(!a.is_remove());
let a = Action {
remove: Some(false),
..Default::default()
};
assert!(!a.is_remove());
let a = Action {
remove: Some(true),
..Default::default()
};
assert!(a.is_remove());
}
}