Skip to main content

roas_overlay/v1_0/
overlay.rs

1//! Overlay v1.0 root document.
2
3use 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/// Root Overlay v1.0 document.
17///
18/// See [§3.1 Overlay Object](https://spec.openapis.org/overlay/v1.0.0.html#overlay-object).
19#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
20pub struct Overlay {
21    /// **Required** `1.0.x` per the schema's pattern `^1\.0\.\d+$`.
22    pub overlay: Version,
23
24    /// **Required** Metadata about the overlay.
25    pub info: Info,
26
27    /// URI reference identifying the target document the overlay
28    /// applies to. Absolute or relative per RFC 3986.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub extends: Option<String>,
31
32    /// **Required** Ordered, non-empty list of actions applied
33    /// sequentially.
34    pub actions: Vec<Action>,
35
36    /// `x-`-prefixed Specification Extensions on the root.
37    #[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        // Work on a clone so a mid-pipeline failure leaves `target`
73        // untouched. Commit only on success.
74        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    // Validate ensures every action has either `update` or `remove: true`,
104    // so the only meaningful action shapes here are remove (`is_remove`)
105    // and update (`update.is_some()`). The pre-validate fall-through
106    // (action with neither) is treated as a true no-op at apply time:
107    // we report `matched: 0` so the report doesn't claim work that
108    // didn't happen.
109    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    // Spec §4.4: `target` MUST resolve to objects or arrays for every
129    // action — checked up front so a failure leaves the working copy
130    // untouched.
131    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        // Action with neither `update` nor `remove: true` — see the
141        // comment above. Validation catches this; apply is defensive.
142        return Ok(ActionOutcome {
143            index,
144            target: action.target.clone(),
145            operation,
146            matched: 0,
147        });
148    }
149
150    if action.is_remove() {
151        // Process in reverse to preserve earlier pointer validity
152        // when siblings live in the same array. `remove_at` returns
153        // false for stale or unsupported pointers (e.g. the document
154        // root); count only the removes that actually took effect.
155        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    // Update path. Per spec §4.4: when the matched node is an array,
170    // `update` is "an entry to append" — push it as a single element
171    // regardless of its kind. When the matched node is an object,
172    // recurse per §4.4.3.1.
173    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                    // Primitives were rejected above; Missing happens
188                    // only if a prior action invalidated the pointer
189                    // (impossible here because pointers were just
190                    // located against the current doc).
191                }
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(), // empty title/version
340            actions: vec![Action {
341                target: "".into(), // empty
342                ..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                // No `update`, no `remove: true` — likely a typo.
377                ..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"); // preserved
421    }
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        // First action's mutation must be rolled back.
475        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        // Spec §4.4: target must resolve to objects or arrays for *every*
509        // action, including `remove`. Primitive targets fail before any
510        // mutation lands.
511        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        // Spec §4.4: "If the `target` selects an array, the value of
526        // this field MUST be an entry to append to the array." So the
527        // *object*-typed update becomes one new array element — the
528        // existing array contents are preserved.
529        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        // Even when `update` itself is an array, the spec says it is
558        // "an entry to append" — so the whole array becomes one new
559        // element, *not* element-wise concatenation.
560        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        // Without ErrorOnMixedKindMatch: object recurses (gets the
642        // new key); array appends the update value as a single new
643        // element (spec §4.4).
644        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        // Defensive: validate flags actions with neither `update` nor
652        // `remove: true`, but if one reaches apply (caller skipped
653        // validate), it's a true no-op — `matched: 0` reflects the
654        // (lack of) effect.
655        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        // Removing the document root is undefined — `remove_at("")`
669        // returns false. `matched` reflects what actually changed,
670        // not the JSONPath match count.
671        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}