Skip to content

Commit

Permalink
Adds functions to support JSONPath
Browse files Browse the repository at this point in the history
In tektoncd#178 we decided to support JSONPath syntax for
expressions in TriggerBindings. This commit adds
functions to support JSONPath using the
`k8s.io/client-go/util/jsonpath` library.

There are a few deviations from the default behavior of the library:

1. The JSONPath expressions have to be wrapped in the Tekton variable
interpolation syntax i.e `$()` : `$(body)`.

2. We  support the `RelaxedJSONPathExpresion` syntax used in
`kubectl get -o custom-columns`. This means that the curly braces `{}`
and the leading dot `.` can be omitted i.e we support both `$(body.key)`
as well as `$({.body.key})

3. We return valid JSON values when the value is a JSON array or
map. By default, the library returns a string containing an internal
go representation. So, if the JSONPath expression selects `{"a": "b"}`,
the library will return `map[a: b]` while we return the valid JSON
string i.e `{"a":"b"}`

In order to support 3, we have to copy a couple of unexported functions
from the library (printResults, and evalToText).

Signed-off-by: Dibyo Mukherjee <[email protected]>
  • Loading branch information
dibyom authored and tekton-robot committed Dec 10, 2019
1 parent 080d850 commit 0111406
Show file tree
Hide file tree
Showing 2 changed files with 357 additions and 0 deletions.
156 changes: 156 additions & 0 deletions pkg/template/jsonpath.go
Original file line number Diff line number Diff line change
@@ -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 <nil> here
if t == nil {
return []byte("null"), nil
}

switch t.Kind() {
// evalToText() returns <map> ....; 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)
}
201 changes: 201 additions & 0 deletions pkg/template/jsonpath_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

0 comments on commit 0111406

Please sign in to comment.