From e1a1e4a70faca14ce65cb712d62f057bf598c8f7 Mon Sep 17 00:00:00 2001 From: Mats Willemsen Date: Sat, 26 Jul 2025 08:38:38 +0200 Subject: [PATCH] feat: add json patch support --- Cargo.lock | 43 ++++ Cargo.toml | 3 + src/builtins/objects.rs | 51 ++++- .../cases/builtins/objects/json.patch.yaml | 212 ++++++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 tests/interpreter/cases/builtins/objects/json.patch.yaml diff --git a/Cargo.lock b/Cargo.lock index dd20cfe2..fab21519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "159294d661a039f7644cea7e4d844e6b25aaf71c1ffe9d73a96d768c24b0faf4" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jsonschema" version = "0.30.0" @@ -1190,6 +1212,7 @@ dependencies = [ "criterion", "data-encoding", "globset", + "json-patch", "jsonschema", "lazy_static", "msvc_spectre_libs", @@ -1386,6 +1409,26 @@ dependencies = [ "syn 0.15.44", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2 1.0.95", + "quote 1.0.40", + "syn 2.0.104", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 9ca8b6f8..d801e521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ http = [] glob = ["dep:globset"] graph = [] jsonschema = ["dep:jsonschema"] +jsonpatch = ["dep:json-patch"] no_std = ["lazy_static/spin_no_std"] opa-runtime = [] regex = ["dep:regex"] @@ -49,6 +50,7 @@ full-opa = [ "hex", "http", "jsonschema", + "jsonpatch", "opa-runtime", "regex", "semver", @@ -100,6 +102,7 @@ semver = {version = "1.0.25", optional = true, default-features = false } url = { version = "2.5.4", optional = true } uuid = { version = "1.15.1", default-features = false, features = ["v4", "fast-rng"], optional = true } jsonschema = { version = "0.30.0", default-features = false, optional = true } +json-patch = { version = "4.0.0", default-features = false, optional = true } chrono = { version = "0.4.40", optional = true } chrono-tz = { version = "0.10.1", optional = true } diff --git a/src/builtins/objects.rs b/src/builtins/objects.rs index c326fd1d..49e65217 100644 --- a/src/builtins/objects.rs +++ b/src/builtins/objects.rs @@ -30,6 +30,11 @@ pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn m.insert("json.match_schema", (json_match_schema, 2)); m.insert("json.verify_schema", (json_verify_schema, 1)); } + + #[cfg(feature = "jsonpatch")] + { + m.insert("json.patch", (json_patch, 2)); + } } fn json_filter_impl(v: &Value, filter: &Value) -> Value { @@ -438,7 +443,6 @@ fn json_match_schema( let name = "json.match_schema"; ensure_args_count(span, name, params, args, 2)?; - // The following is expected to succeed. let document: serde_json::Value = serde_json::from_str(&args[0].to_json_str()?) .map_err(|err| span.error(&format!("Failed to parse JSON: {err}")))?; @@ -456,3 +460,48 @@ fn json_match_schema( .to_vec(), )) } + +#[cfg(feature = "jsonpatch")] +fn json_patch(span: &Span, params: &[Ref], args: &[Value], strict: bool) -> Result { + let name = "json.patch"; + ensure_args_count(span, name, params, args, 2)?; + + let object_str = args[0].to_json_str()?; + let mut object: serde_json::Value = serde_json::from_str(&object_str) + .map_err(|err| span.error(&format!("Failed to parse object as JSON: {err}")))?; + + ensure_array(name, ¶ms[1], args[1].clone())?; + + let patches_str = args[1].to_json_str()?; + let patches_json: serde_json::Value = serde_json::from_str(&patches_str) + .map_err(|err| span.error(&format!("Failed to parse patches as JSON: {err}")))?; + + let patch: json_patch::Patch = serde_json::from_value(patches_json).map_err(|err| { + if strict { + params[1] + .span() + .error(&format!("Invalid patch format: {err}")) + } else { + span.error(&format!("Invalid patch format: {err}")) + } + })?; + + match json_patch::patch(&mut object, &patch) { + Ok(_) => { + let result_str = serde_json::to_string(&object) + .map_err(|err| span.error(&format!("Failed to serialize patched object: {err}")))?; + Value::from_json_str(&result_str).map_err(|err| { + span.error(&format!( + "Failed to convert patched object back to Value: {err}" + )) + }) + } + Err(err) => { + if strict { + bail!(span.error(&format!("Failed to apply patch: {err}"))); + } else { + Ok(Value::Undefined) + } + } + } +} diff --git a/tests/interpreter/cases/builtins/objects/json.patch.yaml b/tests/interpreter/cases/builtins/objects/json.patch.yaml new file mode 100644 index 00000000..59097f48 --- /dev/null +++ b/tests/interpreter/cases/builtins/objects/json.patch.yaml @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +cases: + - note: basic add operation + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1}} + patches = [{"op": "add", "path": "/a/bar", "value": 2}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + foo: 1 + bar: 2 + + - note: basic remove operation + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1, "bar": 2}} + patches = [{"op": "remove", "path": "/a/bar"}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + foo: 1 + + - note: basic replace operation + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1}} + patches = [{"op": "replace", "path": "/a/foo", "value": 42}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + foo: 42 + + - note: basic move operation + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1}, "b": {}} + patches = [{"op": "move", "from": "/a/foo", "path": "/b/foo"}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: {} + b: + foo: 1 + + - note: basic copy operation + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1}, "b": {}} + patches = [{"op": "copy", "from": "/a/foo", "path": "/b/foo"}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + foo: 1 + b: + foo: 1 + + - note: basic test operation (successful) + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1}} + patches = [{"op": "test", "path": "/a/foo", "value": 1}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + foo: 1 + + - note: multiple operations + data: {} + modules: + - | + package test + + obj = {"a": {"foo": 1}} + patches = [ + {"op": "add", "path": "/a/bar", "value": 2}, + {"op": "replace", "path": "/a/foo", "value": 42} + ] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + foo: 42 + bar: 2 + + - note: array operations - add to array + data: {} + modules: + - | + package test + + obj = {"arr": [1, 2, 3]} + patches = [{"op": "add", "path": "/arr/1", "value": "inserted"}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + arr: [1, "inserted", 2, 3] + + - note: array operations - add to end of array + data: {} + modules: + - | + package test + + obj = {"arr": [1, 2, 3]} + patches = [{"op": "add", "path": "/arr/-", "value": 4}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + arr: [1, 2, 3, 4] + + - note: array operations - remove from array + data: {} + modules: + - | + package test + + obj = {"arr": [1, 2, 3]} + patches = [{"op": "remove", "path": "/arr/1"}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + arr: [1, 3] + + - note: nested object operations + data: {} + modules: + - | + package test + + obj = {"a": {"b": {"c": {"d": 1}}}} + patches = [{"op": "add", "path": "/a/b/c/e", "value": 2}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + a: + b: + c: + d: 1 + e: 2 + + - note: special characters in keys + data: {} + modules: + - | + package test + + obj = {"foo/bar": {"baz~": 1}} + patches = [{"op": "add", "path": "/foo~1bar/baz~0test", "value": 2}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + "foo/bar": + "baz~": 1 + "baz~test": 2 + + - note: empty object and array handling + data: {} + modules: + - | + package test + + obj = {} + patches = [{"op": "add", "path": "/newkey", "value": {"nested": []}}] + result = json.patch(obj, patches) + query: data.test.result + want_result: + newkey: + nested: [] + + - note: complex value types + data: {} + modules: + - | + package test + + obj = {"data": null} + patches = [ + {"op": "replace", "path": "/data", "value": {"bool": true, "num": 3.14, "str": "hello"}} + ] + result = json.patch(obj, patches) + query: data.test.result + want_result: + data: + bool: true + num: 3.14 + str: "hello"