harmony_jolt/spec.rs
1use serde::Deserialize;
2use serde_json::Value;
3use crate::{JsonPointer, shift::Shift, modifier::ModifierSpec};
4
5/// The JSON transformation specification.
6///
7/// Composes a list of operation specifications. Each operation has its own DSL (Domain Specific
8/// Language) in order to facilitate its narrow job.
9///
10/// ```
11/// use harmony_jolt::TransformSpec;
12///
13/// let spec: TransformSpec =
14/// serde_json::from_str(r#"[
15/// {
16/// "operation": "shift",
17/// "spec": {
18/// "name": "data.name",
19/// "account": "data.account"
20/// }
21/// }
22/// ]"#).unwrap();
23/// ```
24///
25/// ### `Shift` operation
26/// Specifies where the data from the input JSON should be placed in the output JSON, or in other
27/// words, how the input JSON/data should be shifted around to make the output JSON/data.
28///
29/// At a base level, a single `shift` operation is a mapping from an input path to an output path,
30/// similar to the `mv` command in Unix, `mv /var/data /var/backup/data`.
31///
32/// The input path is a JSON tree structure, and the output path is flattened "dot notation" path
33/// notation.
34///
35/// For example, given this simple input JSON:
36/// <pre>
37/// {
38/// "id": 1,
39/// "name": "John Smith",
40/// "account": {
41/// "id": 1000,
42/// "type": "Checking"
43/// }
44/// }
45/// </pre>
46/// A simple spec could be constructed by copying that input, and modifying it to supply an output
47/// path for each piece of data:
48/// <pre>
49/// {
50/// "id": "data.id",
51/// "name": "data.name",
52/// "account": "data.account"
53/// }
54/// </pre>
55/// would produce the following output JSON:
56/// <pre>
57/// {
58/// "data" : {
59/// "id": 1,
60/// "name": "John Smith",
61/// "account": {
62/// "id": 1000,
63/// "type": "Checking"
64/// }
65/// }
66/// }
67/// </pre>
68/// #### Wildcards
69/// The `shift` specification on the keys level supports wildcards and conditions:
70/// 1. `*` - match everything
71/// 2. `name1|name2|nameN` - match any of the specified names
72///
73/// `&` lookup allows referencing the values captured by the `*` or `|`.
74/// It allows for specs to be more compact. For example, for this input:
75/// <pre>
76/// {
77/// "id": 1,
78/// "name": "John Smith",
79/// "account": {
80/// "id": 1000,
81/// "type": "Checking"
82/// }
83/// }
84/// </pre>
85/// to get the output:
86/// <pre>
87/// {
88/// "data" : {
89/// "id": 1,
90/// "name": "John Smith",
91/// "account": {
92/// "id": 1000,
93/// "type": "Checking"
94/// }
95/// }
96/// }
97/// </pre>
98/// the spec with wildcards would be:
99/// <pre>
100/// {
101/// "*": "data.&0"
102/// }
103/// </pre>
104/// If you want only `id` and `name` in the output, the spec is:
105/// <pre>
106/// {
107/// "id|name": "data.&0"
108/// }
109/// </pre>
110///
111///
112/// `&` wildcard also allows to dereference any level of the path of given node:
113/// <pre>
114/// {
115/// "foo": {
116/// "bar" : {
117/// "baz": "new_location.&0.&1.&2" // &0 = baz, &1 = bar, &2 = foo
118/// }
119/// }
120/// }
121/// }
122/// </pre>
123/// for the input:
124/// <pre>
125/// {
126/// "foo": {
127/// "bar": {
128/// "baz": "value"
129/// }
130/// }
131/// }
132/// </pre>
133/// will produce:
134/// <pre>
135/// {
136/// "new_location": {
137/// "baz": {
138/// "bar": {
139/// "foo": "value"
140/// }
141/// }
142/// }
143/// }
144/// </pre>
145///
146/// ### `Default` operation
147/// Applies default values if the value is not present in the input JSON.
148///
149/// For example, given this simple input JSON:
150/// <pre>
151/// {
152/// "phones": {
153/// "mobile": 01234567,
154/// "country": "US"
155/// }
156/// }
157/// </pre>
158/// with the following specification for `default` operation:
159/// <pre>
160/// {
161/// "phones": {
162/// "mobile": 0000000,
163/// "code": "+1"
164/// }
165/// }
166/// </pre>
167/// the output JSON will be:
168/// <pre>
169/// {
170/// "phones": {
171/// "mobile": 01234567,
172/// "country": "US",
173/// "code": "+1"
174/// }
175/// }
176/// </pre>
177/// As you can see, the field `mobile` remains not affected while the `code` has a default '+1' value.
178///
179/// ### `Remove` operation
180/// Removes content from the input JSON.
181/// The spec structure matches the input JSON structure. The value of fields is ignored.
182///
183/// For example, given this simple input JSON:
184/// <pre>
185/// {
186/// "phones": {
187/// "mobile": 01234567,
188/// "country": "US"
189/// }
190/// }
191/// </pre>
192/// you can remove the `country` by the following specification for `remove` operation:
193/// <pre>
194/// {
195/// "phones": {
196/// "country": ""
197/// }
198/// }
199/// </pre>
200/// the output JSON will be:
201/// <pre>
202/// {
203/// "phones": {
204/// "mobile": 01234567
205/// }
206/// }
207/// </pre>
208#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
209pub struct TransformSpec(Vec<SpecEntry>);
210
211#[derive(Debug, Deserialize, Clone, PartialEq)]
212#[serde(tag = "operation", content = "spec")]
213#[serde(rename_all = "kebab-case")]
214pub(crate) enum SpecEntry {
215 Shift(Shift),
216 Default(Spec),
217 #[serde(
218 alias = "modify-overwrite-beta",
219 alias = "modify-default-beta",
220 alias = "modify-define-beta"
221 )]
222 Modify(ModifierSpec),
223 Remove(Spec),
224}
225
226#[derive(Debug, Deserialize, Clone, PartialEq)]
227pub(crate) struct Spec(Value);
228
229#[derive(Debug)]
230pub(crate) struct SpecIter<'a> {
231 path: Vec<(&'a Value, usize, String)>,
232}
233
234impl TransformSpec {
235 pub(crate) fn entries(&self) -> impl Iterator<Item = &SpecEntry> {
236 self.0.iter()
237 }
238}
239
240impl Spec {
241 pub(crate) fn iter(&self) -> SpecIter<'_> {
242 SpecIter::new(self)
243 }
244}
245
246impl<'a> SpecIter<'a> {
247 fn new(spec: &'a Spec) -> Self {
248 Self {
249 path: vec![(&spec.0, 0, String::new())],
250 }
251 }
252}
253
254impl<'a> Iterator for SpecIter<'a> {
255 type Item = (JsonPointer, &'a Value);
256
257 fn next(&mut self) -> Option<Self::Item> {
258 loop {
259 let (current, index, name) = self.path.pop()?;
260 match current {
261 Value::Array(vec) => {
262 if let Some(next) = vec.get(index) {
263 self.path.push((current, index + 1, name));
264 self.path.push((next, 0, index.to_string()));
265 }
266 }
267 Value::Object(map) => {
268 if let Some((next_name, next)) = map.iter().nth(index) {
269 self.path.push((current, index + 1, name));
270 self.path.push((next, 0, next_name.clone()));
271 }
272 }
273 other => {
274 let mut path: Vec<String> =
275 self.path.iter().map(|(_, _, path)| path.clone()).collect();
276 path.push(name);
277 return Some((JsonPointer::new(path), other));
278 }
279 };
280 }
281 }
282}
283
284#[cfg(test)]
285mod test {
286
287 use serde_json::json;
288 use super::*;
289
290 #[test]
291 fn test_de_from_str() {
292 let spec = r#"
293 [
294 {
295 "operation": "shift",
296 "spec": {
297 "id": "__data.id",
298 "name": "__data.name",
299 "account": "__data.account"
300 }
301 }
302 ]"#;
303 let result: TransformSpec = serde_json::from_str(spec).expect("parsed transform spec");
304
305 assert_eq!(
306 result,
307 TransformSpec(vec![SpecEntry::Shift(
308 serde_json::from_value(json!({
309 "id": "__data.id",
310 "name": "__data.name",
311 "account": "__data.account"
312 }))
313 .unwrap()
314 )])
315 );
316 }
317}