audit fixes, repo restructure, and documentation

Soundness and performance audit (17 fixes):
- See AUDIT.md for full details and @claude comments in code

Repo restructure:
- Inline json-ptr and json-patch submodules as regular directories
- Remove cbor submodule, replace serde_cbor with ciborium
- Rename patch-db/ -> core/, patch-db-macro/ -> macro/,
  patch-db-macro-internals/ -> macro-internals/, patch-db-util/ -> util/
- Purge upstream CI/CD, bench, and release cruft from json-patch
- Remove .gitmodules

Test fixes:
- Fix proptest doesnt_crash (unique file paths, proper close/cleanup)
- Add PatchDb::close() for clean teardown

Documentation:
- Add README.md, ARCHITECTURE.md, CONTRIBUTING.md, CLAUDE.md, AUDIT.md
- Add TSDocs to TypeScript client exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Hill
2026-02-23 19:06:42 -07:00
parent 05c93290c7
commit 86b0768bbb
46 changed files with 5744 additions and 95 deletions

Submodule json-patch deleted from ba38c78e4d

4
json-patch/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/.idea/
/target/
**/*.rs.bk
Cargo.lock

27
json-patch/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "json-patch"
version = "0.2.7-alpha.0"
authors = ["Ivan Dubrov <dubrov.ivan@gmail.com>"]
categories = []
keywords = ["json", "json-patch"]
description = "RFC 6902, JavaScript Object Notation (JSON) Patch"
repository = "https://github.com/idubrov/json-patch"
license = "MIT/Apache-2.0"
readme = "README.md"
edition = "2018"
[features]
default = ["diff"]
nightly = []
diff = []
[dependencies]
imbl-value = "0.4.1"
json-ptr = { path = "../json-ptr" }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
rand = "0.9.1"
serde_json = { version = "1.0.60", features = ["preserve_order"] }
proptest = "1"
imbl-value = { version = "0.4.1", features = ["arbitrary"] }

View File

@@ -0,0 +1,92 @@
[
{
"comment": "1. introduction",
"doc": {
"a": "b",
"c": {
"d": "e",
"f": "g"
}
},
"patch": {
"a": "z",
"c": {
"f": null
}
},
"expected": {
"a": "z",
"c": {
"d": "e"
}
},
"merge": true
},
{
"comment": "3. example",
"doc": {
"title": "Goodbye!",
"author": {
"givenName": "John",
"familyName": "Doe"
},
"tags": [
"example",
"sample"
],
"content": "This will be unchanged"
},
"patch": {
"title": "Hello!",
"phoneNumber": "+01-123-456-7890",
"author": {
"familyName": null
},
"tags": [
"example"
]
},
"expected": {
"title": "Hello!",
"author": {
"givenName": "John"
},
"tags": [
"example"
],
"content": "This will be unchanged",
"phoneNumber": "+01-123-456-7890"
},
"merge": true
},
{
"comment": "replacing non-object",
"doc": {
"title": "Goodbye!",
"author": {
"givenName": "John"
},
"tags": [
"example",
"sample"
],
"content": "This will be unchanged"
},
"patch": {
"tags": {
"kind": "example"
}
},
"expected": {
"title": "Goodbye!",
"author": {
"givenName": "John"
},
"tags": {
"kind": "example"
},
"content": "This will be unchanged"
},
"merge": true
}
]

View File

@@ -0,0 +1,286 @@
[
{
"comment": "Can revert add (replace key)",
"doc": {
"foo": {
"bar": {
"baz": true
}
}
},
"patch": [
{
"op": "add",
"path": "/foo",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert add (insert into array)",
"doc": {
"foo": [1, 2, 3]
},
"patch": [
{
"op": "add",
"path": "/foo/1",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert add (insert last element into array)",
"doc": {
"foo": [1, 2, 3]
},
"patch": [
{
"op": "add",
"path": "/foo/-",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert remove (object)",
"doc": {
"foo": {
"bar": {
"baz": true
}
}
},
"patch": [
{
"op": "remove",
"path": "/foo"
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert remove (array)",
"doc": {
"foo": [1, 2, 3]
},
"patch": [
{
"op": "remove",
"path": "/foo/1"
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert replace (replace key)",
"doc": {
"foo": {
"bar": {
"baz": true
}
}
},
"patch": [
{
"op": "replace",
"path": "/foo",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert replace (replace array element)",
"doc": {
"foo": [1, 2, 3]
},
"patch": [
{
"op": "replace",
"path": "/foo/1",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert move (move into key)",
"doc": {
"foo": {
"bar": {
"baz": true
}
},
"abc": {
"def": {
"ghi": false
}
}
},
"patch": [
{
"op": "move",
"from": "/abc",
"path": "/foo",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert move (move into array)",
"doc": {
"foo": [1, 2, 3],
"abc": {
"def": {
"ghi": false
}
}
},
"patch": [
{
"op": "move",
"path": "/foo/1",
"from": "/abc"
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert move (move into last element of an array)",
"doc": {
"foo": [1, 2, 3],
"abc": {
"def": {
"ghi": false
}
}
},
"patch": [
{
"op": "move",
"path": "/foo/-",
"from": "/abc"
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert copy (copy into key)",
"doc": {
"foo": {
"bar": {
"baz": true
}
},
"abc": {
"def": {
"ghi": false
}
}
},
"patch": [
{
"op": "copy",
"from": "/abc",
"path": "/foo",
"value": false
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert copy (copy into array)",
"doc": {
"foo": [1, 2, 3],
"abc": {
"def": {
"ghi": false
}
}
},
"patch": [
{
"op": "copy",
"path": "/foo/1",
"from": "/abc"
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
},
{
"comment": "Can revert copy (copy into last element of an array)",
"doc": {
"foo": [1, 2, 3],
"abc": {
"def": {
"ghi": false
}
}
},
"patch": [
{
"op": "copy",
"path": "/foo/-",
"from": "/abc"
},
{
"op": "remove",
"path": "/foo/bar"
}
],
"error": "invalid pointer"
}
]

View File

@@ -0,0 +1,343 @@
[
{
"comment": "4.1. add with missing object",
"doc": {
"q": {
"bar": 2
}
},
"patch": [
{
"op": "add",
"path": "/a/b",
"value": 1
}
],
"error": "path /a does not exist -- missing objects are not created recursively"
},
{
"comment": "A.1. Adding an Object Member",
"doc": {
"foo": "bar"
},
"patch": [
{
"op": "add",
"path": "/baz",
"value": "qux"
}
],
"expected": {
"baz": "qux",
"foo": "bar"
}
},
{
"comment": "A.2. Adding an Array Element",
"doc": {
"foo": [
"bar",
"baz"
]
},
"patch": [
{
"op": "add",
"path": "/foo/1",
"value": "qux"
}
],
"expected": {
"foo": [
"bar",
"qux",
"baz"
]
}
},
{
"comment": "A.3. Removing an Object Member",
"doc": {
"baz": "qux",
"foo": "bar"
},
"patch": [
{
"op": "remove",
"path": "/baz"
}
],
"expected": {
"foo": "bar"
}
},
{
"comment": "A.4. Removing an Array Element",
"doc": {
"foo": [
"bar",
"qux",
"baz"
]
},
"patch": [
{
"op": "remove",
"path": "/foo/1"
}
],
"expected": {
"foo": [
"bar",
"baz"
]
}
},
{
"comment": "A.5. Replacing a Value",
"doc": {
"baz": "qux",
"foo": "bar"
},
"patch": [
{
"op": "replace",
"path": "/baz",
"value": "boo"
}
],
"expected": {
"baz": "boo",
"foo": "bar"
}
},
{
"comment": "A.6. Moving a Value",
"doc": {
"foo": {
"bar": "baz",
"waldo": "fred"
},
"qux": {
"corge": "grault"
}
},
"patch": [
{
"op": "move",
"from": "/foo/waldo",
"path": "/qux/thud"
}
],
"expected": {
"foo": {
"bar": "baz"
},
"qux": {
"corge": "grault",
"thud": "fred"
}
}
},
{
"comment": "A.7. Moving an Array Element",
"doc": {
"foo": [
"all",
"grass",
"cows",
"eat"
]
},
"patch": [
{
"op": "move",
"from": "/foo/1",
"path": "/foo/3"
}
],
"expected": {
"foo": [
"all",
"cows",
"eat",
"grass"
]
}
},
{
"comment": "A.8. Testing a Value: Success",
"doc": {
"baz": "qux",
"foo": [
"a",
2,
"c"
]
},
"patch": [
{
"op": "test",
"path": "/baz",
"value": "qux"
},
{
"op": "test",
"path": "/foo/1",
"value": 2
}
],
"expected": {
"baz": "qux",
"foo": [
"a",
2,
"c"
]
}
},
{
"comment": "A.9. Testing a Value: Error",
"doc": {
"baz": "qux"
},
"patch": [
{
"op": "test",
"path": "/baz",
"value": "bar"
}
],
"error": "string not equivalent"
},
{
"comment": "A.10. Adding a nested Member Object",
"doc": {
"foo": "bar"
},
"patch": [
{
"op": "add",
"path": "/child",
"value": {
"grandchild": {}
}
}
],
"expected": {
"foo": "bar",
"child": {
"grandchild": {
}
}
}
},
{
"comment": "A.11. Ignoring Unrecognized Elements",
"doc": {
"foo": "bar"
},
"patch": [
{
"op": "add",
"path": "/baz",
"value": "qux",
"xyz": 123
}
],
"expected": {
"foo": "bar",
"baz": "qux"
}
},
{
"comment": "A.12. Adding to a Non-existent Target",
"doc": {
"foo": "bar"
},
"patch": [
{
"op": "add",
"path": "/baz/bat",
"value": "qux"
}
],
"error": "add to a non-existent target"
},
{
"comment": "A.13 Invalid JSON Patch Document",
"doc": {
"foo": "bar"
},
"patch": [
{
"op": "add",
"path": "/baz",
"value": "qux",
"op": "remove"
}
],
"error": "operation has two 'op' members",
"disabled": true
},
{
"comment": "A.14. ~ Escape Ordering",
"doc": {
"/": 9,
"~1": 10
},
"patch": [
{
"op": "test",
"path": "/~01",
"value": 10
}
],
"expected": {
"/": 9,
"~1": 10
}
},
{
"comment": "A.15. Comparing Strings and Numbers",
"doc": {
"/": 9,
"~1": 10
},
"patch": [
{
"op": "test",
"path": "/~01",
"value": "10"
}
],
"error": "number is not equal to string"
},
{
"comment": "A.16. Adding an Array Value",
"doc": {
"foo": [
"bar"
]
},
"patch": [
{
"op": "add",
"path": "/foo/-",
"value": [
"abc",
"def"
]
}
],
"expected": {
"foo": [
"bar",
[
"abc",
"def"
]
]
}
}
]

1877
json-patch/specs/tests.json Normal file

File diff suppressed because it is too large Load Diff

293
json-patch/src/diff.rs Normal file
View File

@@ -0,0 +1,293 @@
use std::collections::BTreeSet;
use imbl_value::Value;
use json_ptr::JsonPointer;
use crate::{AddOperation, PatchOperation, RemoveOperation, ReplaceOperation};
struct PatchDiffer {
path: JsonPointer,
patch: super::Patch,
}
impl PatchDiffer {
fn new() -> Self {
Self {
path: JsonPointer::default(),
patch: super::Patch(Vec::new()),
}
}
}
/// Diff two JSON documents and generate a JSON Patch (RFC 6902).
///
/// # Example
/// Diff two JSONs:
///
/// ```rust
/// #[macro_use]
/// extern crate imbl_value;
/// extern crate json_patch;
///
/// use json_patch::{patch, diff, from_value};
///
/// # pub fn main() {
/// let left = json!({
/// "title": "Goodbye!",
/// "author" : {
/// "givenName" : "John",
/// "familyName" : "Doe"
/// },
/// "tags":[ "example", "sample" ],
/// "content": "This will be unchanged"
/// });
///
/// let right = json!({
/// "title": "Hello!",
/// "author" : {
/// "givenName" : "John"
/// },
/// "tags": [ "example" ],
/// "content": "This will be unchanged",
/// "phoneNumber": "+01-123-456-7890"
/// });
///
/// let p = diff(&left, &right);
/// assert_eq!(p, from_value(json!([
/// { "op": "remove", "path": "/author/familyName" },
/// { "op": "add", "path": "/phoneNumber", "value": "+01-123-456-7890" },
/// { "op": "remove", "path": "/tags/1" },
/// { "op": "replace", "path": "/title", "value": "Hello!" },
/// ])).unwrap());
///
/// let mut doc = left.clone();
/// patch(&mut doc, &p).unwrap();
/// assert_eq!(doc, right);
///
/// # }
/// ```
pub fn diff(from: &Value, to: &Value) -> super::Patch {
let mut differ = PatchDiffer::new();
diff_mut(&mut differ, from, to);
differ.patch
}
fn diff_mut(differ: &mut PatchDiffer, from: &Value, to: &Value) {
match (from, to) {
(Value::Object(f), Value::Object(t)) if !f.ptr_eq(t) => {
for key in f
.keys()
.chain(t.keys())
.map(|k| &**k)
.collect::<BTreeSet<_>>()
{
differ.path.push_end(key);
match (f.get(key), to.get(key)) {
(Some(f), Some(t)) if f != t => {
diff_mut(differ, f, t);
}
(Some(_), None) => {
differ.patch.0.push(PatchOperation::Remove(RemoveOperation {
path: differ.path.clone(),
}));
}
(None, Some(t)) => {
differ.patch.0.push(PatchOperation::Add(AddOperation {
path: differ.path.clone(),
value: t.clone(),
}));
}
_ => (),
}
differ.path.pop_end();
}
}
(Value::Array(f), Value::Array(t)) if !f.ptr_eq(t) => {
if f.len() < t.len() {
let mut f_idx = 0;
let mut t_idx = 0;
while t_idx < t.len() {
if f_idx == f.len() {
differ.patch.0.push(PatchOperation::Add(AddOperation {
path: differ.path.clone().join_end_idx(t_idx),
value: t[t_idx].clone(),
}));
t_idx += 1;
} else {
if !f[f_idx].ptr_eq(&t[t_idx]) {
if t.iter().skip(t_idx + 1).any(|t| f[f_idx].ptr_eq(t)) {
differ.patch.0.push(PatchOperation::Add(AddOperation {
path: differ.path.clone().join_end_idx(t_idx),
value: t[t_idx].clone(),
}));
t_idx += 1;
continue;
} else {
differ.path.push_end_idx(t_idx);
diff_mut(differ, &f[f_idx], &t[t_idx]);
differ.path.pop_end();
}
}
f_idx += 1;
t_idx += 1;
}
}
while f_idx < f.len() {
differ.patch.0.push(PatchOperation::Remove(RemoveOperation {
path: differ.path.clone().join_end_idx(t_idx),
}));
f_idx += 1;
}
} else if f.len() > t.len() {
let mut f_idx = 0;
let mut t_idx = 0;
while f_idx < f.len() {
if t_idx == t.len() {
differ.patch.0.push(PatchOperation::Remove(RemoveOperation {
path: differ.path.clone().join_end_idx(t_idx),
}));
f_idx += 1;
} else {
if !f[f_idx].ptr_eq(&t[t_idx]) {
if f.iter().skip(f_idx + 1).any(|f| t[t_idx].ptr_eq(f)) {
differ.patch.0.push(PatchOperation::Remove(RemoveOperation {
path: differ.path.clone().join_end_idx(t_idx),
}));
f_idx += 1;
continue;
} else {
differ.path.push_end_idx(t_idx);
diff_mut(differ, &f[f_idx], &t[t_idx]);
differ.path.pop_end();
}
}
f_idx += 1;
t_idx += 1;
}
}
while t_idx < t.len() {
differ.patch.0.push(PatchOperation::Add(AddOperation {
path: differ.path.clone().join_end_idx(t_idx),
value: t[t_idx].clone(),
}));
t_idx += 1;
}
} else {
for i in 0..f.len() {
if !f[i].ptr_eq(&t[i]) {
differ.path.push_end_idx(i);
diff_mut(differ, &f[i], &t[i]);
differ.path.pop_end();
}
}
}
}
(f, t) if f != t => differ
.patch
.0
.push(PatchOperation::Replace(ReplaceOperation {
path: differ.path.clone(),
value: t.clone(),
})),
_ => (),
}
}
#[cfg(test)]
mod tests {
use imbl_value::Value;
#[test]
pub fn replace_all() {
let left = json!({"title": "Hello!"});
let p = super::diff(&left, &Value::Null);
assert_eq!(
p,
imbl_value::from_value(json!([
{ "op": "replace", "path": "", "value": null },
]))
.unwrap()
);
}
#[test]
pub fn add_all() {
let right = json!({"title": "Hello!"});
let p = super::diff(&Value::Null, &right);
assert_eq!(
p,
imbl_value::from_value(json!([
{ "op": "replace", "path": "", "value": { "title": "Hello!" } },
]))
.unwrap()
);
}
#[test]
pub fn remove_all() {
let left = json!(["hello", "bye"]);
let right = json!([]);
let p = super::diff(&left, &right);
assert_eq!(
p,
imbl_value::from_value(json!([
{ "op": "remove", "path": "/0" },
{ "op": "remove", "path": "/0" },
]))
.unwrap()
);
}
#[test]
pub fn remove_tail() {
let left = json!(["hello", "bye", "hi"]);
let right = json!(["hello"]);
let p = super::diff(&left, &right);
assert_eq!(
p,
imbl_value::from_value(json!([
{ "op": "remove", "path": "/1" },
{ "op": "remove", "path": "/1" },
]))
.unwrap()
);
}
#[test]
pub fn replace_object() {
let left = json!(["hello", "bye"]);
let right = json!({"hello": "bye"});
let p = super::diff(&left, &right);
assert_eq!(
p,
imbl_value::from_value(json!([
{ "op": "replace", "path": "", "value": &right },
]))
.unwrap()
);
}
#[test]
fn escape_json_keys() {
let mut left = json!({
"/slashed/path": 1
});
let right = json!({
"/slashed/path": 2,
});
let patch = super::diff(&left, &right);
eprintln!("{:?}", patch);
crate::patch(&mut left, &patch).unwrap();
assert_eq!(left, right);
}
proptest::proptest! {
#[test]
fn test_diff(mut from: Value, to: Value) {
let patch = super::diff(&from, &to);
crate::patch(&mut from, &patch).unwrap();
assert_eq!(from, to);
}
}
}

603
json-patch/src/lib.rs Normal file
View File

@@ -0,0 +1,603 @@
//! A [JSON Patch (RFC 6902)](https://tools.ietf.org/html/rfc6902) and
//! [JSON Merge Patch (RFC 7396)](https://tools.ietf.org/html/rfc7396) implementation for Rust.
//!
//! # Usage
//!
//! Add this to your *Cargo.toml*:
//! ```toml
//! [dependencies]
//! json-patch = "*"
//! ```
//!
//! # Examples
//! Create and patch document using JSON Patch:
//!
//! ```rust
//! #[macro_use]
//! extern crate imbl_value;
//! extern crate json_patch;
//!
//! use json_patch::patch;
//! use serde_json::from_str;
//!
//! # pub fn main() {
//! let mut doc = json!([
//! { "name": "Andrew" },
//! { "name": "Maxim" }
//! ]);
//!
//! let p = from_str(r#"[
//! { "op": "test", "path": "/0/name", "value": "Andrew" },
//! { "op": "add", "path": "/0/happy", "value": true }
//! ]"#).unwrap();
//!
//! patch(&mut doc, &p).unwrap();
//! assert_eq!(doc, json!([
//! { "name": "Andrew", "happy": true },
//! { "name": "Maxim" }
//! ]));
//!
//! # }
//! ```
//!
//! Create and patch document using JSON Merge Patch:
//!
//! ```rust
//! #[macro_use]
//! extern crate imbl_value;
//! extern crate json_patch;
//!
//! use json_patch::merge;
//!
//! # pub fn main() {
//! let mut doc = json!({
//! "title": "Goodbye!",
//! "author" : {
//! "givenName" : "John",
//! "familyName" : "Doe"
//! },
//! "tags":[ "example", "sample" ],
//! "content": "This will be unchanged"
//! });
//!
//! let patch = json!({
//! "title": "Hello!",
//! "phoneNumber": "+01-123-456-7890",
//! "author": {
//! "familyName": null
//! },
//! "tags": [ "example" ]
//! });
//!
//! merge(&mut doc, &patch);
//! assert_eq!(doc, json!({
//! "title": "Hello!",
//! "author" : {
//! "givenName" : "John"
//! },
//! "tags": [ "example" ],
//! "content": "This will be unchanged",
//! "phoneNumber": "+01-123-456-7890"
//! }));
//! # }
//! ```
#![deny(warnings)]
#![warn(missing_docs)]
#[cfg_attr(test, macro_use)]
extern crate imbl_value;
use imbl_value::{InOMap as Map, Value};
use json_ptr::{JsonPointer, SegList};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::{fmt, mem};
/// Representation of JSON Patch (list of patch operations)
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct Patch(pub Vec<PatchOperation>);
impl Patch {
/// Prepend a path to a patch.
/// This is useful if you run a diff on a JSON document that is a small member of a larger document
pub fn prepend<S: AsRef<str>, V: SegList>(&mut self, ptr: &JsonPointer<S, V>) {
for op in self.0.iter_mut() {
match op {
PatchOperation::Add(ref mut op) => {
op.path.prepend(ptr);
}
PatchOperation::Remove(ref mut op) => {
op.path.prepend(ptr);
}
PatchOperation::Replace(ref mut op) => {
op.path.prepend(ptr);
}
PatchOperation::Move(ref mut op) => {
op.path.prepend(ptr);
op.from.prepend(ptr);
}
PatchOperation::Copy(ref mut op) => {
op.path.prepend(ptr);
op.from.prepend(ptr);
}
PatchOperation::Test(ref mut op) => {
op.path.prepend(ptr);
}
}
}
}
/// Checks whether or not the data at a path could be affected by a patch
pub fn affects_path<S: AsRef<str>, V: SegList>(&self, ptr: &JsonPointer<S, V>) -> bool {
for op in self.0.iter() {
match op {
PatchOperation::Add(ref op) => {
if op.path.starts_with(ptr) || ptr.starts_with(&op.path) {
return true;
}
}
PatchOperation::Remove(ref op) => {
if op.path.starts_with(ptr) || ptr.starts_with(&op.path) {
return true;
}
}
PatchOperation::Replace(ref op) => {
if op.path.starts_with(ptr) || ptr.starts_with(&op.path) {
return true;
}
}
PatchOperation::Move(ref op) => {
if op.path.starts_with(ptr)
|| ptr.starts_with(&op.path)
|| op.from.starts_with(ptr)
|| ptr.starts_with(&op.from)
{
return true;
}
}
PatchOperation::Copy(ref op) => {
if op.path.starts_with(ptr) || ptr.starts_with(&op.path) {
return true;
}
}
PatchOperation::Test(_) => {}
}
}
false
}
/// Returns whether the patch is empty
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// JSON Patch 'add' operation representation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct AddOperation {
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// within the target document where the operation is performed.
pub path: JsonPointer<String>,
/// Value to add to the target location.
pub value: Value,
}
/// JSON Patch 'remove' operation representation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct RemoveOperation {
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// within the target document where the operation is performed.
pub path: JsonPointer<String>,
}
/// JSON Patch 'replace' operation representation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct ReplaceOperation {
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// within the target document where the operation is performed.
pub path: JsonPointer<String>,
/// Value to replace with.
pub value: Value,
}
/// JSON Patch 'move' operation representation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct MoveOperation {
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// to move value from.
pub from: JsonPointer<String>,
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// within the target document where the operation is performed.
pub path: JsonPointer<String>,
}
/// JSON Patch 'copy' operation representation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct CopyOperation {
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// to copy value from.
pub from: JsonPointer<String>,
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// within the target document where the operation is performed.
pub path: JsonPointer<String>,
}
/// JSON Patch 'test' operation representation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct TestOperation {
/// JSON-Pointer value [RFC6901](https://tools.ietf.org/html/rfc6901) that references a location
/// within the target document where the operation is performed.
pub path: JsonPointer<String>,
/// Value to test against.
pub value: Value,
}
/// JSON Patch single patch operation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "op")]
#[serde(rename_all = "lowercase")]
pub enum PatchOperation {
/// 'add' operation
Add(AddOperation),
/// 'remove' operation
Remove(RemoveOperation),
/// 'replace' operation
Replace(ReplaceOperation),
/// 'move' operation
Move(MoveOperation),
/// 'copy' operation
Copy(CopyOperation),
/// 'test' operation
Test(TestOperation),
}
/// This type represents all possible errors that can occur when applying JSON patch
#[derive(Debug)]
pub enum PatchError {
/// One of the pointers in the patch is invalid
InvalidPointer,
/// 'test' operation failed
TestFailed,
}
impl Error for PatchError {}
impl fmt::Display for PatchError {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
match *self {
PatchError::InvalidPointer => write!(fmt, "invalid pointer"),
PatchError::TestFailed => write!(fmt, "test failed"),
}
}
}
fn add<S: AsRef<str>, V: SegList>(
doc: &mut Value,
path: &JsonPointer<S, V>,
value: Value,
) -> Result<Option<Value>, PatchError> {
path.insert(doc, value, false)
.map_err(|_| PatchError::InvalidPointer)
}
fn remove<S: AsRef<str>, V: SegList>(
doc: &mut Value,
path: &JsonPointer<S, V>,
allow_last: bool,
) -> Result<Value, PatchError> {
path.remove(doc, allow_last)
.ok_or(PatchError::InvalidPointer)
}
fn replace<S: AsRef<str>, V: SegList>(
doc: &mut Value,
path: &JsonPointer<S, V>,
value: Value,
) -> Result<Value, PatchError> {
if let Some(target) = path.get_mut(doc) {
Ok(mem::replace(target, value))
} else {
Ok(add(doc, path, value)?.unwrap_or_default())
}
}
fn mov<S0: AsRef<str>, S1: AsRef<str>>(
doc: &mut Value,
from: &JsonPointer<S0>,
path: &JsonPointer<S1>,
allow_last: bool,
) -> Result<Option<Value>, PatchError> {
if path == from {
return Ok(None);
}
// Check we are not moving inside own child
if path.starts_with(from) || from.is_empty() {
return Err(PatchError::InvalidPointer);
}
let val = remove(doc, from, allow_last)?;
add(doc, path, val)
}
fn copy<S0: AsRef<str>, S1: AsRef<str>>(
doc: &mut Value,
from: &JsonPointer<S0>,
path: &JsonPointer<S1>,
) -> Result<Option<Value>, PatchError> {
let source = from.get(doc).ok_or(PatchError::InvalidPointer)?.clone();
add(doc, path, source)
}
fn test<S: AsRef<str>, V: SegList>(
doc: &Value,
path: &JsonPointer<S, V>,
expected: &Value,
) -> Result<(), PatchError> {
let target = path.get(doc).ok_or(PatchError::InvalidPointer)?;
if *target == *expected {
Ok(())
} else {
Err(PatchError::TestFailed)
}
}
/// Create JSON Patch from JSON Value
/// # Examples
///
/// Create patch from `imbl_value::Value`:
///
/// ```rust
/// #[macro_use]
/// extern crate imbl_value;
/// extern crate json_patch;
///
/// use json_patch::{Patch, from_value};
///
/// # pub fn main() {
/// let patch_value = json!([
/// { "op": "test", "path": "/0/name", "value": "Andrew" },
/// { "op": "add", "path": "/0/happy", "value": true }
/// ]);
/// let patch: Patch = from_value(patch_value).unwrap();
/// # }
/// ```
///
/// Create patch from string:
///
/// ```rust
/// #[macro_use]
/// extern crate serde_json;
/// extern crate json_patch;
///
/// use json_patch::Patch;
/// use serde_json::from_str;
///
/// # pub fn main() {
/// let patch_str = r#"[
/// { "op": "test", "path": "/0/name", "value": "Andrew" },
/// { "op": "add", "path": "/0/happy", "value": true }
/// ]"#;
/// let patch: Patch = from_str(patch_str).unwrap();
/// # }
/// ```
pub fn from_value(value: Value) -> Result<Patch, imbl_value::Error> {
let patch = imbl_value::from_value::<Vec<PatchOperation>>(value)?;
Ok(Patch(patch))
}
/// Patch provided JSON document (given as `imbl_value::Value`) in-place. If any of the patch is
/// failed, all previous operations are reverted. In case of internal error resulting in panic,
/// document might be left in inconsistent state.
///
/// # Example
/// Create and patch document:
///
/// ```rust
/// #[macro_use]
/// extern crate imbl_value;
/// extern crate json_patch;
///
/// use json_patch::patch;
/// use serde_json::from_str;
///
/// # pub fn main() {
/// let mut doc = json!([
/// { "name": "Andrew" },
/// { "name": "Maxim" }
/// ]);
///
/// let p = from_str(r#"[
/// { "op": "test", "path": "/0/name", "value": "Andrew" },
/// { "op": "add", "path": "/0/happy", "value": true }
/// ]"#).unwrap();
///
/// patch(&mut doc, &p).unwrap();
/// assert_eq!(doc, json!([
/// { "name": "Andrew", "happy": true },
/// { "name": "Maxim" }
/// ]));
///
/// # }
/// ```
pub fn patch<'a>(doc: &mut Value, patch: &'a Patch) -> Result<Undo<'a>, PatchError> {
let mut undo = Undo(Vec::with_capacity(patch.0.len()));
apply_patches(doc, &patch.0, &mut undo).map(|_| undo)
}
/// Object that can be used to undo a patch if successful
pub struct Undo<'a>(Vec<Box<dyn FnOnce(&mut Value) + Send + Sync + 'a>>);
impl<'a> Undo<'a> {
/// Apply the undo to the document
pub fn apply(mut self, doc: &mut Value) {
while let Some(undo) = self.0.pop() {
undo(doc)
}
}
}
// Apply patches while tracking all the changes being made so they can be reverted back in case
// subsequent patches fail. Uses heap allocated closures to keep the state.
fn apply_patches<'a>(
doc: &mut Value,
patches: &'a [PatchOperation],
undo: &mut Undo<'a>,
) -> Result<(), PatchError> {
let (patch, tail) = match patches.split_first() {
None => return Ok(()),
Some((patch, tail)) => (patch, tail),
};
let res = match *patch {
PatchOperation::Add(ref op) => {
let prev = add(doc, &op.path, op.value.clone())?;
undo.0.push(Box::new(move |doc| {
match prev {
None => remove(doc, &op.path, true).unwrap(),
Some(v) => add(doc, &op.path, v).unwrap().unwrap(),
};
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Remove(ref op) => {
let prev = remove(doc, &op.path, false)?;
undo.0.push(Box::new(move |doc| {
assert!(add(doc, &op.path, prev).unwrap().is_none());
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Replace(ref op) => {
let prev = replace(doc, &op.path, op.value.clone())?;
undo.0.push(Box::new(move |doc| {
replace(doc, &op.path, prev).unwrap();
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Move(ref op) => {
let prev = mov(doc, &op.from, &op.path, false)?;
undo.0.push(Box::new(move |doc| {
mov(doc, &op.path, &op.from, true).unwrap();
if let Some(prev) = prev {
assert!(add(doc, &op.path, prev).unwrap().is_none());
}
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Copy(ref op) => {
let prev = copy(doc, &op.from, &op.path)?;
undo.0.push(Box::new(move |doc| {
match prev {
None => remove(doc, &op.path, true).unwrap(),
Some(v) => add(doc, &op.path, v).unwrap().unwrap(),
};
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Test(ref op) => {
test(doc, &op.path, &op.value)?;
undo.0.push(Box::new(move |_| ()));
apply_patches(doc, tail, undo)
}
};
if res.is_err() {
undo.0.pop().unwrap()(doc);
}
res
}
/// Patch provided JSON document (given as `imbl_value::Value`) in place.
/// Operations are applied in unsafe manner. If any of the operations fails, all previous
/// operations are not reverted.
pub fn patch_unsafe(doc: &mut Value, patch: &Patch) -> Result<(), PatchError> {
for op in &patch.0 {
match *op {
PatchOperation::Add(ref op) => {
add(doc, &op.path, op.value.clone())?;
}
PatchOperation::Remove(ref op) => {
remove(doc, &op.path, false)?;
}
PatchOperation::Replace(ref op) => {
replace(doc, &op.path, op.value.clone())?;
}
PatchOperation::Move(ref op) => {
mov(doc, &op.from, &op.path, false)?;
}
PatchOperation::Copy(ref op) => {
copy(doc, &op.from, &op.path)?;
}
PatchOperation::Test(ref op) => {
test(doc, &op.path, &op.value)?;
}
};
}
Ok(())
}
/// Patch provided JSON document (given as `imbl_value::Value`) in place with JSON Merge Patch
/// (RFC 7396).
///
/// # Example
/// Create and patch document:
///
/// ```rust
/// #[macro_use]
/// extern crate imbl_value;
/// extern crate json_patch;
///
/// use json_patch::merge;
///
/// # pub fn main() {
/// let mut doc = json!({
/// "title": "Goodbye!",
/// "author" : {
/// "givenName" : "John",
/// "familyName" : "Doe"
/// },
/// "tags":[ "example", "sample" ],
/// "content": "This will be unchanged"
/// });
///
/// let patch = json!({
/// "title": "Hello!",
/// "phoneNumber": "+01-123-456-7890",
/// "author": {
/// "familyName": null
/// },
/// "tags": [ "example" ]
/// });
///
/// merge(&mut doc, &patch);
/// assert_eq!(doc, json!({
/// "title": "Hello!",
/// "author" : {
/// "givenName" : "John"
/// },
/// "tags": [ "example" ],
/// "content": "This will be unchanged",
/// "phoneNumber": "+01-123-456-7890"
/// }));
/// # }
/// ```
pub fn merge(doc: &mut Value, patch: &Value) {
if !patch.is_object() {
*doc = patch.clone();
return;
}
if !doc.is_object() {
*doc = Value::Object(Map::new());
}
let map = doc.as_object_mut().unwrap();
for (key, value) in patch.as_object().unwrap() {
if value.is_null() {
map.remove(&*key);
} else {
merge(map.entry(key.clone()).or_insert(Value::Null), value);
}
}
}
#[cfg(feature = "diff")]
mod diff;
#[cfg(feature = "diff")]
pub use self::diff::diff;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,83 @@
#![allow(unused)]
extern crate rand;
mod util;
use super::*;
use serde_json::from_str;
#[test]
fn parse_from_value() {
use PatchOperation::*;
let json = json!([{"op": "add", "path": "/a/b", "value": 1}, {"op": "remove", "path": "/c"}]);
let patch: Patch = from_value(json).unwrap();
assert_eq!(
patch,
Patch(vec![
Add(AddOperation {
path: "/a/b".parse().unwrap(),
value: json!(1),
}),
Remove(RemoveOperation {
path: "/c".parse().unwrap(),
}),
])
);
let _patch: Patch =
from_str(r#"[{"op": "add", "path": "/a/b", "value": 1}, {"op": "remove", "path": "/c"}]"#)
.unwrap();
}
#[test]
fn parse_from_string() {
use PatchOperation::*;
let patch: Patch =
from_str(r#"[{"op": "add", "path": "/a/b", "value": 1}, {"op": "remove", "path": "/c"}]"#)
.unwrap();
assert_eq!(
patch,
Patch(vec![
Add(AddOperation {
path: "/a/b".parse().unwrap(),
value: json!(1),
}),
Remove(RemoveOperation {
path: "/c".parse().unwrap()
}),
])
);
}
#[test]
fn serialize_patch() {
let s = r#"[{"op":"add","path":"/a/b","value":1},{"op":"remove","path":"/c"}]"#;
let patch: Patch = from_str(s).unwrap();
let serialized = serde_json::to_string(&patch).unwrap();
assert_eq!(serialized, s);
}
#[test]
fn tests() {
util::run_specs("specs/tests.json");
}
#[test]
fn spec_tests() {
util::run_specs("specs/spec_tests.json");
}
#[test]
fn revert_tests() {
util::run_specs("specs/revert_tests.json");
}
#[test]
fn merge_tests() {
util::run_specs("specs/merge_tests.json");
}

View File

@@ -0,0 +1,103 @@
use imbl_value::Value;
use serde::Deserialize;
use std::fmt::Write;
use std::{fs, io};
#[derive(Debug, Deserialize)]
struct TestCase {
comment: Option<String>,
doc: Value,
patch: Value,
expected: Option<Value>,
error: Option<String>,
#[serde(default)]
disabled: bool,
#[serde(default)]
merge: bool,
}
fn run_case(doc: &Value, patches: &Value, merge_patch: bool) -> Result<Value, String> {
let mut actual = doc.clone();
if merge_patch {
crate::merge(&mut actual, &patches);
} else {
let patches: crate::Patch =
imbl_value::from_value(patches.clone()).map_err(|e| e.to_string())?;
// Patch and verify that in case of error document wasn't changed
crate::patch(&mut actual, &patches)
.map_err(|e| {
assert_eq!(
*doc, actual,
"no changes should be made to the original document"
);
e
})
.map_err(|e| e.to_string())?;
}
Ok(actual)
}
fn run_case_patch_unsafe(doc: &Value, patches: &Value) -> Result<Value, String> {
let mut actual = doc.clone();
let patches: crate::Patch =
imbl_value::from_value(patches.clone()).map_err(|e| e.to_string())?;
crate::patch_unsafe(&mut actual, &patches).map_err(|e| e.to_string())?;
Ok(actual)
}
pub fn run_specs(path: &str) {
let file = fs::File::open(path).unwrap();
let buf = io::BufReader::new(file);
let cases: Vec<TestCase> = serde_json::from_reader(buf).unwrap();
for (idx, tc) in cases.into_iter().enumerate() {
print!("Running test case {}", idx);
if let Some(comment) = tc.comment {
print!(" ({})... ", comment);
} else {
print!("... ");
}
if tc.disabled {
println!("disabled...");
continue;
}
match run_case(&tc.doc, &tc.patch, tc.merge) {
Ok(actual) => {
if let Some(ref error) = tc.error {
println!("expected to fail with '{}'", error);
panic!("expected to fail, got document {:?}", actual);
}
println!();
if let Some(ref expected) = tc.expected {
assert_eq!(*expected, actual);
}
}
Err(err) => {
println!("failed with '{}'", err);
tc.error.as_ref().expect("patch expected to succeed");
}
}
if !tc.merge {
match run_case_patch_unsafe(&tc.doc, &tc.patch) {
Ok(actual) => {
if let Some(ref error) = tc.error {
println!("expected to fail with '{}'", error);
panic!("expected to fail, got document {:?}", actual);
}
println!();
if let Some(ref expected) = tc.expected {
assert_eq!(*expected, actual);
}
}
Err(err) => {
println!("failed with '{}'", err);
tc.error.as_ref().expect("patch expected to succeed");
}
}
}
}
}