1use crate::apply::{
4 ActionOutcome, Apply, ApplyError, ApplyErrorKind, ApplyOptions, ApplyReport, Operation,
5};
6use crate::common::apply::{compile_path, locate, merge_json, remove_at};
7use crate::v1_0::action::Action;
8use crate::v1_0::info::Info;
9use crate::v1_0::version::Version;
10use crate::validation::{Context, Error, Validate, ValidateWithContext, ValidationOptions};
11use enumset::EnumSet;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::BTreeMap;
15
16#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
20pub struct Overlay {
21 pub overlay: Version,
23
24 pub info: Info,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
30 pub extends: Option<String>,
31
32 pub actions: Vec<Action>,
35
36 #[serde(flatten)]
38 #[serde(with = "crate::common::extensions")]
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub extensions: Option<BTreeMap<String, serde_json::Value>>,
41}
42
43impl Overlay {
44 fn validate_inner(&self, options: EnumSet<ValidationOptions>) -> Result<(), Error> {
45 let mut ctx = Context::new(options);
46
47 ctx.in_field("info", |ctx| self.info.validate_with_context(ctx));
48
49 if self.actions.is_empty() {
50 ctx.error_field("actions", "must contain at least one entry");
51 }
52 for (i, action) in self.actions.iter().enumerate() {
53 ctx.in_index("actions", i, |ctx| action.validate_with_context(ctx));
54 }
55
56 ctx.into_result()
57 }
58}
59
60impl Validate for Overlay {
61 fn validate(&self, options: EnumSet<ValidationOptions>) -> Result<(), Error> {
62 self.validate_inner(options)
63 }
64}
65
66impl Apply for Overlay {
67 fn apply(
68 &self,
69 target: &mut Value,
70 options: EnumSet<ApplyOptions>,
71 ) -> Result<ApplyReport, ApplyError> {
72 let mut working = target.clone();
75 let mut report = ApplyReport::default();
76
77 for (index, action) in self.actions.iter().enumerate() {
78 let outcome = apply_action(index, action, &mut working, options)?;
79 report.actions.push(outcome);
80 }
81
82 *target = working;
83 Ok(report)
84 }
85}
86
87fn apply_action(
88 index: usize,
89 action: &Action,
90 doc: &mut Value,
91 options: EnumSet<ApplyOptions>,
92) -> Result<ActionOutcome, ApplyError> {
93 let err = |kind| ApplyError {
94 action_index: index,
95 target: action.target.clone(),
96 kind,
97 };
98
99 let path =
100 compile_path(&action.target).map_err(|msg| err(ApplyErrorKind::InvalidJsonPath(msg)))?;
101 let pointers = locate(doc, &path);
102
103 let operation = if action.is_remove() {
110 Operation::Remove
111 } else {
112 Operation::Update
113 };
114 let no_effect = !action.is_remove() && action.update.is_none();
115
116 if pointers.is_empty() {
117 if options.contains(ApplyOptions::ErrorOnZeroMatch) {
118 return Err(err(ApplyErrorKind::ZeroMatch));
119 }
120 return Ok(ActionOutcome {
121 index,
122 target: action.target.clone(),
123 operation,
124 matched: 0,
125 });
126 }
127
128 let kinds: Vec<NodeKind> = pointers.iter().map(|p| classify(doc, p)).collect();
132 if kinds.iter().any(|k| matches!(k, NodeKind::Primitive)) {
133 return Err(err(ApplyErrorKind::PrimitiveActionTarget));
134 }
135 if options.contains(ApplyOptions::ErrorOnMixedKindMatch) && !uniform_kinds(&kinds) {
136 return Err(err(ApplyErrorKind::MixedKindMatch));
137 }
138
139 if no_effect {
140 return Ok(ActionOutcome {
143 index,
144 target: action.target.clone(),
145 operation,
146 matched: 0,
147 });
148 }
149
150 if action.is_remove() {
151 let mut removed = 0;
156 for ptr in pointers.iter().rev() {
157 if remove_at(doc, ptr) {
158 removed += 1;
159 }
160 }
161 return Ok(ActionOutcome {
162 index,
163 target: action.target.clone(),
164 operation,
165 matched: removed,
166 });
167 }
168
169 let update = action
174 .update
175 .as_ref()
176 .expect("no_effect path covers the None case");
177 for (ptr, kind) in pointers.iter().zip(kinds.iter()) {
178 if let Some(node) = doc.pointer_mut(ptr) {
179 match kind {
180 NodeKind::Array => {
181 if let Value::Array(arr) = node {
182 arr.push(update.clone());
183 }
184 }
185 NodeKind::Object => merge_json(node, update),
186 NodeKind::Primitive | NodeKind::Missing => {
187 }
192 }
193 }
194 }
195 Ok(ActionOutcome {
196 index,
197 target: action.target.clone(),
198 operation,
199 matched: pointers.len(),
200 })
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204enum NodeKind {
205 Object,
206 Array,
207 Primitive,
208 Missing,
209}
210
211fn classify(doc: &Value, pointer: &str) -> NodeKind {
212 match doc.pointer(pointer) {
213 None => NodeKind::Missing,
214 Some(Value::Object(_)) => NodeKind::Object,
215 Some(Value::Array(_)) => NodeKind::Array,
216 Some(_) => NodeKind::Primitive,
217 }
218}
219
220fn uniform_kinds(kinds: &[NodeKind]) -> bool {
221 let mut iter = kinds
222 .iter()
223 .copied()
224 .filter(|k| !matches!(k, NodeKind::Missing));
225 let Some(first) = iter.next() else {
226 return true;
227 };
228 iter.all(|k| k == first)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use serde_json::json;
235
236 fn parse(s: &str) -> Overlay {
237 serde_json::from_str(s).unwrap()
238 }
239
240 #[test]
241 fn deserialize_minimal_round_trips() {
242 let json = r#"{
243 "overlay": "1.0.0",
244 "info": { "title": "T", "version": "1" },
245 "actions": [ { "target": "$.x", "update": {} } ]
246 }"#;
247 let o = parse(json);
248 assert_eq!(o.overlay, Version::V1_0_0());
249 assert_eq!(o.info.title, "T");
250 assert_eq!(o.actions.len(), 1);
251 assert!(o.extends.is_none());
252 assert!(o.extensions.is_none());
253 }
254
255 #[test]
256 fn deserialize_with_extends_and_extensions() {
257 let json = r#"{
258 "overlay": "1.0.0",
259 "info": { "title": "T", "version": "1" },
260 "extends": "./base.yaml",
261 "actions": [ { "target": "$", "update": {} } ],
262 "x-team": "platform",
263 "skipped": 1
264 }"#;
265 let o = parse(json);
266 assert_eq!(o.extends.as_deref(), Some("./base.yaml"));
267 let ext = o.extensions.as_ref().unwrap();
268 assert!(ext.contains_key("x-team"));
269 assert!(!ext.contains_key("skipped"));
270 }
271
272 #[test]
273 fn serialize_skips_optional_none_fields() {
274 let o = Overlay {
275 overlay: Version::V1_0_0(),
276 info: Info {
277 title: "T".into(),
278 version: "1".into(),
279 ..Default::default()
280 },
281 extends: None,
282 actions: vec![Action {
283 target: "$".into(),
284 ..Default::default()
285 }],
286 extensions: None,
287 };
288 let v = serde_json::to_value(&o).unwrap();
289 assert_eq!(
290 v,
291 json!({
292 "overlay": "1.0.0",
293 "info": { "title": "T", "version": "1" },
294 "actions": [ { "target": "$" } ]
295 }),
296 );
297 }
298
299 #[test]
300 fn deserialize_rejects_non_1_0_overlay_version() {
301 let err = serde_json::from_value::<Overlay>(json!({
302 "overlay": "2.0.0",
303 "info": { "title": "T", "version": "1" },
304 "actions": [ { "target": "$" } ]
305 }))
306 .unwrap_err();
307 let msg = err.to_string();
308 assert!(
309 msg.contains("\"2.0.0\"") && msg.contains("1.0"),
310 "expected error to mention the bad version and the schema, got: {msg}",
311 );
312 }
313
314 #[test]
315 fn validate_rejects_empty_actions_vec() {
316 let o = Overlay {
317 overlay: Version::V1_0_0(),
318 info: Info {
319 title: "T".into(),
320 version: "1".into(),
321 ..Default::default()
322 },
323 actions: vec![],
324 extends: None,
325 extensions: None,
326 };
327 let err = o.validate(EnumSet::empty()).unwrap_err();
328 assert!(
329 err.errors
330 .iter()
331 .any(|e| e == "#.actions: must contain at least one entry")
332 );
333 }
334
335 #[test]
336 fn validate_recurses_into_info_and_actions() {
337 let o = Overlay {
338 overlay: Version::V1_0_0(),
339 info: Info::default(), actions: vec![Action {
341 target: "".into(), ..Default::default()
343 }],
344 extends: None,
345 extensions: None,
346 };
347 let err = o.validate(EnumSet::empty()).unwrap_err();
348 assert!(
349 err.errors
350 .iter()
351 .any(|e| e == "#.info.title: must not be empty")
352 );
353 assert!(
354 err.errors
355 .iter()
356 .any(|e| e == "#.info.version: must not be empty")
357 );
358 assert!(
359 err.errors
360 .iter()
361 .any(|e| e == "#.actions[0].target: must not be empty")
362 );
363 }
364
365 #[test]
366 fn validate_flags_action_with_no_effect() {
367 let o = Overlay {
368 overlay: Version::V1_0_0(),
369 info: Info {
370 title: "T".into(),
371 version: "1".into(),
372 ..Default::default()
373 },
374 actions: vec![Action {
375 target: "$.foo".into(),
376 ..Default::default()
378 }],
379 extends: None,
380 extensions: None,
381 };
382 let err = o.validate(EnumSet::empty()).unwrap_err();
383 assert!(
384 err.errors.iter().any(|e| e.contains("must specify either")),
385 "got: {err}",
386 );
387 }
388
389 fn ovl(actions: Vec<Action>) -> Overlay {
390 Overlay {
391 overlay: Version::V1_0_0(),
392 info: Info {
393 title: "T".into(),
394 version: "1".into(),
395 ..Default::default()
396 },
397 extends: None,
398 actions,
399 extensions: None,
400 }
401 }
402
403 #[test]
404 fn apply_update_merges_into_selected_object() {
405 let o = ovl(vec![Action {
406 target: "$.info".into(),
407 update: Some(json!({ "description": "patched" })),
408 ..Default::default()
409 }]);
410 let mut doc = json!({
411 "openapi": "3.1.0",
412 "info": { "title": "API", "version": "1.0.0" },
413 "paths": {}
414 });
415 let report = o.apply(&mut doc, EnumSet::empty()).unwrap();
416 assert_eq!(report.actions.len(), 1);
417 assert_eq!(report.actions[0].matched, 1);
418 assert_eq!(report.actions[0].operation, Operation::Update);
419 assert_eq!(doc["info"]["description"], "patched");
420 assert_eq!(doc["info"]["title"], "API"); }
422
423 #[test]
424 fn apply_remove_drops_selected_node() {
425 let o = ovl(vec![Action {
426 target: "$.paths['/x']".into(),
427 remove: Some(true),
428 ..Default::default()
429 }]);
430 let mut doc = json!({
431 "paths": { "/x": { "get": {} }, "/y": { "get": {} } }
432 });
433 let report = o.apply(&mut doc, EnumSet::empty()).unwrap();
434 assert_eq!(report.actions[0].operation, Operation::Remove);
435 assert!(!doc["paths"].as_object().unwrap().contains_key("/x"));
436 assert!(doc["paths"].as_object().unwrap().contains_key("/y"));
437 }
438
439 #[test]
440 fn apply_zero_match_default_is_no_op_with_count_zero() {
441 let o = ovl(vec![Action {
442 target: "$.nope".into(),
443 update: Some(json!({})),
444 ..Default::default()
445 }]);
446 let mut doc = json!({ "foo": 1 });
447 let snapshot = doc.clone();
448 let report = o.apply(&mut doc, EnumSet::empty()).unwrap();
449 assert_eq!(report.actions[0].matched, 0);
450 assert_eq!(doc, snapshot);
451 }
452
453 #[test]
454 fn apply_zero_match_strict_errors_and_rolls_back() {
455 let o = ovl(vec![
456 Action {
457 target: "$.foo".into(),
458 update: Some(json!({ "x": 1 })),
459 ..Default::default()
460 },
461 Action {
462 target: "$.nope".into(),
463 update: Some(json!({})),
464 ..Default::default()
465 },
466 ]);
467 let mut doc = json!({ "foo": { "a": 0 } });
468 let snapshot = doc.clone();
469 let err = o
470 .apply(&mut doc, ApplyOptions::ErrorOnZeroMatch.into())
471 .unwrap_err();
472 assert_eq!(err.action_index, 1);
473 assert_eq!(err.kind, ApplyErrorKind::ZeroMatch);
474 assert_eq!(doc, snapshot);
476 }
477
478 #[test]
479 fn apply_invalid_jsonpath_errors_and_does_not_touch_target() {
480 let o = ovl(vec![Action {
481 target: "not a path".into(),
482 update: Some(json!({})),
483 ..Default::default()
484 }]);
485 let mut doc = json!({ "x": 1 });
486 let snapshot = doc.clone();
487 let err = o.apply(&mut doc, EnumSet::empty()).unwrap_err();
488 assert!(matches!(err.kind, ApplyErrorKind::InvalidJsonPath(_)));
489 assert_eq!(doc, snapshot);
490 }
491
492 #[test]
493 fn apply_update_on_primitive_target_errors() {
494 let o = ovl(vec![Action {
495 target: "$.info.title".into(),
496 update: Some(json!({ "ignored": true })),
497 ..Default::default()
498 }]);
499 let mut doc = json!({ "info": { "title": "API" } });
500 let snapshot = doc.clone();
501 let err = o.apply(&mut doc, EnumSet::empty()).unwrap_err();
502 assert_eq!(err.kind, ApplyErrorKind::PrimitiveActionTarget);
503 assert_eq!(doc, snapshot);
504 }
505
506 #[test]
507 fn apply_remove_on_primitive_target_errors() {
508 let o = ovl(vec![Action {
512 target: "$.info.title".into(),
513 remove: Some(true),
514 ..Default::default()
515 }]);
516 let mut doc = json!({ "info": { "title": "API" } });
517 let snapshot = doc.clone();
518 let err = o.apply(&mut doc, EnumSet::empty()).unwrap_err();
519 assert_eq!(err.kind, ApplyErrorKind::PrimitiveActionTarget);
520 assert_eq!(doc, snapshot);
521 }
522
523 #[test]
524 fn apply_update_against_array_target_appends_single_entry() {
525 let o = ovl(vec![Action {
530 target: "$.paths['/pets'].get.parameters".into(),
531 update: Some(json!({ "name": "limit", "in": "query" })),
532 ..Default::default()
533 }]);
534 let mut doc = json!({
535 "paths": {
536 "/pets": {
537 "get": {
538 "parameters": [
539 { "name": "page", "in": "query" }
540 ]
541 }
542 }
543 }
544 });
545 o.apply(&mut doc, EnumSet::empty()).unwrap();
546 assert_eq!(
547 doc["paths"]["/pets"]["get"]["parameters"],
548 json!([
549 { "name": "page", "in": "query" },
550 { "name": "limit", "in": "query" }
551 ]),
552 );
553 }
554
555 #[test]
556 fn apply_update_against_array_target_appends_array_as_single_element() {
557 let o = ovl(vec![Action {
561 target: "$.tags".into(),
562 update: Some(json!(["new-a", "new-b"])),
563 ..Default::default()
564 }]);
565 let mut doc = json!({ "tags": ["existing"] });
566 o.apply(&mut doc, EnumSet::empty()).unwrap();
567 assert_eq!(
568 doc["tags"],
569 json!(["existing", ["new-a", "new-b"]]),
570 "the update array must be appended as a single nested element",
571 );
572 }
573
574 #[test]
575 fn apply_multiple_remove_targets_in_array_preserves_indices() {
576 let o = ovl(vec![Action {
577 target: "$.items[?@.delete == true]".into(),
578 remove: Some(true),
579 ..Default::default()
580 }]);
581 let mut doc = json!({
582 "items": [
583 { "id": 0, "delete": true },
584 { "id": 1 },
585 { "id": 2, "delete": true },
586 { "id": 3 }
587 ]
588 });
589 let report = o.apply(&mut doc, EnumSet::empty()).unwrap();
590 assert_eq!(report.actions[0].matched, 2);
591 assert_eq!(doc, json!({ "items": [ { "id": 1 }, { "id": 3 } ] }),);
592 }
593
594 #[test]
595 fn apply_sequential_actions_compose() {
596 let o = ovl(vec![
597 Action {
598 target: "$.info".into(),
599 update: Some(json!({ "description": "v1" })),
600 ..Default::default()
601 },
602 Action {
603 target: "$.info".into(),
604 update: Some(json!({ "description": "v2" })),
605 ..Default::default()
606 },
607 ]);
608 let mut doc = json!({ "info": { "title": "API" } });
609 o.apply(&mut doc, EnumSet::empty()).unwrap();
610 assert_eq!(doc["info"]["description"], "v2");
611 }
612
613 #[test]
614 fn apply_mixed_kind_strict_errors() {
615 let o = ovl(vec![Action {
616 target: "$.choices[*]".into(),
617 update: Some(json!({ "z": 1 })),
618 ..Default::default()
619 }]);
620 let mut doc = json!({
621 "choices": [ { "a": 1 }, [ 1, 2 ] ]
622 });
623 let snapshot = doc.clone();
624 let err = o
625 .apply(&mut doc, ApplyOptions::ErrorOnMixedKindMatch.into())
626 .unwrap_err();
627 assert_eq!(err.kind, ApplyErrorKind::MixedKindMatch);
628 assert_eq!(doc, snapshot);
629 }
630
631 #[test]
632 fn apply_mixed_kind_lax_treats_each_match_per_its_kind() {
633 let o = ovl(vec![Action {
634 target: "$.choices[*]".into(),
635 update: Some(json!({ "z": 1 })),
636 ..Default::default()
637 }]);
638 let mut doc = json!({
639 "choices": [ { "a": 1 }, [ 1, 2 ] ]
640 });
641 o.apply(&mut doc, EnumSet::empty()).unwrap();
645 assert_eq!(doc["choices"][0], json!({ "a": 1, "z": 1 }));
646 assert_eq!(doc["choices"][1], json!([1, 2, { "z": 1 }]));
647 }
648
649 #[test]
650 fn apply_action_with_no_effect_reports_matched_zero_and_does_not_touch_doc() {
651 let o = ovl(vec![Action {
656 target: "$.foo".into(),
657 ..Default::default()
658 }]);
659 let mut doc = json!({ "foo": { "a": 1 } });
660 let snapshot = doc.clone();
661 let r = o.apply(&mut doc, EnumSet::empty()).unwrap();
662 assert_eq!(r.actions[0].matched, 0);
663 assert_eq!(doc, snapshot);
664 }
665
666 #[test]
667 fn apply_remove_at_root_does_not_count_as_match() {
668 let o = ovl(vec![Action {
672 target: "$".into(),
673 remove: Some(true),
674 ..Default::default()
675 }]);
676 let mut doc = json!({ "foo": 1 });
677 let snapshot = doc.clone();
678 let r = o.apply(&mut doc, EnumSet::empty()).unwrap();
679 assert_eq!(r.actions[0].matched, 0);
680 assert_eq!(doc, snapshot);
681 }
682}