forked from tektoncd/pipeline
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
080d850
commit 0111406
Showing
2 changed files
with
357 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |