From f67177024d906aaf5e13ee7cd470b4e87a9fef40 Mon Sep 17 00:00:00 2001 From: Nic Cope Date: Mon, 24 Feb 2020 12:37:52 -0800 Subject: [PATCH] Add a package for getting and setting unstructured fields by path https://github.com/kubernetes-sigs/kustomize/blob/d190e1/api/k8sdeps/kunstruct/helper.go https://github.com/kubernetes/apimachinery/blob/2373d0/pkg/apis/meta/v1/unstructured/helpers.go This package is similar to the above two, with some key differences: * Our fieldpath lexer is a little stricter; it won't allow dangling open braces, unexpected periods, or empty brackets. It also supplies the position of any syntax error if lexing fails. * We support setting and getting fields within a pkg/json unmarshalled object by fieldpath. Other packages support only getting fields, or only setting fields in paths that do not contain any array indexes. Signed-off-by: Nic Cope --- pkg/fieldpath/fieldpath.go | 280 +++++++++++++++ pkg/fieldpath/fieldpath_test.go | 283 +++++++++++++++ pkg/fieldpath/paved.go | 307 ++++++++++++++++ pkg/fieldpath/paved_test.go | 616 ++++++++++++++++++++++++++++++++ 4 files changed, 1486 insertions(+) create mode 100644 pkg/fieldpath/fieldpath.go create mode 100644 pkg/fieldpath/fieldpath_test.go create mode 100644 pkg/fieldpath/paved.go create mode 100644 pkg/fieldpath/paved_test.go diff --git a/pkg/fieldpath/fieldpath.go b/pkg/fieldpath/fieldpath.go new file mode 100644 index 000000000..4215d0c9c --- /dev/null +++ b/pkg/fieldpath/fieldpath.go @@ -0,0 +1,280 @@ +/* +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 provides utilities for working with field paths. +// +// Field paths reference a field within a Kubernetes object via a simple string. +// API conventions describe the syntax as "standard JavaScript syntax for +// accessing that field, assuming the JSON object was transformed into a +// JavaScript object, without the leading dot, such as metadata.name". +// +// Valid examples: +// +// * metadata.name +// * spec.containers[0].name +// * data[.config.yml] +// * metadata.annotations['crossplane.io/external-name'] +// * spec.items[0][8] +// * apiVersion +// * [42] +// +// Invalid examples: +// +// * .metadata.name - Leading period. +// * metadata..name - Double period. +// * metadata.name. - Trailing period. +// * spec.containers[] - Empty brackets. +// * spec.containers.[0].name - Period before open bracket. +// +// https://github.com/kubernetes/community/blob/61f3d0/contributors/devel/sig-architecture/api-conventions.md#selecting-fields +package fieldpath + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" + + "github.com/pkg/errors" +) + +// A SegmentType within a field path; either a field within an object, or an +// index within an array. +type SegmentType int + +// Segment types. +const ( + _ SegmentType = iota + SegmentField + SegmentIndex +) + +// A Segment of a field path. +type Segment struct { + Type SegmentType + Field string + Index uint +} + +// Segments of a field path. +type Segments []Segment + +func (sg Segments) String() string { + var b strings.Builder + + for _, s := range sg { + switch s.Type { + case SegmentField: + if strings.ContainsRune(s.Field, period) { + b.WriteString(fmt.Sprintf("[%s]", s.Field)) + continue + } + b.WriteString(fmt.Sprintf(".%s", s.Field)) + case SegmentIndex: + b.WriteString(fmt.Sprintf("[%d]", s.Index)) + } + } + + return strings.TrimPrefix(b.String(), ".") +} + +// FieldOrIndex produces a new segment from the supplied string. The segment is +// considered to be an array index if the string can be interpreted as an +// unsigned 32 bit integer. Anything else is interpreted as an object field +// name. +func FieldOrIndex(s string) Segment { + // Attempt to parse the segment as an unsigned integer. If the integer is + // larger than 2^32 (the limit for most JSON arrays) we presume it's too big + // to be an array index, and is thus a field name. + if i, err := strconv.ParseUint(s, 10, 32); err == nil { + return Segment{Type: SegmentIndex, Index: uint(i)} + } + + // If the segment is not a valid unsigned integer we presume it's + // a string field name. + return Field(s) +} + +// Field produces a new segment from the supplied string. The segment is always +// considered to be an object field name. +func Field(s string) Segment { + return Segment{Type: SegmentField, Field: strings.Trim(s, "'\"")} +} + +// Parse the supplied path into a slice of Segments. +func Parse(path string) (Segments, error) { + l := &lexer{input: path, items: make(chan item)} + go l.run() + + segments := make(Segments, 0, 1) + for i := range l.items { + switch i.typ { + case itemField: + segments = append(segments, Field(i.val)) + case itemFieldOrIndex: + segments = append(segments, FieldOrIndex(i.val)) + case itemError: + return nil, errors.Errorf("%s at position %d", i.val, i.pos) + } + } + return segments, nil +} + +const ( + period = '.' + leftBracket = '[' + rightBracket = ']' +) + +type itemType int + +const ( + itemError itemType = iota + itemPeriod + itemLeftBracket + itemRightBracket + itemField + itemFieldOrIndex + itemEOL +) + +type item struct { + typ itemType + pos int + val string +} + +type stateFn func(*lexer) stateFn + +// A simplified version of the text/template lexer. +// https://github.com/golang/go/blob/6396bc9d/src/text/template/parse/lex.go#L108 +type lexer struct { + input string + pos int + start int + items chan item +} + +func (l *lexer) run() { + for state := lexField; state != nil; { + state = state(l) + } + close(l.items) +} + +func (l *lexer) emit(t itemType) { + // Don't emit empty values. + if l.pos <= l.start { + return + } + l.items <- item{typ: t, pos: l.start, val: l.input[l.start:l.pos]} + l.start = l.pos +} + +func (l *lexer) errorf(pos int, format string, args ...interface{}) stateFn { + l.items <- item{typ: itemError, pos: pos, val: fmt.Sprintf(format, args...)} + return nil +} + +func lexField(l *lexer) stateFn { + for i, r := range l.input[l.pos:] { + switch r { + // A right bracket may not appear in an object field name. + case rightBracket: + return l.errorf(l.pos+i, "unexpected %q", rightBracket) + + // A left bracket indicates the end of the field name. + case leftBracket: + l.pos += i + l.emit(itemField) + return lexLeftBracket + + // A period indicates the end of the field name. + case period: + l.pos += i + l.emit(itemField) + return lexPeriod + } + } + + // The end of the input indicates the end of the field name. + l.pos = len(l.input) + l.emit(itemField) + l.emit(itemEOL) + return nil +} + +func lexPeriod(l *lexer) stateFn { + // A period may not appear at the beginning or the end of the input. + if l.pos == 0 || l.pos == len(l.input)-1 { + return l.errorf(l.pos, "unexpected %q", period) + } + + l.pos += utf8.RuneLen(period) + l.emit(itemPeriod) + + // A period may only be followed by a field name. We defer checking for + // right brackets to lexField, where they are invalid. + r, _ := utf8.DecodeRuneInString(l.input[l.pos:]) + if r == period { + return l.errorf(l.pos, "unexpected %q", period) + } + if r == leftBracket { + return l.errorf(l.pos, "unexpected %q", leftBracket) + } + + return lexField +} + +func lexLeftBracket(l *lexer) stateFn { + // A right bracket must appear before the input ends. + if !strings.ContainsRune(l.input[l.pos:], rightBracket) { + return l.errorf(l.pos, "unterminated %q", leftBracket) + } + + l.pos += utf8.RuneLen(leftBracket) + l.emit(itemLeftBracket) + return lexFieldOrIndex +} + +// Strings between brackets may be either a field name or an array index. +// Periods have no special meaning in this context. +func lexFieldOrIndex(l *lexer) stateFn { + // We know a right bracket exists before EOL thanks to the preceding + // lexLeftBracket. + rbi := strings.IndexRune(l.input[l.pos:], rightBracket) + + // A right bracket may not immediately follow a left bracket. + if rbi == 0 { + return l.errorf(l.pos, "unexpected %q", rightBracket) + } + + // A left bracket may not appear before the next right bracket. + if lbi := strings.IndexRune(l.input[l.pos:l.pos+rbi], leftBracket); lbi > -1 { + return l.errorf(l.pos+lbi, "unexpected %q", leftBracket) + } + + // Periods are not considered field separators when we're inside brackets. + l.pos += rbi + l.emit(itemFieldOrIndex) + return lexRightBracket +} + +func lexRightBracket(l *lexer) stateFn { + l.pos += utf8.RuneLen(rightBracket) + l.emit(itemRightBracket) + return lexField +} diff --git a/pkg/fieldpath/fieldpath_test.go b/pkg/fieldpath/fieldpath_test.go new file mode 100644 index 000000000..d3537d02c --- /dev/null +++ b/pkg/fieldpath/fieldpath_test.go @@ -0,0 +1,283 @@ +/* +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 ( + "math" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + + "github.com/crossplane/crossplane-runtime/pkg/test" +) + +func TestSegments(t *testing.T) { + cases := map[string]struct { + s Segments + want string + }{ + "SingleField": { + s: Segments{Field("spec")}, + want: "spec", + }, + "SingleIndex": { + s: Segments{FieldOrIndex("0")}, + want: "[0]", + }, + "FieldsAndIndex": { + s: Segments{ + Field("spec"), + Field("containers"), + FieldOrIndex("0"), + Field("name"), + }, + want: "spec.containers[0].name", + }, + "PeriodsInField": { + s: Segments{ + Field("data"), + Field(".config.yml"), + }, + want: "data[.config.yml]", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if diff := cmp.Diff(tc.want, tc.s.String()); diff != "" { + t.Errorf("s.String(): -want, +got:\n %s", diff) + } + + }) + } +} + +func TestFieldOrIndex(t *testing.T) { + cases := map[string]struct { + reason string + s string + want Segment + }{ + "Field": { + reason: "An unambiguous string should be interpreted as a field segment", + s: "coolField", + want: Segment{Type: SegmentField, Field: "coolField"}, + }, + "QuotedField": { + reason: "A quoted string should be interpreted as a field segment with the quotes removed", + s: "'coolField'", + want: Segment{Type: SegmentField, Field: "coolField"}, + }, + "Index": { + reason: "An unambiguous integer should be interpreted as an index segment", + s: "3", + want: Segment{Type: SegmentIndex, Index: 3}, + }, + "Negative": { + reason: "A negative integer should be interpreted as an field segment", + s: "-3", + want: Segment{Type: SegmentField, Field: "-3"}, + }, + "Float": { + reason: "A float should be interpreted as an field segment", + s: "3.0", + want: Segment{Type: SegmentField, Field: "3.0"}, + }, + "Overflow": { + reason: "A very big integer will be interpreted as a field segment", + s: strconv.Itoa(math.MaxUint32 + 1), + want: Segment{Type: SegmentField, Field: strconv.Itoa(math.MaxUint32 + 1)}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := FieldOrIndex(tc.s) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("\nFieldOrIndex(...): %s: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestParse(t *testing.T) { + type want struct { + s Segments + err error + } + + cases := map[string]struct { + reason string + path string + want want + }{ + "SingleField": { + reason: "A path with no brackets or periods should be interpreted as a single field segment", + path: "spec", + want: want{ + s: Segments{Field("spec")}, + }, + }, + "SingleIndex": { + reason: "An integer surrounded by brackets should be interpreted as an index", + path: "[0]", + want: want{ + s: Segments{FieldOrIndex("0")}, + }, + }, + "TwoFields": { + reason: "A path with one period should be interpreted as two field segments", + path: "metadata.name", + want: want{ + s: Segments{Field("metadata"), Field("name")}, + }, + }, + "APIConventionsExample": { + reason: "The example given by the Kubernetes API convention should be parse correctly", + path: "fields[1].state.current", + want: want{ + s: Segments{ + Field("fields"), + FieldOrIndex("1"), + Field("state"), + Field("current"), + }, + }, + }, + "SimpleIndex": { + reason: "Indexing an object field that is an array should result in a field and an index", + path: "items[0]", + want: want{ + s: Segments{Field("items"), FieldOrIndex("0")}, + }, + }, + "FieldsAndIndex": { + reason: "A path with periods and braces should be interpreted as fields and indices", + path: "spec.containers[0].name", + want: want{ + s: Segments{ + Field("spec"), + Field("containers"), + FieldOrIndex("0"), + Field("name"), + }, + }, + }, + "NestedArray": { + reason: "A nested array should result in two consecutive index fields", + path: "nested[0][1].name", + want: want{ + s: Segments{ + Field("nested"), + FieldOrIndex("0"), + FieldOrIndex("1"), + Field("name"), + }, + }, + }, + "BracketStyleField": { + reason: "A field name can be specified using brackets rather than a period", + path: "spec[containers][0].name", + want: want{ + s: Segments{ + Field("spec"), + Field("containers"), + FieldOrIndex("0"), + Field("name"), + }, + }, + }, + "BracketFieldWithPeriod": { + reason: "A field name specified using brackets can include a period", + path: "data[.config.yml]", + want: want{ + s: Segments{ + Field("data"), + FieldOrIndex(".config.yml"), + }, + }, + }, + "LeadingPeriod": { + reason: "A path may not start with a period (unlike a JSON path)", + path: ".metadata.name", + want: want{ + err: errors.New("unexpected '.' at position 0"), + }, + }, + "TrailingPeriod": { + reason: "A path may not end with a period", + path: "metadata.name.", + want: want{ + err: errors.New("unexpected '.' at position 13"), + }, + }, + "BracketsFollowingPeriod": { + reason: "Brackets may not follow a period", + path: "spec.containers.[0].name", + want: want{ + err: errors.New("unexpected '[' at position 16"), + }, + }, + "DoublePeriod": { + reason: "A path may not include two consecutive periods", + path: "metadata..name", + want: want{ + err: errors.New("unexpected '.' at position 9"), + }, + }, + "DanglingRightBracket": { + reason: "A right bracket may not appear in a field name", + path: "metadata.]name", + want: want{ + err: errors.New("unexpected ']' at position 9"), + }, + }, + "DoubleOpenBracket": { + reason: "Brackets may not be nested", + path: "spec[bracketed[name]]", + want: want{ + err: errors.New("unexpected '[' at position 14"), + }, + }, + "DanglingLeftBracket": { + reason: "A left bracket must be closed", + path: "spec[name", + want: want{ + err: errors.New("unterminated '[' at position 4"), + }, + }, + "EmptyBracket": { + reason: "Brackets may not be empty", + path: "spec[]", + want: want{ + err: errors.New("unexpected ']' at position 5"), + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := Parse(tc.path) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Fatalf("\nParse(%s): %s: -want error, +got error:\n%s", tc.path, tc.reason, diff) + } + if diff := cmp.Diff(tc.want.s, got); diff != "" { + t.Errorf("\nParse(%s): %s: -want, +got:\n%s", tc.path, tc.reason, diff) + } + }) + } +} diff --git a/pkg/fieldpath/paved.go b/pkg/fieldpath/paved.go new file mode 100644 index 000000000..19269b678 --- /dev/null +++ b/pkg/fieldpath/paved.go @@ -0,0 +1,307 @@ +/* +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 ( + "encoding/json" + + "github.com/pkg/errors" +) + +// A Paved JSON object supports getting and setting values by their field path. +type Paved struct { + object map[string]interface{} +} + +// Pave a JSON object, making it possible to get and set values by field path. +func Pave(object map[string]interface{}) *Paved { + return &Paved{object: object} +} + +// MarshalJSON to the underlying object. +func (p Paved) MarshalJSON() ([]byte, error) { + return json.Marshal(p.object) +} + +// UnmarshalJSON from the underlying object. +func (p *Paved) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &p.object) +} + +func (p *Paved) getValue(s Segments) (interface{}, error) { + var it interface{} = p.object + for i, current := range s { + final := i == len(s)-1 + switch current.Type { + case SegmentIndex: + array, ok := it.([]interface{}) + if !ok { + return nil, errors.Errorf("%s: not an array", s[:i]) + } + if int(current.Index) >= len(array) { + return nil, errors.Errorf("%s: no such element", s[:i+1]) + } + if final { + return array[current.Index], nil + } + it = array[current.Index] + + case SegmentField: + object, ok := it.(map[string]interface{}) + if !ok { + return nil, errors.Errorf("%s: not an object", s[:i]) + } + v, ok := object[current.Field] + if !ok { + return nil, errors.Errorf("%s: no such field", s[:i+1]) + } + if final { + return v, nil + } + it = object[current.Field] + } + } + + // This should be unreachable. + return nil, nil +} + +// GetValue of the supplied field path. +func (p *Paved) GetValue(path string) (interface{}, error) { + segments, err := Parse(path) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse path %q", path) + } + + return p.getValue(segments) +} + +// GetString value of the supplied field path. +func (p *Paved) GetString(path string) (string, error) { + v, err := p.GetValue(path) + if err != nil { + return "", err + } + + s, ok := v.(string) + if !ok { + return "", errors.Errorf("%s: not a string", path) + } + return s, nil +} + +// GetStringArray value of the supplied field path. +func (p *Paved) GetStringArray(path string) ([]string, error) { + v, err := p.GetValue(path) + if err != nil { + return nil, err + } + + a, ok := v.([]interface{}) + if !ok { + return nil, errors.Errorf("%s: not an array", path) + } + + sa := make([]string, len(a)) + for i := range a { + s, ok := a[i].(string) + if !ok { + return nil, errors.Errorf("%s: not an array of strings", path) + } + sa[i] = s + } + + return sa, nil +} + +// GetStringObject value of the supplied field path. +func (p *Paved) GetStringObject(path string) (map[string]string, error) { + v, err := p.GetValue(path) + if err != nil { + return nil, err + } + + o, ok := v.(map[string]interface{}) + if !ok { + return nil, errors.Errorf("%s: not an object", path) + } + + so := make(map[string]string) + for k, in := range o { + s, ok := in.(string) + if !ok { + return nil, errors.Errorf("%s: not an object with string field values", path) + } + so[k] = s + + } + + return so, nil +} + +// GetBool value of the supplied field path. +func (p *Paved) GetBool(path string) (bool, error) { + v, err := p.GetValue(path) + if err != nil { + return false, err + } + + b, ok := v.(bool) + if !ok { + return false, errors.Errorf("%s: not a bool", path) + } + return b, nil +} + +// GetNumber value of the supplied field path. +func (p *Paved) GetNumber(path string) (float64, error) { + v, err := p.GetValue(path) + if err != nil { + return 0, err + } + + f, ok := v.(float64) + if !ok { + return 0, errors.Errorf("%s: not a (float64) number", path) + } + return f, nil +} + +func (p *Paved) setValue(s Segments, value interface{}) error { + var in interface{} = p.object + for i, current := range s { + final := i == len(s)-1 + + switch current.Type { + case SegmentIndex: + array, ok := in.([]interface{}) + if !ok { + return errors.Errorf("%s is not an array", s[:i]) + } + + if final { + array[current.Index] = value + return nil + } + + prepareElement(array, current, s[i+1]) + in = array[current.Index] + + case SegmentField: + object, ok := in.(map[string]interface{}) + if !ok { + return errors.Errorf("%s is not an object", s[:i]) + } + + if final { + object[current.Field] = value + return nil + } + + prepareField(object, current, s[i+1]) + in = object[current.Field] + } + } + + return nil +} + +func prepareElement(array []interface{}, current, next Segment) { + // If this segment is not the final one and doesn't exist we need to + // create it for our next segment. + if array[current.Index] == nil { + switch next.Type { + case SegmentIndex: + array[current.Index] = make([]interface{}, next.Index+1) + case SegmentField: + array[current.Index] = make(map[string]interface{}) + } + return + } + + // If our next segment indexes an array that exists in our current segment's + // element we must ensure the array is long enough to set the next segment. + if next.Type != SegmentIndex { + return + } + + na, ok := array[current.Index].([]interface{}) + if !ok { + return + } + + if int(next.Index) < len(na) { + return + } + + array[current.Index] = append(na, make([]interface{}, int(next.Index)-len(na)+1)...) +} + +func prepareField(object map[string]interface{}, current, next Segment) { + // If this segment is not the final one and doesn't exist we need to + // create it for our next segment. + if _, ok := object[current.Field]; !ok { + switch next.Type { + case SegmentIndex: + object[current.Field] = make([]interface{}, next.Index+1) + case SegmentField: + object[current.Field] = make(map[string]interface{}) + } + return + } + + // If our next segment indexes an array that exists in our current segment's + // field we must ensure the array is long enough to set the next segment. + if next.Type != SegmentIndex { + return + } + + na, ok := object[current.Field].([]interface{}) + if !ok { + return + } + + if int(next.Index) < len(na) { + return + } + + object[current.Field] = append(na, make([]interface{}, int(next.Index)-len(na)+1)...) +} + +// SetValue at the supplied field path. +func (p *Paved) SetValue(path string, value interface{}) error { + segments, err := Parse(path) + if err != nil { + return errors.Wrapf(err, "cannot parse path %q", path) + } + return p.setValue(segments, value) +} + +// SetString value at the supplied field path. +func (p *Paved) SetString(path, value string) error { + return p.SetValue(path, value) +} + +// SetBool value at the supplied field path. +func (p *Paved) SetBool(path string, value bool) error { + return p.SetValue(path, value) +} + +// SetNumber value at the supplied field path. +func (p *Paved) SetNumber(path string, value float64) error { + return p.SetValue(path, value) +} diff --git a/pkg/fieldpath/paved_test.go b/pkg/fieldpath/paved_test.go new file mode 100644 index 000000000..aae5d75e1 --- /dev/null +++ b/pkg/fieldpath/paved_test.go @@ -0,0 +1,616 @@ +/* +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 ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + + "github.com/crossplane/crossplane-runtime/pkg/test" +) + +func TestGetValue(t *testing.T) { + type want struct { + value interface{} + 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 a number field should work", + path: "metadata.version", + data: []byte(`{"metadata":{"version":2}}`), + 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: 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: 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[]\""), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + in := make(map[string]interface{}) + _ = 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 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]interface{}) + _ = 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]interface{}) + _ = 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]interface{}) + _ = 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]interface{}) + _ = 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 TestGetNumber(t *testing.T) { + type want struct { + value float64 + 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 (float64) number"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + in := make(map[string]interface{}) + _ = json.Unmarshal(tc.data, &in) + p := Pave(in) + + got, err := p.GetNumber(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 interface{} + } + type want struct { + object map[string]interface{} + 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]interface{}{ + "metadata": map[string]interface{}{ + "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]interface{}{ + "metadata": map[string]interface{}{ + "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]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "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]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "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]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "cool", + }, + map[string]interface{}{ + "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]interface{}{ + "data": []interface{}{ + []interface{}{"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]interface{}{ + "data": []interface{}{ + []interface{}{"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]interface{}{ + "data": []interface{}{"a", nil, "c"}, + }, + }, + }, + "NotAnArray": { + reason: "Indexing an object field should fail", + data: []byte(`{"data":{}}`), + args: args{ + path: "data[0]", + }, + want: want{ + object: map[string]interface{}{"data": map[string]interface{}{}}, + 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]interface{}{"data": []interface{}{}}, + 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]interface{}{}, + 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]interface{}) + _ = json.Unmarshal(tc.data, &in) + p := Pave(in) + + 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) + } + }) + } +}