diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ce5adb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +vendor diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4ee07d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/denouche/go-json-patch-jsonpath + +go 1.18 + +require github.com/ohler55/ojg v1.17.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d073c0e --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/ohler55/ojg v1.17.1 h1:tctLinqjSLksyXOip6ALetxK4l9CRGH6K5KSYUYUrYI= +github.com/ohler55/ojg v1.17.1/go.mod h1:7Ghirupn8NC8hSSDpI0gcjorPxj+vSVIONDWfliHR1k= +github.com/ohler55/ojg v1.17.2 h1:e8vUJa6l9v9li0T5xqQIDhVFnialk21LA1biXJlu0Rs= +github.com/ohler55/ojg v1.17.2/go.mod h1:7Ghirupn8NC8hSSDpI0gcjorPxj+vSVIONDWfliHR1k= diff --git a/model_patchrequest.go b/model_patchrequest.go new file mode 100644 index 0000000..baa7fdb --- /dev/null +++ b/model_patchrequest.go @@ -0,0 +1,50 @@ +package jsonpatch + +import ( + "encoding/json" + "errors" + "fmt" +) + +const ( + PatchOperationAdd = "add" + PatchOperationRemove = "remove" + PatchOperationReplace = "replace" +) + +type PatchOperation string + +type PatchRequest[T any] struct { + Operation PatchOperation `json:"op" validate:"required,oneof=remove replace"` // TODO implements add + Path string `json:"path" validate:"required,jsonpath,ne=$"` + Value any `json:"value"` +} + +// Apply TODO +func (pr *PatchRequest[T]) Apply(initialResource *T, emptyResource *T) (*T, error) { + switch pr.Operation { + case PatchOperationReplace: + return pr.replace(initialResource, emptyResource) + case PatchOperationRemove: + return pr.remove(initialResource, emptyResource) + case PatchOperationAdd: + //TODO make the implementation + fallthrough // fallthrough for now + default: + return nil, errors.New("operation not implemented") + } +} + +func (pr *PatchRequest[T]) remarshal(resourceAsMap interface{}, emptyResource *T) (*T, error) { + newBytes, err := json.Marshal(resourceAsMap) + if err != nil { + return nil, fmt.Errorf("match fail to marshal input resource %s", err.Error()) + } + + err = json.Unmarshal(newBytes, &emptyResource) + if err != nil { + return nil, fmt.Errorf("match fail to unmarshal %s", err.Error()) + } + + return emptyResource, nil +} diff --git a/model_patchrequests.go b/model_patchrequests.go new file mode 100644 index 0000000..594d391 --- /dev/null +++ b/model_patchrequests.go @@ -0,0 +1,18 @@ +package jsonpatch + +type PatchRequests[T any] struct { + Patches []*PatchRequest[T] `json:"patches" validate:"required,min=1,dive,required"` +} + +// Apply TODO +func (prs *PatchRequests[T]) Apply(initialResource *T, newer func() *T) (*T, error) { + var err error + patched := initialResource + for _, pr := range prs.Patches { + patched, err = pr.Apply(initialResource, newer()) + if err != nil { + return nil, err + } + } + return patched, nil +} diff --git a/model_test.go b/model_test.go new file mode 100644 index 0000000..6151ad8 --- /dev/null +++ b/model_test.go @@ -0,0 +1,21 @@ +package jsonpatch + +type MyStruct struct { + FieldString string `json:"field_string"` + FieldStringPtr *string `json:"field_string_ptr"` + FieldMapStringString map[string]string `json:"field_map_string_string"` + FieldArrayString []string `json:"field_array_string"` + FieldStruct *MySubStruct `json:"field_struct"` + FieldArrayStruct []*MySubStruct `json:"field_array_struct"` +} + +type MySubStruct struct { + FieldIntPtr *int `json:"field_int_ptr"` + FieldBool bool `json:"field_bool"` + FieldString1 string `json:"field_string_sub1"` + FieldString2 string `json:"field_string_sub2"` +} + +func getPtr[T any](in T) *T { + return &in +} diff --git a/operation_remove.go b/operation_remove.go new file mode 100644 index 0000000..41cd950 --- /dev/null +++ b/operation_remove.go @@ -0,0 +1,33 @@ +package jsonpatch + +import ( + "encoding/json" + "fmt" + + "github.com/ohler55/ojg/jp" +) + +func (pr *PatchRequest[T]) remove(initialResource *T, emptyResource *T) (*T, error) { + compiledPath, err := jp.ParseString(pr.Path) + if err != nil { + return nil, fmt.Errorf("fail to compile path %s %s", pr.Path, err.Error()) + } + + b, err := json.Marshal(initialResource) + if err != nil { + return nil, fmt.Errorf("match fail to marshal input resource %s", err.Error()) + } + + var resourceAsMap interface{} + err = json.Unmarshal(b, &resourceAsMap) + if err != nil { + return nil, fmt.Errorf("match fail to unmarshal %s", err.Error()) + } + + resourceAsMapModified, err := compiledPath.Remove(resourceAsMap) + if err != nil { + return nil, fmt.Errorf("error while deleting nodes %s", err.Error()) + } + + return pr.remarshal(resourceAsMapModified, emptyResource) +} diff --git a/operation_remove_test.go b/operation_remove_test.go new file mode 100644 index 0000000..701d552 --- /dev/null +++ b/operation_remove_test.go @@ -0,0 +1,81 @@ +package jsonpatch + +import ( + "encoding/json" + "reflect" + "testing" +) + +type applyRemoveTestCase[T any] struct { + name string + patches []*PatchRequest[MyStruct] + input T + newEmptyInputFunc func() T + expectError bool + expect T +} + +func TestPatchRequest_applyRemove(t *testing.T) { + testCases := []applyRemoveTestCase[*MyStruct]{ + { + name: "remove_string", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "remove", + Path: "$.field_string", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldString: "foo", + }, + expectError: false, + expect: &MyStruct{ + FieldString: "", + }, + }, + + { + name: "remove_string_ptr", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "remove", + Path: "$.field_string_ptr", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldStringPtr: getPtr("foo"), + }, + expectError: false, + expect: &MyStruct{ + FieldStringPtr: nil, + }, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + var err error + var patched *MyStruct + patched = tt.input + for _, pr := range tt.patches { + patched, err = pr.remove(patched, tt.newEmptyInputFunc()) + } + + if (err != nil) != tt.expectError { + t.Errorf("applyRemove() error = %v, expectError %v", err, tt.expectError) + return + } + if !reflect.DeepEqual(patched, tt.expect) { + bPatched, _ := json.Marshal(patched) + bExpected, _ := json.Marshal(tt.expect) + t.Errorf("applyRemove() got = %s", string(bPatched)) + t.Errorf("applyRemove() expect = %s", string(bExpected)) + } + }) + } +} diff --git a/operation_replace.go b/operation_replace.go new file mode 100644 index 0000000..42992a2 --- /dev/null +++ b/operation_replace.go @@ -0,0 +1,33 @@ +package jsonpatch + +import ( + "encoding/json" + "fmt" + + "github.com/ohler55/ojg/jp" +) + +func (pr *PatchRequest[T]) replace(resource *T, emptyResource *T) (*T, error) { + compiledPath, err := jp.ParseString(pr.Path) + if err != nil { + return nil, fmt.Errorf("fail to compile path %s %s", pr.Path, err.Error()) + } + + b, err := json.Marshal(resource) + if err != nil { + return nil, fmt.Errorf("match fail to marshal input resource %s", err.Error()) + } + + var resourceAsMap interface{} + err = json.Unmarshal(b, &resourceAsMap) + if err != nil { + return nil, fmt.Errorf("match fail to unmarshal %s", err.Error()) + } + + err = compiledPath.Set(resourceAsMap, pr.Value) + if err != nil { + return nil, err + } + + return pr.remarshal(resourceAsMap, emptyResource) +} diff --git a/operation_replace_test.go b/operation_replace_test.go new file mode 100644 index 0000000..c6821c0 --- /dev/null +++ b/operation_replace_test.go @@ -0,0 +1,343 @@ +package jsonpatch + +import ( + "encoding/json" + "reflect" + "testing" +) + +type applyReplaceTestCase[T any] struct { + name string + patches []*PatchRequest[MyStruct] + input *T + newEmptyInputFunc func() *T + expectError bool + expect *T +} + +func TestPatchRequest_applyReplace(t *testing.T) { + testCases := []applyReplaceTestCase[MyStruct]{ + { + name: "replace_string", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: "$.field_string", + Value: "bar", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldString: "foo", + }, + expectError: false, + expect: &MyStruct{ + FieldString: "bar", + }, + }, + + { + name: "replace_int", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: "$.field_struct.field_int_ptr", + Value: 42, + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldStruct: &MySubStruct{ + FieldIntPtr: getPtr(15), + }, + }, + expectError: false, + expect: &MyStruct{ + FieldStruct: &MySubStruct{ + FieldIntPtr: getPtr(42), + }, + }, + }, + + { + name: "replace_struct", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: "$.field_struct", + Value: &MySubStruct{ + FieldBool: true, + }, + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldStruct: &MySubStruct{ + FieldBool: false, + }, + }, + expectError: false, + expect: &MyStruct{ + FieldStruct: &MySubStruct{ + FieldBool: true, + }, + }, + }, + + { + name: "replace_map_string_string", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: "$.field_map_string_string.field1", + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1": "value1", + "field2": "value2", + }, + }, + expectError: false, + expect: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1": "newvalue", + "field2": "value2", + }, + }, + }, + + { + name: "replace_map_string_string_brackets", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: "$.field_map_string_string['field1']", + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1": "value1", + "field2": "value2", + }, + }, + expectError: false, + expect: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1": "newvalue", + "field2": "value2", + }, + }, + }, + + { + name: "replace_map_string_string_brackets_complex", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: "$.field_map_string_string['field1/foo.bar']", + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1/foo.bar": "value1", + "field2": "value2", + }, + }, + expectError: false, + expect: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1/foo.bar": "newvalue", + "field2": "value2", + }, + }, + }, + + { + name: "replace_map_string_string_brackets_complex_double_quotes", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: `$.field_map_string_string["field1/foo.bar"]`, + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1/foo.bar": "value1", + "field2": "value2", + }, + }, + expectError: false, + expect: &MyStruct{ + FieldMapStringString: map[string]string{ + "field1/foo.bar": "newvalue", + "field2": "value2", + }, + }, + }, + + { + name: "replace_array_string_by_index", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: `$.field_array_string[0]`, + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldArrayString: []string{"m1", "m2"}, + }, + expectError: false, + expect: &MyStruct{ + FieldArrayString: []string{"newvalue", "m2"}, + }, + }, + + { + name: "replace_struct_array_string_by_filter_by_string", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: `$.field_array_struct[?(@.field_string_sub1 == 'string1')].field_string_sub2`, + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldArrayStruct: []*MySubStruct{ + { + FieldString1: "string1", + FieldString2: "str1", + }, + { + FieldString1: "string2", + FieldString2: "str2", + }, + { + FieldString1: "string1", + FieldString2: "str3", + }, + }, + }, + expectError: false, + expect: &MyStruct{ + FieldArrayStruct: []*MySubStruct{ + { + FieldString1: "string1", + FieldString2: "newvalue", + }, + { + FieldString1: "string2", + FieldString2: "str2", + }, + { + FieldString1: "string1", + FieldString2: "newvalue", + }, + }, + }, + }, + + { + name: "replace_struct_array_string_by_filter_by_string_and", + patches: []*PatchRequest[MyStruct]{ + { + Operation: "replace", + Path: `$.field_array_struct[?(@.field_string_sub1 == 'string1' && @.field_int_ptr == 42)].field_string_sub2`, + Value: "newvalue", + }, + }, + newEmptyInputFunc: func() *MyStruct { + return &MyStruct{} + }, + input: &MyStruct{ + FieldArrayStruct: []*MySubStruct{ + { + FieldString1: "string1", + FieldIntPtr: getPtr(12), + FieldString2: "str1", + }, + { + FieldString1: "string2", + FieldIntPtr: getPtr(42), + FieldString2: "str2", + }, + { + FieldString1: "string1", + FieldIntPtr: getPtr(42), + FieldString2: "str3", + }, + }, + }, + expectError: false, + expect: &MyStruct{ + FieldArrayStruct: []*MySubStruct{ + { + FieldString1: "string1", + FieldIntPtr: getPtr(12), + FieldString2: "str1", + }, + { + FieldString1: "string2", + FieldIntPtr: getPtr(42), + FieldString2: "str2", + }, + { + FieldString1: "string1", + FieldIntPtr: getPtr(42), + FieldString2: "newvalue", + }, + }, + }, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + var err error + var patched *MyStruct + patched = tt.input + for _, pr := range tt.patches { + patched, err = pr.replace(patched, tt.newEmptyInputFunc()) + } + + if (err != nil) != tt.expectError { + t.Errorf("applyReplace() error = %v, expectError %v", err, tt.expectError) + return + } + if !reflect.DeepEqual(patched, tt.expect) { + bPatched, _ := json.Marshal(patched) + bExpected, _ := json.Marshal(tt.expect) + t.Errorf("applyReplace() got = %s", string(bPatched)) + t.Errorf("applyReplace() expect = %s", string(bExpected)) + } + }) + } +}