/* Copyright 2019 The Crossplane Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package fieldpath import ( "fmt" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/json" "github.com/crossplane/crossplane-runtime/pkg/errors" "github.com/crossplane/crossplane-runtime/pkg/test" ) func TestIsNotFound(t *testing.T) { cases := map[string]struct { reason string err error want bool }{ "NotFound": { reason: "An error with method `IsNotFound() bool` should be considered a not found error.", err: notFoundError{errors.New("boom")}, want: true, }, "WrapsNotFound": { reason: "An error that wraps an error with method `IsNotFound() bool` should be considered a not found error.", err: errors.Wrap(notFoundError{errors.New("boom")}, "because reasons"), want: true, }, "SomethingElse": { reason: "An error without method `IsNotFound() bool` should not be considered a not found error.", err: errors.New("boom"), want: false, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { got := IsNotFound(tc.err) if got != tc.want { t.Errorf("IsNotFound(...): Want %t, got %t", tc.want, got) } }) } } func TestGetValue(t *testing.T) { type want struct { value any err error } cases := map[string]struct { reason string path string data []byte want want }{ "MetadataName": { reason: "It should be possible to get a field from a nested object", path: "metadata.name", data: []byte(`{"metadata":{"name":"cool"}}`), want: want{ value: "cool", }, }, "ContainerName": { reason: "It should be possible to get a field from an object array element", path: "spec.containers[0].name", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), want: want{ value: "cool", }, }, "NestedArray": { reason: "It should be possible to get a field from a nested array", path: "items[0][1]", data: []byte(`{"items":[["a", "b"]]}`), want: want{ value: "b", }, }, "OwnerRefController": { reason: "Requesting a boolean field path should work.", path: "metadata.ownerRefs[0].controller", data: []byte(`{"metadata":{"ownerRefs":[{"controller": true}]}}`), want: want{ value: true, }, }, "MetadataVersion": { reason: "Requesting an integer field should work", path: "metadata.version", data: []byte(`{"metadata":{"version":2}}`), want: want{ value: int64(2), }, }, "SomeFloat": { reason: "Requesting a float field should work", path: "metadata.version", data: []byte(`{"metadata":{"version":2.0}}`), want: want{ value: float64(2), }, }, "MetadataNope": { reason: "Requesting a non-existent object field should fail", path: "metadata.name", data: []byte(`{"metadata":{"nope":"cool"}}`), want: want{ err: notFoundError{errors.New("metadata.name: no such field")}, }, }, "InsufficientContainers": { reason: "Requesting a non-existent array element should fail", path: "spec.containers[1].name", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), want: want{ err: notFoundError{errors.New("spec.containers[1]: no such element")}, }, }, "NotAnArray": { reason: "Indexing an object should fail", path: "metadata[1]", data: []byte(`{"metadata":{"nope":"cool"}}`), want: want{ err: errors.New("metadata: not an array"), }, }, "NotAnObject": { reason: "Requesting a field in an array should fail", path: "spec.containers[nope].name", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), want: want{ err: errors.New("spec.containers: not an object"), }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NilParent": { reason: "Request for a path with a nil parent value", path: "spec.containers[*].name", data: []byte(`{"spec":{"containers": null}}`), want: want{ err: notFoundError{errors.Errorf("%s: expected map, got nil", "spec.containers")}, }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.GetValue(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetValue(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.value, got); diff != "" { t.Errorf("\np.GetValue(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestGetValueInto(t *testing.T) { type Struct struct { Slice []string `json:"slice"` StringField string `json:"string"` IntField int `json:"int"` } type Slice []string type args struct { path string out any } type want struct { out any err error } cases := map[string]struct { reason string data []byte args args want want }{ "Struct": { reason: "It should be possible to get a value into a struct.", data: []byte(`{"s":{"slice":["a"],"string":"b","int":1}}`), args: args{ path: "s", out: &Struct{}, }, want: want{ out: &Struct{Slice: []string{"a"}, StringField: "b", IntField: 1}, }, }, "Slice": { reason: "It should be possible to get a value into a slice.", data: []byte(`{"s": ["a", "b"]}`), args: args{ path: "s", out: &Slice{}, }, want: want{ out: &Slice{"a", "b"}, }, }, "MissingPath": { reason: "Getting a value from a fieldpath that doesn't exist should return an error.", data: []byte(`{}`), args: args{ path: "s", out: &Struct{}, }, want: want{ out: &Struct{}, err: notFoundError{errors.New("s: no such field")}, }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) err := p.GetValueInto(tc.args.path, tc.args.out) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetValueInto(%s): %s: -want error, +got error:\n%s", tc.args.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.out, tc.args.out); diff != "" { t.Errorf("\np.GetValueInto(%s): %s: -want, +got:\n%s", tc.args.path, tc.reason, diff) } }) } } func TestGetString(t *testing.T) { type want struct { value string err error } cases := map[string]struct { reason string path string data []byte want want }{ "MetadataName": { reason: "It should be possible to get a field from a nested object", path: "metadata.name", data: []byte(`{"metadata":{"name":"cool"}}`), want: want{ value: "cool", }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NotAString": { reason: "Requesting an non-string field path should fail", path: "metadata.version", data: []byte(`{"metadata":{"version":2}}`), want: want{ err: errors.New("metadata.version: not a string"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.GetString(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetString(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.value, got); diff != "" { t.Errorf("\np.GetString(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestGetStringArray(t *testing.T) { type want struct { value []string err error } cases := map[string]struct { reason string path string data []byte want want }{ "MetadataLabels": { reason: "It should be possible to get a field from a nested object", path: "spec.containers[0].command", data: []byte(`{"spec": {"containers": [{"command": ["/bin/bash"]}]}}`), want: want{ value: []string{"/bin/bash"}, }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NotAnArray": { reason: "Requesting an non-object field path should fail", path: "metadata.version", data: []byte(`{"metadata":{"version":2}}`), want: want{ err: errors.New("metadata.version: not an array"), }, }, "NotAStringArray": { reason: "Requesting an non-string-object field path should fail", path: "metadata.versions", data: []byte(`{"metadata":{"versions":[1,2]}}`), want: want{ err: errors.New("metadata.versions: not an array of strings"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.GetStringArray(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetStringArray(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.value, got); diff != "" { t.Errorf("\np.GetStringArray(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestGetStringObject(t *testing.T) { type want struct { value map[string]string err error } cases := map[string]struct { reason string path string data []byte want want }{ "MetadataLabels": { reason: "It should be possible to get a field from a nested object", path: "metadata.labels", data: []byte(`{"metadata":{"labels":{"cool":"true"}}}`), want: want{ value: map[string]string{"cool": "true"}, }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NotAnObject": { reason: "Requesting an non-object field path should fail", path: "metadata.version", data: []byte(`{"metadata":{"version":2}}`), want: want{ err: errors.New("metadata.version: not an object"), }, }, "NotAStringObject": { reason: "Requesting an non-string-object field path should fail", path: "metadata.versions", data: []byte(`{"metadata":{"versions":{"a": 2}}}`), want: want{ err: errors.New("metadata.versions: not an object with string field values"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.GetStringObject(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetStringObject(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.value, got); diff != "" { t.Errorf("\np.GetStringObject(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestGetBool(t *testing.T) { type want struct { value bool err error } cases := map[string]struct { reason string path string data []byte want want }{ "OwnerRefController": { reason: "Requesting a boolean field path should work.", path: "metadata.ownerRefs[0].controller", data: []byte(`{"metadata":{"ownerRefs":[{"controller": true}]}}`), want: want{ value: true, }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NotABool": { reason: "Requesting an non-boolean field path should fail", path: "metadata.name", data: []byte(`{"metadata":{"name":"cool"}}`), want: want{ err: errors.New("metadata.name: not a bool"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.GetBool(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetBool(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.value, got); diff != "" { t.Errorf("\np.GetBool(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestGetInteger(t *testing.T) { type want struct { value int64 err error } cases := map[string]struct { reason string path string data []byte want want }{ "MetadataVersion": { reason: "Requesting a number field should work", path: "metadata.version", data: []byte(`{"metadata":{"version":2}}`), want: want{ value: 2, }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NotANumber": { reason: "Requesting an non-number field path should fail", path: "metadata.name", data: []byte(`{"metadata":{"name":"cool"}}`), want: want{ err: errors.New("metadata.name: not a (int64) number"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.GetInteger(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.GetNumber(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.value, got); diff != "" { t.Errorf("\np.GetNumber(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestSetValue(t *testing.T) { type args struct { path string value any opts []PavedOption } type want struct { object map[string]any err error } cases := map[string]struct { reason string data []byte args args want want }{ "MetadataName": { reason: "Setting an object field should work", data: []byte(`{"metadata":{"name":"lame"}}`), args: args{ path: "metadata.name", value: "cool", }, want: want{ object: map[string]any{ "metadata": map[string]any{ "name": "cool", }, }, }, }, "NonExistentMetadataName": { reason: "Setting a non-existent object field should work", data: []byte(`{}`), args: args{ path: "metadata.name", value: "cool", }, want: want{ object: map[string]any{ "metadata": map[string]any{ "name": "cool", }, }, }, }, "ContainerName": { reason: "Setting a field of an object that is an array element should work", data: []byte(`{"spec":{"containers":[{"name":"lame"}]}}`), args: args{ path: "spec.containers[0].name", value: "cool", }, want: want{ object: map[string]any{ "spec": map[string]any{ "containers": []any{ map[string]any{ "name": "cool", }, }, }, }, }, }, "NonExistentContainerName": { reason: "Setting a field of a non-existent object that is an array element should work", data: []byte(`{}`), args: args{ path: "spec.containers[0].name", value: "cool", }, want: want{ object: map[string]any{ "spec": map[string]any{ "containers": []any{ map[string]any{ "name": "cool", }, }, }, }, }, }, "NewContainer": { reason: "Growing an array object field should work", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), args: args{ path: "spec.containers[1].name", value: "cooler", }, want: want{ object: map[string]any{ "spec": map[string]any{ "containers": []any{ map[string]any{ "name": "cool", }, map[string]any{ "name": "cooler", }, }, }, }, }, }, "NestedArray": { reason: "Setting a value in a nested array should work", data: []byte(`{}`), args: args{ path: "data[0][0]", value: "a", }, want: want{ object: map[string]any{ "data": []any{ []any{"a"}, }, }, }, }, "GrowNestedArray": { reason: "Growing then setting a value in a nested array should work", data: []byte(`{"data":[["a"]]}`), args: args{ path: "data[0][1]", value: "b", }, want: want{ object: map[string]any{ "data": []any{ []any{"a", "b"}, }, }, }, }, "GrowArrayField": { reason: "Growing then setting a value in an array field should work", data: []byte(`{"data":["a"]}`), args: args{ path: "data[2]", value: "c", }, want: want{ object: map[string]any{ "data": []any{"a", nil, "c"}, }, }, }, "RejectsHighIndexes": { reason: "Paths having indexes above the maximum default value are rejected", data: []byte(`{"data":["a"]}`), args: args{ path: fmt.Sprintf("data[%v]", DefaultMaxFieldPathIndex+1), value: "c", }, want: want{ object: map[string]any{ "data": []any{"a"}, }, err: errors.Errorf("index %v is greater than max allowed index %v", DefaultMaxFieldPathIndex+1, DefaultMaxFieldPathIndex), }, }, "NotRejectsHighIndexesIfNoDefaultOptions": { reason: "Paths having indexes above the maximum default value are not rejected if default disabled", data: []byte(`{"data":["a"]}`), args: args{ path: fmt.Sprintf("data[%v]", DefaultMaxFieldPathIndex+1), value: "c", opts: []PavedOption{WithMaxFieldPathIndex(0)}, }, want: want{ object: map[string]any{ "data": func() []any { res := make([]any, DefaultMaxFieldPathIndex+2) res[0] = "a" res[DefaultMaxFieldPathIndex+1] = "c" return res }(), }, }, }, "MapStringString": { reason: "A map of string to string should be converted to a map of string to any", data: []byte(`{"metadata":{}}`), args: args{ path: "metadata.labels", value: map[string]string{"cool": "very"}, }, want: want{ object: map[string]any{ "metadata": map[string]any{ "labels": map[string]any{"cool": "very"}, }, }, }, }, "OwnerReference": { reason: "An ObjectReference (i.e. struct) should be converted to a map of string to any", data: []byte(`{"metadata":{}}`), args: args{ path: "metadata.ownerRefs[0]", value: metav1.OwnerReference{ APIVersion: "v", Kind: "k", Name: "n", UID: types.UID("u"), }, }, want: want{ object: map[string]any{ "metadata": map[string]any{ "ownerRefs": []any{ map[string]any{ "apiVersion": "v", "kind": "k", "name": "n", "uid": "u", }, }, }, }, }, }, "NotAnArray": { reason: "Indexing an object field should fail", data: []byte(`{"data":{}}`), args: args{ path: "data[0]", }, want: want{ object: map[string]any{"data": map[string]any{}}, err: errors.New("data is not an array"), }, }, "NotAnObject": { reason: "Requesting a field in an array should fail", data: []byte(`{"data":[]}`), args: args{ path: "data.name", }, want: want{ object: map[string]any{"data": []any{}}, err: errors.New("data is not an object"), }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", args: args{ path: "spec[]", }, want: want{ object: map[string]any{}, err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in, tc.args.opts...) err := p.SetValue(tc.args.path, tc.args.value) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.SetValue(%s, %v): %s: -want error, +got error:\n%s", tc.args.path, tc.args.value, tc.reason, diff) } if diff := cmp.Diff(tc.want.object, p.object); diff != "" { t.Fatalf("\np.SetValue(%s, %v): %s: -want, +got:\n%s", tc.args.path, tc.args.value, tc.reason, diff) } }) } } func TestExpandWildcards(t *testing.T) { type want struct { expanded []string err error } cases := map[string]struct { reason string path string data []byte want want }{ "NoWildcardExisting": { reason: "It should return same path if no wildcard in an existing path", path: "password", data: []byte(`{"password":"top-secret"}`), want: want{ expanded: []string{"password"}, }, }, "NoWildcardNonExisting": { reason: "It should return no results if no wildcard in a non-existing path", path: "username", data: []byte(`{"password":"top-secret"}`), want: want{ expanded: []string{}, }, }, "NestedNoWildcardExisting": { reason: "It should return same path if no wildcard in an existing path", path: "items[0][1]", data: []byte(`{"items":[["a", "b"]]}`), want: want{ expanded: []string{"items[0][1]"}, }, }, "NestedNoWildcardNonExisting": { reason: "It should return no results if no wildcard in a non-existing path", path: "items[0][5]", data: []byte(`{"items":[["a", "b"]]}`), want: want{ expanded: []string{}, }, }, "NestedArray": { reason: "It should return all possible paths for an array", path: "items[*][*]", data: []byte(`{"items":[["a", "b", "c"], ["d"]]}`), want: want{ expanded: []string{"items[0][0]", "items[0][1]", "items[0][2]", "items[1][0]"}, }, }, "KeysOfMap": { reason: "It should return all possible paths for a map in proper syntax", path: "items[*]", data: []byte(`{"items":{ "key1": "val1", "key2.as.annotation": "val2"}}`), want: want{ expanded: []string{"items.key1", "items[key2.as.annotation]"}, }, }, "ArrayOfObjects": { reason: "It should return all possible paths for an array of objects", path: "spec.containers[*][*]", data: []byte(`{"spec":{"containers":[{"name":"cool", "image": "latest", "args": ["start", "now"]}]}}`), want: want{ expanded: []string{"spec.containers[0].name", "spec.containers[0].image", "spec.containers[0].args"}, }, }, "MultiLayer": { reason: "It should return all possible paths for a multilayer input", path: "spec.containers[*].args[*]", data: []byte(`{"spec":{"containers":[{"name":"cool", "image": "latest", "args": ["start", "now", "debug"]}]}}`), want: want{ expanded: []string{"spec.containers[0].args[0]", "spec.containers[0].args[1]", "spec.containers[0].args[2]"}, }, }, "WildcardInTheBeginning": { reason: "It should return all possible paths for a multilayer input with wildcard in the beginning", path: "spec.containers[*].args[1]", data: []byte(`{"spec":{"containers":[{"name":"cool", "image": "latest", "args": ["start", "now", "debug"]}]}}`), want: want{ expanded: []string{"spec.containers[0].args[1]"}, }, }, "WildcardAtTheEnd": { reason: "It should return all possible paths for a multilayer input with wildcard at the end", path: "spec.containers[0].args[*]", data: []byte(`{"spec":{"containers":[{"name":"cool", "image": "latest", "args": ["start", "now", "debug"]}]}}`), want: want{ expanded: []string{"spec.containers[0].args[0]", "spec.containers[0].args[1]", "spec.containers[0].args[2]"}, }, }, "NoData": { reason: "If there is no input data, no expanded fields could be found", path: "metadata[*]", data: nil, want: want{ expanded: []string{}, }, }, "InsufficientContainers": { reason: "Requesting a non-existent array element should return nothing", path: "spec.containers[1].args[*]", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), want: want{ expanded: []string{}, }, }, "UnexpectedWildcard": { reason: "Requesting a wildcard for an object should fail", path: "spec.containers[0].name[*]", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), want: want{ err: errors.Wrapf(errors.Errorf("%q: unexpected wildcard usage", "spec.containers[0].name"), "cannot expand wildcards for segments: %q", "spec.containers[0].name[*]"), }, }, "NotAnArray": { reason: "Indexing an object should fail", path: "metadata[1]", data: []byte(`{"metadata":{"nope":"cool"}}`), want: want{ err: errors.Wrapf(errors.New("metadata: not an array"), "cannot expand wildcards for segments: %q", "metadata[1]"), }, }, "NotAnObject": { reason: "Requesting a field in an array should fail", path: "spec.containers[nope].name", data: []byte(`{"spec":{"containers":[{"name":"cool"}]}}`), want: want{ err: errors.Wrapf(errors.New("spec.containers: not an object"), "cannot expand wildcards for segments: %q", "spec.containers.nope.name"), }, }, "MalformedPath": { reason: "Requesting an invalid field path should fail", path: "spec[]", want: want{ err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "NilValue": { reason: "Requesting a wildcard for an object that has nil value", path: "spec.containers[*].name", data: []byte(`{"spec":{"containers": null}}`), want: want{ err: errors.Wrapf(notFoundError{errors.Errorf("wildcard field %q is not found in the path", "spec.containers")}, "cannot expand wildcards for segments: %q", "spec.containers[*].name"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) got, err := p.ExpandWildcards(tc.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.ExpandWildcards(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.expanded, got, cmpopts.SortSlices(func(x, y string) bool { return x < y })); diff != "" { t.Errorf("\np.ExpandWildcards(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) } }) } } func TestDeleteField(t *testing.T) { type args struct { path string } type want struct { object map[string]any err error } cases := map[string]struct { reason string data []byte args args want want }{ "MalformedPath": { reason: "Requesting an invalid field path should fail", args: args{ path: "spec[]", }, want: want{ object: map[string]any{}, err: errors.Wrap(errors.New("unexpected ']' at position 5"), "cannot parse path \"spec[]\""), }, }, "IndexGivenForNonArray": { reason: "Trying to delete a numbered index from a map should fail.", data: []byte(`{"data":{}}`), args: args{ path: "data[0]", }, want: want{ object: map[string]any{"data": map[string]any{}}, err: errors.Wrap(errors.New("not an array"), "cannot delete data[0]"), }, }, "KeyGivenForNonMap": { reason: "Trying to delete a key from an array should fail.", data: []byte(`{"data":[["a"]]}`), args: args{ path: "data[0].a", }, want: want{ object: map[string]any{"data": []any{[]any{"a"}}}, err: errors.Wrap(errors.New("not an object"), "cannot delete data[0].a"), }, }, "KeyGivenForNonMapInMiddle": { reason: "If one of the segments that is a field corresponds to array, it should fail.", data: []byte(`{"data":[{"another": "field"}]}`), args: args{ path: "data.some.another", }, want: want{ object: map[string]any{"data": []any{ map[string]any{ "another": "field", }, }}, err: errors.New("data is not an object"), }, }, "IndexGivenForNonArrayInMiddle": { reason: "If one of the segments that is an index corresponds to map, it should fail.", data: []byte(`{"data":{"another": ["field"]}}`), args: args{ path: "data[0].another", }, want: want{ object: map[string]any{"data": map[string]any{ "another": []any{ "field", }, }}, err: errors.New("data is not an array"), }, }, "ObjectField": { reason: "Deleting a field from a map should work.", data: []byte(`{"metadata":{"name":"lame"}}`), args: args{ path: "metadata.name", }, want: want{ object: map[string]any{ "metadata": map[string]any{}, }, }, }, "ObjectSingleField": { reason: "Deleting a field from a map should work.", data: []byte(`{"metadata":{"name":"lame"}, "olala": {"omama": "koala"}}`), args: args{ path: "metadata", }, want: want{ object: map[string]any{ "olala": map[string]any{ "omama": "koala", }, }, }, }, "ObjectLeafField": { reason: "Deleting a field that is deep in the tree from a map should work.", data: []byte(`{"spec":{"some": {"more": "delete-me"}}}`), args: args{ path: "spec.some.more", }, want: want{ object: map[string]any{ "spec": map[string]any{ "some": map[string]any{}, }, }, }, }, "ObjectMidField": { reason: "Deleting a field that is in the middle of the tree from a map should work.", data: []byte(`{"spec":{"some": {"more": "delete-me"}}}`), args: args{ path: "spec.some", }, want: want{ object: map[string]any{ "spec": map[string]any{}, }, }, }, "ObjectInArray": { reason: "Deleting a field that is in the middle of the tree from a map should work.", data: []byte(`{"spec":[{"some": {"more": "delete-me"}}]}`), args: args{ path: "spec[0].some.more", }, want: want{ object: map[string]any{ "spec": []any{ map[string]any{ "some": map[string]any{}, }, }, }, }, }, "ArrayFirstElement": { reason: "Deleting the first element from an array should work", data: []byte(`{"items":["a", "b"]}`), args: args{ path: "items[0]", }, want: want{ object: map[string]any{ "items": []any{ "b", }, }, }, }, "ArrayLastElement": { reason: "Deleting the last element from an array should work", data: []byte(`{"items":["a", "b"]}`), args: args{ path: "items[1]", }, want: want{ object: map[string]any{ "items": []any{ "a", }, }, }, }, "ArrayMidElement": { reason: "Deleting an element that is neither first nor last from an array should work", data: []byte(`{"items":["a", "b", "c"]}`), args: args{ path: "items[1]", }, want: want{ object: map[string]any{ "items": []any{ "a", "c", }, }, }, }, "ArrayOnlyElements": { reason: "Deleting the only element from an array should work", data: []byte(`{"items":["a"]}`), args: args{ path: "items[0]", }, want: want{ object: map[string]any{ "items": []any{}, }, }, }, "ArrayMultipleIndex": { reason: "Deleting an element from an array of array should work", data: []byte(`{"items":[["a", "b"]]}`), args: args{ path: "items[0][1]", }, want: want{ object: map[string]any{ "items": []any{ []any{ "a", }, }, }, }, }, "ArrayNoElement": { reason: "Deleting an element from an empty array should work", data: []byte(`{"items":[]}`), args: args{ path: "items[0]", }, want: want{ object: map[string]any{ "items": []any{}, }, }, }, "NonExistentPathInMap": { reason: "It should be no-op if the field does not exist already.", data: []byte(`{"items":[]}`), args: args{ path: "items[0].metadata", }, want: want{ object: map[string]any{ "items": []any{}, }, }, }, "NonExistentPathInArray": { reason: "It should be no-op if the field does not exist already.", data: []byte(`{"items":{"some": "other"}}`), args: args{ path: "items.metadata[0]", }, want: want{ object: map[string]any{ "items": map[string]any{ "some": "other", }, }, }, }, "NonExistentElementInArray": { reason: "It should be no-op if the field does not exist already.", data: []byte(`{"items":["some", "other"]}`), args: args{ path: "items[5]", }, want: want{ object: map[string]any{ "items": []any{ "some", "other", }, }, }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { in := make(map[string]any) _ = json.Unmarshal(tc.data, &in) p := Pave(in) err := p.DeleteField(tc.args.path) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Fatalf("\np.DeleteField(%s): %s: -want error, +got error:\n%s", tc.args.path, tc.reason, diff) } if diff := cmp.Diff(tc.want.object, p.object); diff != "" { t.Fatalf("\np.DeleteField(%s): %s: -want, +got:\n%s", tc.args.path, tc.reason, diff) } }) } }