Skip to main content

roas_overlay/
apply.rs

1//! Public surface for applying an Overlay to a target JSON document.
2//!
3//! See [`Apply::apply`] for the entry point. The trait is implemented
4//! for each per-version `Overlay` type ([`crate::v1_0::Overlay`] today;
5//! `v1_1::Overlay` once that feature lands).
6
7use enumset::{EnumSet, EnumSetType};
8use std::fmt::{self, Display};
9
10/// Apply an overlay document to a target JSON value in place.
11///
12/// On error the target is left untouched: implementors are expected
13/// to operate on a clone and commit only on success.
14pub trait Apply {
15    fn apply(
16        &self,
17        target: &mut serde_json::Value,
18        options: EnumSet<ApplyOptions>,
19    ) -> Result<ApplyReport, ApplyError>;
20}
21
22/// Per-call apply toggles.
23#[derive(EnumSetType, Debug)]
24pub enum ApplyOptions {
25    /// Treat a zero-match `target` JSONPath as an error rather than a
26    /// no-op. Default behavior (option absent) follows
27    /// [§4.4](https://spec.openapis.org/overlay/v1.0.0.html#action-object):
28    /// "the action succeeds without changing the target document".
29    ErrorOnZeroMatch,
30    /// Reject `update` actions whose `target` selects nodes of mixed
31    /// kind (some objects, some arrays). The v1.1 spec calls this out
32    /// normatively; v1.0 doesn't, so this option lets v1.0 callers
33    /// opt into the stricter check.
34    ErrorOnMixedKindMatch,
35}
36
37#[cfg(feature = "clap")]
38impl clap::ValueEnum for ApplyOptions {
39    fn value_variants<'a>() -> &'a [Self] {
40        &[
41            ApplyOptions::ErrorOnZeroMatch,
42            ApplyOptions::ErrorOnMixedKindMatch,
43        ]
44    }
45
46    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
47        let (name, help) = match self {
48            ApplyOptions::ErrorOnZeroMatch => (
49                "error-on-zero-match",
50                "Fail when an action's `target` selects zero nodes",
51            ),
52            ApplyOptions::ErrorOnMixedKindMatch => (
53                "error-on-mixed-kind-match",
54                "Fail when `update` selects a mix of objects and arrays",
55            ),
56        };
57        Some(clap::builder::PossibleValue::new(name).help(help))
58    }
59}
60
61/// One entry per applied action, in declaration order.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct ActionOutcome {
65    pub index: usize,
66    pub target: String,
67    pub operation: Operation,
68    pub matched: usize,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum Operation {
74    Update,
75    Remove,
76    /// Overlay v1.1 only: source node located via the action's `copy`
77    /// JSONPath was merged into each matched `target` node.
78    Copy,
79}
80
81impl Display for Operation {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(match self {
84            Operation::Update => "update",
85            Operation::Remove => "remove",
86            Operation::Copy => "copy",
87        })
88    }
89}
90
91/// Report returned by a successful [`Apply::apply`] call.
92#[derive(Debug, Clone, PartialEq, Eq, Default)]
93#[non_exhaustive]
94pub struct ApplyReport {
95    pub actions: Vec<ActionOutcome>,
96}
97
98/// Failure returned by [`Apply::apply`]. The `target` document is
99/// guaranteed untouched on error.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub struct ApplyError {
103    pub action_index: usize,
104    pub target: String,
105    pub kind: ApplyErrorKind,
106}
107
108impl Display for ApplyError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(
111            f,
112            "actions[{}] (target {:?}): {}",
113            self.action_index, self.target, self.kind
114        )
115    }
116}
117
118impl std::error::Error for ApplyError {}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121#[non_exhaustive]
122pub enum ApplyErrorKind {
123    /// `target` is not a syntactically valid RFC 9535 JSONPath query.
124    InvalidJsonPath(String),
125    /// No node matched and [`ApplyOptions::ErrorOnZeroMatch`] is set.
126    ZeroMatch,
127    /// Target matched nodes of mixed kinds and
128    /// [`ApplyOptions::ErrorOnMixedKindMatch`] is set.
129    MixedKindMatch,
130    /// `target` resolves to a primitive or `null`. The spec
131    /// [§4.4](https://spec.openapis.org/overlay/v1.0.0.html#action-object)
132    /// requires action targets to be objects or arrays, for both
133    /// `update` and `remove` actions.
134    PrimitiveActionTarget,
135    /// Overlay v1.1 only: the action's `copy` JSONPath is
136    /// syntactically valid but matched no node in the working doc.
137    CopySourceNotFound(String),
138    /// Overlay v1.1 only: the action's `copy` JSONPath matched more
139    /// than one node; the spec requires exactly one source.
140    CopySourceMultiple(String),
141    /// Overlay v1.1 only: the action set both `update` and `copy`,
142    /// which the spec treats as mutually exclusive. Validation flags
143    /// this; apply fails fast rather than silently dropping one.
144    ConflictingMergeSources,
145}
146
147impl Display for ApplyErrorKind {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            ApplyErrorKind::InvalidJsonPath(msg) => write!(f, "invalid JSONPath: {msg}"),
151            ApplyErrorKind::ZeroMatch => {
152                f.write_str("target matched zero nodes (error-on-zero-match)")
153            }
154            ApplyErrorKind::MixedKindMatch => f.write_str(
155                "target matched nodes of mixed kind (objects and arrays) — \
156                 error-on-mixed-kind-match",
157            ),
158            ApplyErrorKind::PrimitiveActionTarget => f.write_str(
159                "action `target` must resolve to objects or arrays, \
160                 not primitives or null",
161            ),
162            ApplyErrorKind::CopySourceNotFound(s) => {
163                write!(f, "`copy` source {s:?} matched no node")
164            }
165            ApplyErrorKind::CopySourceMultiple(s) => write!(
166                f,
167                "`copy` source {s:?} matched multiple nodes; exactly one is required",
168            ),
169            ApplyErrorKind::ConflictingMergeSources => {
170                f.write_str("action sets both `update` and `copy`; they are mutually exclusive")
171            }
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn operation_display_uses_lowercase_words() {
182        assert_eq!(Operation::Update.to_string(), "update");
183        assert_eq!(Operation::Remove.to_string(), "remove");
184        assert_eq!(Operation::Copy.to_string(), "copy");
185    }
186
187    #[test]
188    fn apply_error_display_includes_index_target_and_reason() {
189        let e = ApplyError {
190            action_index: 2,
191            target: "$.foo".into(),
192            kind: ApplyErrorKind::ZeroMatch,
193        };
194        let s = e.to_string();
195        assert!(s.contains("actions[2]"));
196        assert!(s.contains("$.foo"));
197        assert!(s.contains("zero nodes"));
198    }
199
200    #[test]
201    fn apply_error_kind_display_covers_every_variant() {
202        let cases = [
203            ApplyErrorKind::InvalidJsonPath("bad path".into()),
204            ApplyErrorKind::ZeroMatch,
205            ApplyErrorKind::MixedKindMatch,
206            ApplyErrorKind::PrimitiveActionTarget,
207            ApplyErrorKind::CopySourceNotFound("$.src".into()),
208            ApplyErrorKind::CopySourceMultiple("$.src".into()),
209            ApplyErrorKind::ConflictingMergeSources,
210        ];
211        for k in cases {
212            assert!(
213                !k.to_string().is_empty(),
214                "Display impl for {k:?} produced empty string",
215            );
216        }
217    }
218}
219
220#[cfg(all(test, feature = "clap"))]
221mod clap_tests {
222    use super::*;
223    use clap::ValueEnum;
224
225    #[test]
226    fn apply_options_value_enum_round_trips_through_kebab_case() {
227        for v in <ApplyOptions as ValueEnum>::value_variants() {
228            let pv = v.to_possible_value().expect("possible value");
229            let name = pv.get_name();
230            let parsed = <ApplyOptions as ValueEnum>::from_str(name, false).expect("parses");
231            assert_eq!(parsed, *v);
232            assert!(
233                name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
234                "name `{name}` must be kebab-case",
235            );
236        }
237    }
238}