Skip to main content

ag_ui/
patch.rs

1//! JSON Patch operations, as defined by [RFC 6902].
2//!
3//! `STATE_DELTA` and `ACTIVITY_DELTA` carry a patch document: an array of these
4//! operations, applied in order to the previous snapshot.
5//!
6//! [RFC 6902]: https://datatracker.ietf.org/doc/html/rfc6902
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// A JSON Patch document: an ordered list of operations.
12pub type JsonPatch = Vec<PatchOperation>;
13
14/// A single [RFC 6902] operation.
15///
16/// The serde representation is the RFC wire format exactly: an object with an
17/// `op` discriminator, a `path` JSON Pointer, and — depending on the operation
18/// — a `value` or a `from` pointer.
19///
20/// # Why `value` has a default
21///
22/// Upstream types both patch fields as `z.array(z.any())` / `List[Any]` and
23/// validates nothing, so a producer that drops `value` still parses there. The
24/// case is not hypothetical: `JSON.stringify({op: "add", path: "/x", value:
25/// undefined})` yields `{"op":"add","path":"/x"}`, which is what a JavaScript
26/// producer emits whenever the new state holds `undefined` at that key. Making
27/// `value` required would turn that into a deserialization failure for the whole
28/// `STATE_DELTA` event — and in an SSE stream a failed event is usually a failed
29/// run. An omitted `value` therefore reads as JSON `null`, which is how the
30/// JavaScript patch libraries apply it, and re-serializes explicitly as `null`.
31///
32/// Everything else about the operation stays strictly typed: an unrecognized
33/// `op` is still rejected, because the six RFC operations are the whole
34/// vocabulary and a seventh is a producer bug worth surfacing rather than
35/// carrying silently to an applier that cannot execute it.
36///
37/// ```
38/// # use ag_ui::PatchOperation;
39/// let op = PatchOperation::Replace {
40///     path: "/counter".into(),
41///     value: serde_json::json!(2),
42/// };
43/// assert_eq!(
44///     serde_json::to_string(&op).unwrap(),
45///     r#"{"op":"replace","path":"/counter","value":2}"#
46/// );
47/// ```
48///
49/// [RFC 6902]: https://datatracker.ietf.org/doc/html/rfc6902
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "op", rename_all = "lowercase")]
52#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
53#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
54pub enum PatchOperation {
55    /// Inserts `value` at `path`, shifting array elements right when `path`
56    /// ends in an array index or `-`.
57    Add {
58        /// JSON Pointer to the location to add.
59        path: String,
60        /// The value to insert. An omitted `value` reads as `null`; see the
61        /// type-level docs.
62        #[serde(default)]
63        value: Value,
64    },
65
66    /// Removes the value at `path`.
67    Remove {
68        /// JSON Pointer to the location to remove.
69        path: String,
70    },
71
72    /// Replaces the value at `path`, which must already exist.
73    Replace {
74        /// JSON Pointer to the location to overwrite.
75        path: String,
76        /// The replacement value. An omitted `value` reads as `null`.
77        #[serde(default)]
78        value: Value,
79    },
80
81    /// Moves the value at `from` to `path`.
82    Move {
83        /// JSON Pointer to the source location.
84        from: String,
85        /// JSON Pointer to the destination.
86        path: String,
87    },
88
89    /// Copies the value at `from` to `path`.
90    Copy {
91        /// JSON Pointer to the source location.
92        from: String,
93        /// JSON Pointer to the destination.
94        path: String,
95    },
96
97    /// Asserts that the value at `path` equals `value`; a failed test aborts
98    /// the whole patch.
99    Test {
100        /// JSON Pointer to the location to test.
101        path: String,
102        /// The value the location is expected to hold. An omitted `value` reads
103        /// as `null`.
104        #[serde(default)]
105        value: Value,
106    },
107}
108
109impl PatchOperation {
110    /// Builds an [`Add`](PatchOperation::Add) operation.
111    pub fn add(path: impl Into<String>, value: impl Into<Value>) -> Self {
112        Self::Add {
113            path: path.into(),
114            value: value.into(),
115        }
116    }
117
118    /// Builds a [`Remove`](PatchOperation::Remove) operation.
119    pub fn remove(path: impl Into<String>) -> Self {
120        Self::Remove { path: path.into() }
121    }
122
123    /// Builds a [`Replace`](PatchOperation::Replace) operation.
124    pub fn replace(path: impl Into<String>, value: impl Into<Value>) -> Self {
125        Self::Replace {
126            path: path.into(),
127            value: value.into(),
128        }
129    }
130
131    /// Builds a [`Move`](PatchOperation::Move) operation.
132    pub fn mv(from: impl Into<String>, path: impl Into<String>) -> Self {
133        Self::Move {
134            from: from.into(),
135            path: path.into(),
136        }
137    }
138
139    /// Builds a [`Copy`](PatchOperation::Copy) operation.
140    pub fn copy(from: impl Into<String>, path: impl Into<String>) -> Self {
141        Self::Copy {
142            from: from.into(),
143            path: path.into(),
144        }
145    }
146
147    /// Builds a [`Test`](PatchOperation::Test) operation.
148    pub fn test(path: impl Into<String>, value: impl Into<Value>) -> Self {
149        Self::Test {
150            path: path.into(),
151            value: value.into(),
152        }
153    }
154
155    /// The `op` string as it appears on the wire.
156    pub const fn op(&self) -> &'static str {
157        match self {
158            Self::Add { .. } => "add",
159            Self::Remove { .. } => "remove",
160            Self::Replace { .. } => "replace",
161            Self::Move { .. } => "move",
162            Self::Copy { .. } => "copy",
163            Self::Test { .. } => "test",
164        }
165    }
166
167    /// The JSON Pointer this operation targets.
168    pub fn path(&self) -> &str {
169        match self {
170            Self::Add { path, .. }
171            | Self::Remove { path }
172            | Self::Replace { path, .. }
173            | Self::Move { path, .. }
174            | Self::Copy { path, .. }
175            | Self::Test { path, .. } => path,
176        }
177    }
178
179    /// The source pointer of a `move` or `copy`, or `None` for other operations.
180    pub fn from(&self) -> Option<&str> {
181        match self {
182            Self::Move { from, .. } | Self::Copy { from, .. } => Some(from),
183            _ => None,
184        }
185    }
186
187    /// The payload of an `add`, `replace` or `test`, or `None` for other
188    /// operations.
189    pub const fn value(&self) -> Option<&Value> {
190        match self {
191            Self::Add { value, .. } | Self::Replace { value, .. } | Self::Test { value, .. } => {
192                Some(value)
193            }
194            _ => None,
195        }
196    }
197}