From 1164effdeba42cdf72c3e1e29371b4e7de09f142 Mon Sep 17 00:00:00 2001 From: Curtis La Graff Date: Mon, 19 Mar 2018 14:34:37 -0400 Subject: [PATCH 1/6] enable creating merge patches from arrays Signed-off-by: Curtis La Graff --- merge.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++----- merge_test.go | 55 ++++++++++++++++++++++++++++++-------- 2 files changed, 111 insertions(+), 17 deletions(-) diff --git a/merge.go b/merge.go index b9af252..6211873 100644 --- a/merge.go +++ b/merge.go @@ -1,6 +1,7 @@ package jsonpatch import ( + "bytes" "encoding/json" "fmt" "reflect" @@ -89,6 +90,7 @@ func pruneAryNulls(ary *partialArray) *partialArray { var errBadJSONDoc = fmt.Errorf("Invalid JSON Document") var errBadJSONPatch = fmt.Errorf("Invalid JSON Patch") +var errBadMergeTypes = fmt.Errorf("Mismatched JSON Documents") // MergeMergePatches merges two merge patches together, such that // applying this resulting merged merge patch to a document yields the same @@ -160,30 +162,89 @@ func doMergePatch(docData, patchData []byte, mergeMerge bool) ([]byte, error) { return json.Marshal(doc) } +func resemblesJSONArray(input []byte) bool { + input = bytes.TrimSpace(input) + + hasPrefix := bytes.HasPrefix(input, []byte("[")) + hasSuffix := bytes.HasSuffix(input, []byte("]")) + + return hasPrefix && hasSuffix +} + // CreateMergePatch creates a merge patch as specified in http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07 // // 'a' is original, 'b' is the modified document. Both are to be given as json encoded content. // The function will return a mergeable json document with differences from a to b. // // An error will be returned if any of the two documents are invalid. -func CreateMergePatch(a, b []byte) ([]byte, error) { - aI := map[string]interface{}{} - bI := map[string]interface{}{} - err := json.Unmarshal(a, &aI) +func CreateMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { + originalResemblesArray := resemblesJSONArray(originalJSON) + modifiedResemblesArray := resemblesJSONArray(modifiedJSON) + + if originalResemblesArray && modifiedResemblesArray { + return createArrayMergePatch(originalJSON, modifiedJSON) + } + + if !originalResemblesArray && !modifiedResemblesArray { + return createObjectMergePatch(originalJSON, modifiedJSON) + } + + return nil, errBadMergeTypes +} + +func createObjectMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { + originalDoc := map[string]interface{}{} + modifiedDoc := map[string]interface{}{} + + err := json.Unmarshal(originalJSON, &originalDoc) if err != nil { return nil, errBadJSONDoc } - err = json.Unmarshal(b, &bI) + + err = json.Unmarshal(modifiedJSON, &modifiedDoc) if err != nil { return nil, errBadJSONDoc } - dest, err := getDiff(aI, bI) + + dest, err := getDiff(originalDoc, modifiedDoc) if err != nil { return nil, err } + return json.Marshal(dest) } +func createArrayMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { + originalDocs := []json.RawMessage{} + modifiedDocs := []json.RawMessage{} + + err := json.Unmarshal(originalJSON, &originalDocs) + if err != nil { + return nil, errBadJSONDoc + } + + err = json.Unmarshal(modifiedJSON, &modifiedDocs) + if err != nil { + return nil, errBadJSONDoc + } + + res := []json.RawMessage{} + + for _, original := range originalDocs { + for _, modified := range modifiedDocs { + patch, err := createObjectMergePatch(original, modified) + if err != nil { + return nil, err + } + + // We get back bytes, but since we need to + res = append(res, json.RawMessage(patch)) + } + } + + return json.Marshal(res) +} + // Returns true if the array matches (must be json types). // As is idiomatic for go, an empty array is not the same as a nil array. func matchesArray(a, b []interface{}) bool { diff --git a/merge_test.go b/merge_test.go index 5d3ecff..79a92a2 100644 --- a/merge_test.go +++ b/merge_test.go @@ -184,7 +184,7 @@ func TestMergePatchFailRFCCases(t *testing.T) { } -func TestMergeReplaceKey(t *testing.T) { +func TestCreateMergePatchReplaceKey(t *testing.T) { doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }` pat := `{ "title": "goodbye", "nested": {"one": 2, "two": 2} }` @@ -201,7 +201,7 @@ func TestMergeReplaceKey(t *testing.T) { } } -func TestMergeGetArray(t *testing.T) { +func TestCreateMergePatchGetArray(t *testing.T) { doc := `{ "title": "hello", "array": ["one", "two"], "notmatch": [1, 2, 3] }` pat := `{ "title": "hello", "array": ["one", "two", "three"], "notmatch": [1, 2, 3] }` @@ -218,7 +218,7 @@ func TestMergeGetArray(t *testing.T) { } } -func TestMergeGetObjArray(t *testing.T) { +func TestCreateMergePatchGetObjArray(t *testing.T) { doc := `{ "title": "hello", "array": [{"banana": true}, {"evil": false}], "notmatch": [{"one":1}, {"two":2}, {"three":3}] }` pat := `{ "title": "hello", "array": [{"banana": false}, {"evil": true}], "notmatch": [{"one":1}, {"two":2}, {"three":3}] }` @@ -235,7 +235,7 @@ func TestMergeGetObjArray(t *testing.T) { } } -func TestMergeDeleteKey(t *testing.T) { +func TestCreateMergePatchDeleteKey(t *testing.T) { doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }` pat := `{ "title": "hello", "nested": {"one": 1} }` @@ -253,7 +253,7 @@ func TestMergeDeleteKey(t *testing.T) { } } -func TestMergeEmptyArray(t *testing.T) { +func TestCreateMergePatchEmptyArray(t *testing.T) { doc := `{ "array": null }` pat := `{ "array": [] }` @@ -288,7 +288,7 @@ func TestCreateMergePatchNil(t *testing.T) { } } -func TestMergeObjArray(t *testing.T) { +func TestCreateMergePatchObjArray(t *testing.T) { doc := `{ "array": [ {"a": {"b": 2}}, {"a": {"b": 3}} ]}` exp := `{}` @@ -304,7 +304,40 @@ func TestMergeObjArray(t *testing.T) { } } -func TestMergeComplexMatch(t *testing.T) { +func TestCreateMergePatchSameOuterArray(t *testing.T) { + doc := `[{"foo": "bar"}]` + pat := doc + exp := `[{}]` + + res, err := CreateMergePatch([]byte(doc), []byte(pat)) + + if err != nil { + t.Errorf("Unexpected error: %s, %s", err, string(res)) + } + + if exp != string(res) { + t.Fatalf("Outer array was not unmodified") + } +} + +func TestCreateMergePatchNoDifferences(t *testing.T) { + doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }` + pat := doc + + exp := `{}` + + res, err := CreateMergePatch([]byte(doc), []byte(pat)) + + if err != nil { + t.Errorf("Unexpected error: %s, %s", err, string(res)) + } + + if !compareJSON(exp, string(res)) { + t.Fatalf("Key was not replaced") + } +} + +func TestCreateMergePatchComplexMatch(t *testing.T) { doc := `{"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4], "nested": {"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4]} }` empty := `{}` res, err := CreateMergePatch([]byte(doc), []byte(doc)) @@ -319,7 +352,7 @@ func TestMergeComplexMatch(t *testing.T) { } } -func TestMergeComplexAddAll(t *testing.T) { +func TestCreateMergePatchComplexAddAll(t *testing.T) { doc := `{"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4], "nested": {"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4]} }` empty := `{}` res, err := CreateMergePatch([]byte(empty), []byte(doc)) @@ -333,7 +366,7 @@ func TestMergeComplexAddAll(t *testing.T) { } } -func TestMergeComplexRemoveAll(t *testing.T) { +func TestCreateMergePatchComplexRemoveAll(t *testing.T) { doc := `{"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4], "nested": {"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4]} }` exp := `{"a":null,"f":null,"hello":null,"i":null,"n":null,"nested":null,"pi":null,"t":null}` empty := `{}` @@ -355,7 +388,7 @@ func TestMergeComplexRemoveAll(t *testing.T) { */ } -func TestMergeObjectWithInnerArray(t *testing.T) { +func TestCreateMergePatchObjectWithInnerArray(t *testing.T) { stateString := `{ "OuterArray": [ { @@ -379,7 +412,7 @@ func TestMergeObjectWithInnerArray(t *testing.T) { } } -func TestMergeReplaceKeyNotEscape(t *testing.T) { +func TestCreateMergePatchReplaceKeyNotEscape(t *testing.T) { doc := `{ "title": "hello", "nested": {"title/escaped": 1, "two": 2} }` pat := `{ "title": "goodbye", "nested": {"title/escaped": 2, "two": 2} }` From f229dc6b046f9042647f6201903e27969deeb9ee Mon Sep 17 00:00:00 2001 From: Curtis La Graff Date: Mon, 19 Mar 2018 16:28:21 -0400 Subject: [PATCH 2/6] improve merge patch tests Signed-off-by: Curtis La Graff --- merge.go | 20 ++++++++++++-------- merge_test.go | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/merge.go b/merge.go index 6211873..eb6298e 100644 --- a/merge.go +++ b/merge.go @@ -230,16 +230,20 @@ func createArrayMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { res := []json.RawMessage{} - for _, original := range originalDocs { - for _, modified := range modifiedDocs { - patch, err := createObjectMergePatch(original, modified) - if err != nil { - return nil, err - } + total := len(originalDocs) + if len(modifiedDocs) != total { + return nil, errBadJSONDoc + } - // We get back bytes, but since we need to - res = append(res, json.RawMessage(patch)) + for i := 0; i < len(originalDocs); i++ { + original := originalDocs[i] + modified := modifiedDocs[i] + patch, err := createObjectMergePatch(original, modified) + if err != nil { + return nil, err } + + res = append(res, json.RawMessage(patch)) } return json.Marshal(res) diff --git a/merge_test.go b/merge_test.go index 79a92a2..a94ab07 100644 --- a/merge_test.go +++ b/merge_test.go @@ -315,11 +315,49 @@ func TestCreateMergePatchSameOuterArray(t *testing.T) { t.Errorf("Unexpected error: %s, %s", err, string(res)) } - if exp != string(res) { + if !compareJSON(exp, string(res)) { t.Fatalf("Outer array was not unmodified") } } +func TestCreateMergePatchModifiedOuterArray(t *testing.T) { + doc := `[{"name": "John"}, {"name": "Will"}]` + pat := `[{"name": "Jane"}, {"name": "Will"}]` + exp := `[{"name": "Jane"}, {}]` + + res, err := CreateMergePatch([]byte(doc), []byte(pat)) + + if err != nil { + t.Errorf("Unexpected error: %s, %s", err, string(res)) + } + + if !compareJSON(exp, string(res)) { + t.Fatalf("Expected %s but received %s", exp, res) + } +} + +func TestCreateMergePatchMismatchedOuterArray(t *testing.T) { + doc := `[{"name": "John"}, {"name": "Will"}]` + pat := `[{"name": "Jane"}]` + + _, err := CreateMergePatch([]byte(doc), []byte(pat)) + + if err == nil { + t.Errorf("Expected error due to array length differences but received none") + } +} + +func TestCreateMergePatchMismatchedOuterTypes(t *testing.T) { + doc := `[{"name": "John"}]` + pat := `{"name": "Jane"}` + + _, err := CreateMergePatch([]byte(doc), []byte(pat)) + + if err == nil { + t.Errorf("Expected error due to mismatched types but received none") + } +} + func TestCreateMergePatchNoDifferences(t *testing.T) { doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }` pat := doc From 51e91652a583685323d4e0cd8d71ca345e634577 Mon Sep 17 00:00:00 2001 From: Curtis La Graff Date: Mon, 19 Mar 2018 16:35:08 -0400 Subject: [PATCH 3/6] improve docstrings for merge patch func Signed-off-by: Curtis La Graff --- merge.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/merge.go b/merge.go index eb6298e..6209499 100644 --- a/merge.go +++ b/merge.go @@ -171,12 +171,9 @@ func resemblesJSONArray(input []byte) bool { return hasPrefix && hasSuffix } -// CreateMergePatch creates a merge patch as specified in http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07 -// -// 'a' is original, 'b' is the modified document. Both are to be given as json encoded content. -// The function will return a mergeable json document with differences from a to b. -// -// An error will be returned if any of the two documents are invalid. +// CreateMergePatch will return a merge-patch document capable of converting +// the original document(s) to the modified document(s). +// The merge patch is as specified in http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07 func CreateMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { originalResemblesArray := resemblesJSONArray(originalJSON) modifiedResemblesArray := resemblesJSONArray(modifiedJSON) @@ -192,6 +189,8 @@ func CreateMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { return nil, errBadMergeTypes } +// createObjectMergePatch will return a merge-patch document capable of +// converting the original document to the modified document. func createObjectMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { originalDoc := map[string]interface{}{} modifiedDoc := map[string]interface{}{} @@ -214,6 +213,10 @@ func createObjectMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { return json.Marshal(dest) } +// createArrayMergePatch will return an array of merge-patch documents capable +// of converting the original document to the modified document for each +// pair of JSON documents provided in the arrays. +// Arrays of mismatched sizes will result in an error. func createArrayMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { originalDocs := []json.RawMessage{} modifiedDocs := []json.RawMessage{} @@ -228,25 +231,25 @@ func createArrayMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { return nil, errBadJSONDoc } - res := []json.RawMessage{} - total := len(originalDocs) if len(modifiedDocs) != total { return nil, errBadJSONDoc } + result := []json.RawMessage{} for i := 0; i < len(originalDocs); i++ { original := originalDocs[i] modified := modifiedDocs[i] + patch, err := createObjectMergePatch(original, modified) if err != nil { return nil, err } - res = append(res, json.RawMessage(patch)) + result = append(result, json.RawMessage(patch)) } - return json.Marshal(res) + return json.Marshal(result) } // Returns true if the array matches (must be json types). From d305e35bc23b88f9d83c33e5d8e57bd66ddd6bab Mon Sep 17 00:00:00 2001 From: Curtis La Graff Date: Wed, 21 Mar 2018 12:28:59 -0400 Subject: [PATCH 4/6] add missing test Signed-off-by: Curtis La Graff --- merge_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/merge_test.go b/merge_test.go index a94ab07..994b1b5 100644 --- a/merge_test.go +++ b/merge_test.go @@ -184,6 +184,49 @@ func TestMergePatchFailRFCCases(t *testing.T) { } +func TestResembleJSONArray(t *testing.T) { + testCases := []struct { + input []byte + expected bool + }{ + // Failure cases + {input: []byte(``), expected: false}, + {input: []byte(`not an array`), expected: false}, + {input: []byte(`{"foo": "bar"}`), expected: false}, + {input: []byte(`{"fizz": ["buzz"]}`), expected: false}, + {input: []byte(`[bad suffix`), expected: false}, + {input: []byte(`bad prefix]`), expected: false}, + {input: []byte(`][`), expected: false}, + + // Valid cases + {input: []byte(`[]`), expected: true}, + {input: []byte(`["foo", "bar"]`), expected: true}, + {input: []byte(`[["foo", "bar"]]`), expected: true}, + {input: []byte(`[not valid syntax]`), expected: true}, + + // Valid cases with whitespace + {input: []byte(` []`), expected: true}, + {input: []byte(`[] `), expected: true}, + {input: []byte(` [] `), expected: true}, + {input: []byte(` [ ] `), expected: true}, + {input: []byte("\t[]"), expected: true}, + {input: []byte("[]\n"), expected: true}, + {input: []byte("\n\t\r[]"), expected: true}, + } + + for _, test := range testCases { + result := resemblesJSONArray(test.input) + if result != test.expected { + t.Errorf( + `expected "%t" but received "%t" for case: "%s"`, + test.expected, + result, + string(test.input), + ) + } + } +} + func TestCreateMergePatchReplaceKey(t *testing.T) { doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }` pat := `{ "title": "goodbye", "nested": {"one": 2, "two": 2} }` From 05e46dc5e3db2d6e102a9d2275f253081d35bb64 Mon Sep 17 00:00:00 2001 From: Curtis La Graff Date: Wed, 21 Mar 2018 12:34:21 -0400 Subject: [PATCH 5/6] add missing docstring Signed-off-by: Curtis La Graff --- merge.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/merge.go b/merge.go index 6209499..9f11bda 100644 --- a/merge.go +++ b/merge.go @@ -162,6 +162,11 @@ func doMergePatch(docData, patchData []byte, mergeMerge bool) ([]byte, error) { return json.Marshal(doc) } +// resemblesJSONArray indicates whether the byte-slice "appears" to be +// a JSON array or not. +// False-positives are possible, as this function does not check the internal +// structure of the array. It only checks that the outer syntax is present and +// correct. func resemblesJSONArray(input []byte) bool { input = bytes.TrimSpace(input) @@ -171,9 +176,11 @@ func resemblesJSONArray(input []byte) bool { return hasPrefix && hasSuffix } -// CreateMergePatch will return a merge-patch document capable of converting +// CreateMergePatch will return a merge patch document capable of converting // the original document(s) to the modified document(s). -// The merge patch is as specified in http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07 +// The parameters can be bytes of either two JSON Documents, or two arrays of +// JSON documents. +// The merge patch returned follows the specification defined at http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07 func CreateMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { originalResemblesArray := resemblesJSONArray(originalJSON) modifiedResemblesArray := resemblesJSONArray(modifiedJSON) From ce89457d243ba54a514dbfe218dcb60ca331ca5a Mon Sep 17 00:00:00 2001 From: Curtis La Graff Date: Wed, 21 Mar 2018 12:37:49 -0400 Subject: [PATCH 6/6] add comments to CreateMergePatch Signed-off-by: Curtis La Graff --- merge.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/merge.go b/merge.go index 9f11bda..6806c4c 100644 --- a/merge.go +++ b/merge.go @@ -185,14 +185,17 @@ func CreateMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) { originalResemblesArray := resemblesJSONArray(originalJSON) modifiedResemblesArray := resemblesJSONArray(modifiedJSON) + // Do both byte-slices seem like JSON arrays? if originalResemblesArray && modifiedResemblesArray { return createArrayMergePatch(originalJSON, modifiedJSON) } + // Are both byte-slices are not arrays? Then they are likely JSON objects... if !originalResemblesArray && !modifiedResemblesArray { return createObjectMergePatch(originalJSON, modifiedJSON) } + // None of the above? Then return an error because of mismatched types. return nil, errBadMergeTypes }