diff --git a/pkg/template/jsonpath.go b/pkg/template/jsonpath.go new file mode 100644 index 00000000000..6f0ad4560ed --- /dev/null +++ b/pkg/template/jsonpath.go @@ -0,0 +1,156 @@ +package template + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "regexp" + "strings" + + "k8s.io/client-go/third_party/forked/golang/template" + "k8s.io/client-go/util/jsonpath" +) + +var ( + // tektonVar captures strings that are enclosed in $() + tektonVar = regexp.MustCompile(`\$\(?([^\)]+)\)`) + + // jsonRegexp is a regular expression for JSONPath expressions + // with or without the enclosing {} and the leading . inside the curly + // braces e.g. 'a.b' or '.a.b' or '{a.b}' or '{.a.b}' + jsonRegexp = regexp.MustCompile(`^\{\.?([^{}]+)\}$|^\.?([^{}]+)$`) +) + +// ParseJSONPath extracts a subset of the given JSON input +// using the provided JSONPath expression. +func ParseJSONPath(input interface{}, expr string) (string, error) { + j := jsonpath.New("").AllowMissingKeys(false) + buf := new(bytes.Buffer) + + //First turn the expression into fully valid JSONPath + expr, err := TektonJSONPathExpression(expr) + if err != nil { + return "", err + } + + if err := j.Parse(expr); err != nil { + return "", err + } + + fullResults, err := j.FindResults(input) + if err != nil { + return "", err + } + + for _, r := range fullResults { + if err := printResults(buf, r); err != nil { + return "", err + } + } + + return buf.String(), nil +} + +// PrintResults writes the results into writer +// This is a slightly modified copy of the original +// j.PrintResults from k8s.io/client-go/util/jsonpath/jsonpath.go +// in that it uses calls `textValue()` for instead of `evalToText` +// This is a workaround for kubernetes/kubernetes#16707 +func printResults(wr io.Writer, results []reflect.Value) error { + for i, r := range results { + text, err := textValue(r) + if err != nil { + return err + } + if i != len(results)-1 { + text = append(text, ' ') + } + if _, err := wr.Write(text); err != nil { + return err + } + } + return nil +} + +// textValue translates reflect value to corresponding text +// If the value if an array or map, it returns a JSON representation +// of the value (as opposed to the internal go representation of the value) +// Otherwise, the text value is from the `evalToText` function, originally from +// k8s.io/client-go/util/jsonpath/jsonpath.go +func textValue(v reflect.Value) ([]byte, error) { + t := reflect.TypeOf(v.Interface()) + // special case for null values in JSON; evalToText() returns here + if t == nil { + return []byte("null"), nil + } + + switch t.Kind() { + // evalToText() returns ....; return JSON string instead. + case reflect.Map, reflect.Slice: + return json.Marshal(v.Interface()) + default: + return evalToText(v) + } +} + +// evalToText translates reflect value to corresponding text +// This is a unmodified copy of j.evalToText from k8s.io/client-go/util/jsonpath/jsonpath.go +func evalToText(v reflect.Value) ([]byte, error) { + iface, ok := template.PrintableValue(v) + if !ok { + // only happens if v is a Chan or a Func + return nil, fmt.Errorf("can't print type %s", v.Type()) + } + var buffer bytes.Buffer + fmt.Fprint(&buffer, iface) + return buffer.Bytes(), nil +} + +// TektonJSONPathExpression returns a valid JSONPath expression. It accepts +// a "RelaxedJSONPath" expression that is wrapped in the Tekton variable +// interpolation syntax i.e. $(). RelaxedJSONPath expressions can optionally +// omit the leading curly braces '{}' and '.' +func TektonJSONPathExpression(expr string) (string, error) { + if !isTektonExpr(expr) { + return "", errors.New("expression not wrapped in $()") + } + unwrapped := strings.TrimSuffix(strings.TrimPrefix(expr, "$("), ")") + return relaxedJSONPathExpression(unwrapped) +} + +// RelaxedJSONPathExpression attempts to be flexible with JSONPath expressions, it accepts: +// * metadata.name (no leading '.' or curly braces '{...}' +// * {metadata.name} (no leading '.') +// * .metadata.name (no curly braces '{...}') +// * {.metadata.name} (complete expression) +// And transforms them all into a valid jsonpath expression: +// {.metadata.name} +// This function has been copied as-is from +// https://github.com/kubernetes/kubectl/blob/c273777957bd657233cf867892fb061a6498dab8/pkg/cmd/get/customcolumn.go#L47 +func relaxedJSONPathExpression(pathExpression string) (string, error) { + if len(pathExpression) == 0 { + return pathExpression, nil + } + submatches := jsonRegexp.FindStringSubmatch(pathExpression) + if submatches == nil { + return "", fmt.Errorf("unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or '{.name1.name2}'") + } + if len(submatches) != 3 { + return "", fmt.Errorf("unexpected submatch list: %v", submatches) + } + var fieldSpec string + if len(submatches[1]) != 0 { + fieldSpec = submatches[1] + } else { + fieldSpec = submatches[2] + } + return fmt.Sprintf("{.%s}", fieldSpec), nil +} + +// IsTektonExpr returns true if the expr is wrapped in $() +func isTektonExpr(expr string) bool { + return tektonVar.MatchString(expr) +} diff --git a/pkg/template/jsonpath_test.go b/pkg/template/jsonpath_test.go new file mode 100644 index 00000000000..8ea935ed530 --- /dev/null +++ b/pkg/template/jsonpath_test.go @@ -0,0 +1,201 @@ +package template + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var objects = `{"a":"v","c":{"d":"e"},"empty": "","null": null, "number": 42}` +var arrays = `[{"a": "b"}, {"c": "d"}, {"e": "f"}]` + +// Checks that we print JSON strings when the JSONPath selects +// an array or map value and regular values otherwise +func TestParseJSONPath(t *testing.T) { + var objectBody = fmt.Sprintf(`{"body":%s}`, objects) + tests := []struct { + name string + expr string + in string + want string + }{{ + name: "objects", + in: objectBody, + expr: "$(body)", + // TODO: Do we need to escape backslashes for backwards compat? + want: objects, + }, { + name: "array of objects", + in: fmt.Sprintf(`{"body":%s}`, arrays), + expr: "$(body)", + want: arrays, + }, { + name: "array of values", + in: `{"body": ["a", "b", "c"]}`, + expr: "$(body)", + want: `["a", "b", "c"]`, + }, { + name: "string values", + in: objectBody, + expr: "$(body.a)", + want: "v", + }, { + name: "string values", + in: objectBody, + expr: "$(body.a)", + want: "v", + }, { + name: "empty string", + in: objectBody, + expr: "$(body.empty)", + want: "", + }, { + name: "numbers", + in: objectBody, + expr: "$(body.number)", + want: "42", + }, { + name: "booleans", + in: `{"body": {"bool": true}}`, + expr: "$(body.bool)", + want: "true", + }, { + name: "null values", + in: objectBody, + expr: "$(body.null)", + want: "null", + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var data interface{} + err := json.Unmarshal([]byte(tt.in), &data) + if err != nil { + t.Fatalf("Could not unmarshall body : %q", err) + } + got, err := ParseJSONPath(data, tt.expr) + if err != nil { + t.Errorf("ParseJSONPath() error = %v", err) + return + } + if diff := cmp.Diff(strings.Replace(tt.want, " ", "", -1), got); diff != "" { + t.Errorf("ParseJSONPath() -want,+got: %s", diff) + } + }) + } +} + +func TestParseJSONPath_Error(t *testing.T) { + testJSON := `{"body": {"key": "val"}}` + invalidExprs := []string{ + "$({.hello)", + "$(+12.3.0)", + "$([1)", + "$(body", + "body)", + "body", + "$(body.missing)", + "$(body.key[0])", + } + var data interface{} + err := json.Unmarshal([]byte(testJSON), &data) + if err != nil { + t.Fatalf("Could not unmarshall body : %q", err) + return + } + + for _, expr := range invalidExprs { + t.Run(expr, func(t *testing.T) { + got, err := ParseJSONPath(data, expr) + if err == nil { + t.Errorf("ParseJSONPath() did not return expected error; got = %v", got) + } + }) + } +} + +func TestTektonJSONPathExpression(t *testing.T) { + tests := []struct { + expr string + want string + }{ + {"$(metadata.name)", "{.metadata.name}"}, + {"$(.metadata.name)", "{.metadata.name}"}, + {"$({.metadata.name})", "{.metadata.name}"}, + {"$()", ""}, + } + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + got, err := TektonJSONPathExpression(tt.expr) + if err != nil { + t.Errorf("TektonJSONPathExpression() unexpected error = %v, got = %v", err, got) + } + if got != tt.want { + t.Errorf("TektonJSONPathExpression() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTektonJSONPathExpression_Error(t *testing.T) { + tests := []string{ + "{.metadata.name}", // not wrapped in $() + "", + "$({asd)", + "$({)", + "$({foo.bar)", + "$(foo.bar})", + "$({foo.bar}})", + "$({{foo.bar)", + } + for _, expr := range tests { + t.Run(expr, func(t *testing.T) { + _, err := TektonJSONPathExpression(expr) + if err == nil { + t.Errorf("TektonJSONPathExpression() did not get expected error for expression = %s", expr) + } + }) + } +} + +func TestRelaxedJSONPathExpression(t *testing.T) { + tests := []struct { + expr string + want string + }{ + {"metadata.name", "{.metadata.name}"}, + {".metadata.name", "{.metadata.name}"}, + {"{.metadata.name}", "{.metadata.name}"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + got, err := relaxedJSONPathExpression(tt.expr) + if err != nil { + t.Errorf("TektonJSONPathExpression() unexpected error = %v, got = %v", err, got) + } + if got != tt.want { + t.Errorf("TektonJSONPathExpression() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRelaxedJSONPathExpression_Error(t *testing.T) { + tests := []string{ + "{foo.bar", + "foo.bar}", + "{foo.bar}}", + "{{foo.bar}", + } + for _, expr := range tests { + t.Run(expr, func(t *testing.T) { + got, err := relaxedJSONPathExpression(expr) + if err == nil { + t.Errorf("TektonJSONPathExpression() did not get expected error = %v, got = %v", err, got) + } + }) + } +}