Skip to content

Commit

Permalink
AIP-157 Partial response implementation
Browse files Browse the repository at this point in the history
This feature add capabilities to filter the response message from all the APIs.
AIP detail:
https://google.aip.dev/157
  • Loading branch information
sayan-biswas committed Feb 28, 2024
1 parent 21d96ba commit 4ea8c19
Show file tree
Hide file tree
Showing 21 changed files with 6,078 additions and 1 deletion.
8 changes: 7 additions & 1 deletion cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
"strings"
"time"

"github.com/tektoncd/results/internal/fieldmask"

"github.com/tektoncd/results/pkg/api/server/v1alpha2/auth/impersonation"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
Expand Down Expand Up @@ -169,6 +171,7 @@ func main() {
grpc_zap.UnaryServerInterceptor(grpcLogger, zapOpts...),
grpc_auth.UnaryServerInterceptor(determineAuth),
prometheus.UnaryServerInterceptor,
fieldmask.UnaryServerInterceptor(),
recovery.UnaryServerInterceptor(recovery.WithRecoveryHandler(recoveryHandler)),
),
grpc_middleware.WithStreamServerChain(
Expand Down Expand Up @@ -221,7 +224,10 @@ func main() {
if err != nil {
log.Fatalf("Error dialing gRPC endpoint: %v", err)
}
serverMuxOptions = append(serverMuxOptions, runtime.WithHealthzEndpoint(healthpb.NewHealthClient(clientConn)))
serverMuxOptions = append(serverMuxOptions,
runtime.WithHealthzEndpoint(healthpb.NewHealthClient(clientConn)),
runtime.WithMetadata(fieldmask.MetadataAnnotator),
)

// Create server for gRPC gateway
ctx := context.Background()
Expand Down
25 changes: 25 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,31 @@ You must be providing the correct filter for the correct resource.
| Get all the Records of the Runs that failed | `!(data.status.conditions[0].status == 'True')` |
| Get all the Records of the PipelineRuns which had 3 or more tasks | `size(data.status.pipelineSpec.tasks) >= 3 && data_type == 'PIPELINE_RUN'` |

## Filtering Response

Google's [AIP-157](https://google.aip.dev/157) is implemented in the all the APIs and allows the response to be filtered according to user need.

### How to use
A URL parameter called `fields`, containing the paths to the items required in the response need to be sent along with the request. For gRPC requests this should be sent in the **header**. <br>
The response will then contain only the elements specified by the paths. Leaving the field blank or not sending the header will return the whole response. <br>

```fields: records.name, records.data.value.metadata.name```<br><br>
This will only return `name` and `metadata` in the response. If a path is not valid in the proto, it will be ignored. If a path is not valid in a JSON field, the path will appear in the response with a `null` value. <br>
Filtering a JSON array is **NOT** at the moment.

### Examples
```shell
curl -kG \
--data-urlencode "fields=records.name, records.data.value.metadata" \
http://localhost:8080/apis/results.tekton.dev/v1alpha2/parents/default/results/-/records
```
```shell
grpcurl --insecure \
-H 'fields: records.name, records.data.value.metadata' \
-d '{"parent": "default/results/-"}' \
results.tekton.dev:8080 tekton.results.v1alpha2.Results/ListRecords
```

## Ordering

The reference implementation of the Results API supports ordering result and
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ require (
github.com/subosito/gotenv v1.4.2 // indirect
github.com/tektoncd/triggers v0.22.0 // indirect
github.com/theupdateframework/go-tuf v0.5.2-0.20220930112810-3890c1e7ace4 // indirect
github.com/tidwall/gjson v1.17.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
github.com/vbatts/tar-split v0.11.2 // indirect
github.com/xlab/treeprint v1.1.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1663,7 +1663,13 @@ github.com/tektoncd/triggers v0.22.0 h1:xe9l+ebuUMuP4wzBLjvmUGUPZCz0qND4osFFLSVC
github.com/tektoncd/triggers v0.22.0/go.mod h1:lYxFl8cKbr+DaHMQa47U4Y7uyhTAMsRAT0uOOY8xiGE=
github.com/theupdateframework/go-tuf v0.5.2-0.20220930112810-3890c1e7ace4 h1:1i/Afw3rmaR1gF3sfVkG2X6ldkikQwA9zY380LrR5YI=
github.com/theupdateframework/go-tuf v0.5.2-0.20220930112810-3890c1e7ace4/go.mod h1:vAqWV3zEs89byeFsAYoh/Q14vJTgJkHwnnRCWBBBINY=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
Expand Down
165 changes: 165 additions & 0 deletions internal/fieldmask/fieldmask.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package fieldmask

import (
"context"
"net/http"
"strings"

jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)

const key = "fields"

// FieldMask is recursive structure to define a path mask
type FieldMask map[string]FieldMask

// New create a FieldMask from the input array of paths. The array should contain JSON paths with dit "." notation.
func New(paths []string) FieldMask {
mask := make(FieldMask)
for _, path := range paths {
current := mask
fields := strings.Split(path, ".")
for _, field := range fields {
c, ok := current[field]
if !ok {
c = make(FieldMask)
current[field] = c
}
current = c
}
}
return mask
}

// Filter takes a Proto message as input and updates the message according to the FieldMask.
func (fm FieldMask) Filter(message proto.Message) {
if len(fm) == 0 {
return
}

reflect := message.ProtoReflect()
reflect.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
mask, ok := fm[string(fd.Name())]
if !ok {
reflect.Clear(fd)
}

if len(mask) == 0 {
return true
}

switch {
case fd.IsMap():
m := reflect.Get(fd).Map()
m.Range(func(k protoreflect.MapKey, v protoreflect.Value) bool {
if fm, ok := mask[k.String()]; ok {
if i, ok := v.Interface().(protoreflect.Message); ok && len(fm) > 0 {
fm.Filter(i.Interface())
}
} else {
m.Clear(k)
}
return true
})
case fd.IsList():
list := reflect.Get(fd).List()
for i := 0; i < list.Len(); i++ {
mask.Filter(list.Get(i).Message().Interface())
}
case fd.Kind() == protoreflect.MessageKind:
mask.Filter(reflect.Get(fd).Message().Interface())
case fd.Kind() == protoreflect.BytesKind:
if b := v.Bytes(); gjson.ValidBytes(b) {
b, err := jsoniter.Marshal(mask.FilterJSON(b, []string{}))
if err == nil {
reflect.Set(fd, protoreflect.ValueOfBytes(b))
}
}
}
return true
})
}

// Paths return the dot "." JSON notation os all the paths in the FieldMask.
// Parameter root []string is used internally for recursion, but it can also be used for setting an initial root path.
func (fm FieldMask) Paths(path []string) (paths []string) {
for k, v := range fm {
path = append(path, k)
if len(v) == 0 {
paths = append(paths, strings.Join(path, "."))
}
paths = append(paths, v.Paths(path)...)
path = path[:len(path)-1]
}
return
}

// FilterJSON takes a JSON as input and return a map of the filtered JSON according to the FieldMask.
func (fm FieldMask) FilterJSON(json []byte, path []string) (out map[string]any) {
for k, v := range fm {
if out == nil {
out = make(map[string]interface{})
}
path = append(path, k)
if len(v) == 0 {
out[k] = gjson.GetBytes(json, strings.Join(path, ".")).Value()
} else {
out[k] = v.FilterJSON(json, path)
}
path = path[:len(path)-1]
}
return
}

// FromMetadata gets all the filter definitions from gRPC metadata.
func FromMetadata(md metadata.MD) FieldMask {
fm := &fieldmaskpb.FieldMask{}
masks := md.Get(key)
for _, mask := range masks {
paths := strings.Split(mask, ",")
for _, path := range paths {
fm.Paths = append(fm.Paths, strings.TrimSpace(path))
}
}
fm.Normalize()
return New(fm.Paths)
}

// MetadataAnnotator injects key from query parameter to gRPC metadata (for REST client).
func MetadataAnnotator(_ context.Context, req *http.Request) metadata.MD {
if err := req.ParseForm(); err == nil && req.Form.Has(key) {
return metadata.Pairs(key, req.Form.Get(key))
}
return nil
}

// UnaryServerInterceptor updates the response message according to the FieldMask.
func UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
resp, err := handler(ctx, req)
if err != nil {
return resp, err
}

message, ok := resp.(proto.Message)
if !ok {
return resp, err
}

md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return resp, err
}

fm := FromMetadata(md)
fm.Filter(message)

return resp, err
}
}
130 changes: 130 additions & 0 deletions internal/fieldmask/fieldmask_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package fieldmask

import (
"context"
"net/http"
"net/url"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/tektoncd/results/internal/fieldmask/test"
"github.com/tidwall/gjson"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)

var p = []string{"a.b", "a.b.c", "d.e", "f"}

var m = metadata.New(map[string]string{
"fields": strings.Join(p, ","),
})

var fm = FieldMask{
"a": FieldMask{
"b": {},
},
"d": FieldMask{
"e": {},
},
"f": {},
}

var j = `
{
"a": {
"b": {
"c": "test value"
}
},
"d": {
"e": "test value"
},
"g": {
"h": "test value"
}
}`

var pm = &test.Test{
Id: "test-id",
Name: "test-name",
Data: []*test.Any{
{
Type: "type-1",
Value: []byte(gjson.Parse(j).String()),
},
{
Type: "type-2",
Value: []byte(gjson.Parse(j).String()),
},
},
}

func TestNew(t *testing.T) {
f := &fieldmaskpb.FieldMask{Paths: p}
f.Normalize()
want := fm
got := New(f.Paths)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Fieldmask mismatch (-want +got):\n%s", diff)
}
}

func TestFieldMask_Paths(t *testing.T) {
f := &fieldmaskpb.FieldMask{Paths: p}
f.Normalize()
want := f.Paths
got := fm.Paths(nil)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Paths mismatch (-want +got):\n%s", diff)
}
}

func TestFieldMask_Filter(t *testing.T) {
f := New([]string{"name", "data.value.d"})
got := proto.Clone(pm)
f.Filter(got)
d := gjson.Parse(`{"d":{"e":"test value"}}`).String()
want := &test.Test{
Name: "test-name",
Data: []*test.Any{
{Value: []byte(d)},
{Value: []byte(d)},
},
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("Proto mismatch (-want +got):\n%s", diff)
}
}

func TestFieldMask_FilterJSON(t *testing.T) {
want := gjson.Parse(`{"a":{"b":{"c":"test value"}}}`).Value()
f := New([]string{"a.b"})
got := f.FilterJSON([]byte(j), []string{})

if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("JSON mismatch (-want +got):\n%s", diff)
}
}

func TestFromMetadata(t *testing.T) {
want := fm
got := FromMetadata(m)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Fieldmask mismatch (-want +got):\n%s", diff)
}
}

func TestMetadataAnnotator(t *testing.T) {
want := m
got := MetadataAnnotator(context.Background(), &http.Request{
Form: url.Values{
"fields": m.Get("fields"),
},
})
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Metadata mismatch (-want +got):\n%s", diff)
}
}
Loading

0 comments on commit 4ea8c19

Please sign in to comment.