Skip to content

Commit

Permalink
feat: add MatchV2WithProtoJsonStrategy to support structs generated b…
Browse files Browse the repository at this point in the history
…y proto-gen-go

allows for reuse of structs generated by the client code for reuse
with protobuf or json.   the protobuf portion of the struct def
was not compatible with the MatchV2 due to some field types not supported,
and in some cases the json information is only an attribute of protobuf tag.
this enhancement opts for fieldName from 1. json tag. 2. protobuf tag. 3. skips field
additionally, it will support protobufjson enums.  these enums are ints in protobuf
and strings in json. enums will try to generate a pactTag when not present using the enum type.

save

save2
  • Loading branch information
hborham committed Jan 25, 2023
1 parent 6e805a5 commit e895411
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 26 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/hashicorp/logutils v1.0.0
github.com/klauspost/compress v1.15.4 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/pkg/errors v0.9.1
github.com/spf13/afero v1.6.0
github.com/spf13/cobra v1.1.3
github.com/stretchr/testify v1.7.0
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
113 changes: 108 additions & 5 deletions matchers/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package matchers
import (
"encoding/json"
"fmt"
"github.com/pkg/errors"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"log"
"reflect"
"regexp"
Expand Down Expand Up @@ -289,6 +292,42 @@ func objectToString(obj interface{}) string {
}
}

type FieldMatchArgs struct {
name string
matchType reflect.Type
params params
}
type FieldStrategyFunc func(field reflect.StructField) FieldMatchArgs

var DefaultFieldStrategyFunc = func(field reflect.StructField) FieldMatchArgs {
var v, fieldName string
var ok bool
if v, ok = field.Tag.Lookup("json"); ok {
fieldName = strings.Split(v, ",")[0]
}
return FieldMatchArgs{fieldName, field.Type, pluckParams(field.Type, field.Tag.Get("pact"))}
}

var ProtoJsonFieldStrategyFunc = func(field reflect.StructField) FieldMatchArgs {
if fieldName, enum := fieldNameByTagStrategy(field); enum != "" {
var pactTag string
if _, ok := field.Tag.Lookup("pact"); ok {
pactTag = field.Tag.Get("pact")
} else {
pactTag = generateDefaultTagForEnum(enum)
} // enumerations are int in proto message and mapped to strings in json
return FieldMatchArgs{fieldName, reflect.TypeOf("string"), pluckParams(reflect.TypeOf("string"), pactTag)}
} else if fieldName != "" {
return FieldMatchArgs{fieldName, field.Type, pluckParams(field.Type, field.Tag.Get("pact"))}
} else {
return DefaultFieldStrategyFunc(field)
}
}

type MatchStruct struct {
fieldStrategyFunc FieldStrategyFunc
}

// Match recursively traverses the provided type and outputs a
// matcher string for it that is compatible with the Pact dsl.
// By default, it requires slices to have a minimum of 1 element.
Expand All @@ -300,23 +339,33 @@ func objectToString(obj interface{}) string {
// Minimum Slice Size: `pact:"min=2"`
// String RegEx: `pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"`
func MatchV2(src interface{}) Matcher {
return match(reflect.TypeOf(src), getDefaults())
m := &MatchStruct{fieldStrategyFunc: DefaultFieldStrategyFunc}
return m.match(reflect.TypeOf(src), getDefaults())
}

func MatchV2WithProtoJsonStrategy(src interface{}) Matcher {
m := &MatchStruct{fieldStrategyFunc: ProtoJsonFieldStrategyFunc}
return m.match(reflect.TypeOf(src), getDefaults())
}

// match recursively traverses the provided type and outputs a
// matcher string for it that is compatible with the Pact dsl.
func match(srcType reflect.Type, params params) Matcher {
func (m *MatchStruct) match(srcType reflect.Type, params params) Matcher {
switch kind := srcType.Kind(); kind {
case reflect.Ptr:
return match(srcType.Elem(), params)
return m.match(srcType.Elem(), params)
case reflect.Slice, reflect.Array:
return EachLike(match(srcType.Elem(), getDefaults()), params.slice.min)
return EachLike(m.match(srcType.Elem(), getDefaults()), params.slice.min)
case reflect.Struct:
result := StructMatcher{}

for i := 0; i < srcType.NumField(); i++ {
field := srcType.Field(i)
result[strings.Split(field.Tag.Get("json"), ",")[0]] = match(field.Type, pluckParams(field.Type, field.Tag.Get("pact")))
args := m.fieldStrategyFunc(field)
if args.name == "" {
continue
}
result[args.name] = m.match(args.matchType, args.params)
}
return result
case reflect.String:
Expand Down Expand Up @@ -349,6 +398,60 @@ func match(srcType reflect.Type, params params) Matcher {
}
}

func generateDefaultTagForEnum(enum string) string {
var enumType protoreflect.EnumType
var err error
var example, regex string

//example enum="api.v1.FormType"
if enumType, err = protoregistry.GlobalTypes.FindEnumByName(protoreflect.FullName(enum)); err != nil {
panic(errors.Wrapf(err, "could not find enum %s", enum))
}

values := enumType.Descriptor().Values()
enumNames := make([]string, 0)
for i := 0; i < values.Len(); i++ {
enumNames = append(enumNames, fmt.Sprintf("%s", values.Get(i).Name()))
}
if len(enumNames) > 0 {
example = enumNames[0]
}
regex = strings.Join(enumNames, "|")
// example=INTEGER_RESULT,regex=^(OBJECT_RESULT|STRING_RESULT|INTEGER_RESULT|FLOAT_RESULT|BOOLEAN_RESULT|TIME_RESULT)$
return fmt.Sprintf("example=%s,regex=^(%s)$", example, regex)
}

func fieldNameByTagStrategy(field reflect.StructField) (fieldName string, enum string) {
var v string
var ok bool
if v, ok = field.Tag.Lookup("protobuf"); ok {
// parsing tag value like such. need to find spec for protobuf field tag
// see https://github.com/golang/protobuf/blob/master/protoc-gen-go/generator/generator.go#L1447
// and https://github.com/golang/protobuf/blob/master/protoc-gen-go/generator/generator.go#L2225
// https://github.com/protocolbuffers/protobuf-go/blob/master/cmd/protoc-gen-go/internal_gengo/main.go
//https://github.com/protocolbuffers/protobuf-go/blob/a9481185b34db2fb2f5c90fcf7446be1554e42f7/internal/encoding/tag/tag.go#L143
// "varint,1,opt,name=proto_with_json_tag,json=protoWithJsonTag,proto3"
// "varint,2,opt,name=form_type,json=formType,proto3,enum=api.v1.FormType"
arr := strings.Split(v, ",")
for i := 0; i < len(arr); i++ {
if strings.HasPrefix(arr[i], "json=") {
fieldName = strings.Split(arr[i], "=")[1]
//return fieldName, false
}
if strings.HasPrefix(arr[i], "enum=") {
enum = strings.Split(arr[i], "=")[1]
//return fieldName, false
}
}
}

if v, ok = field.Tag.Lookup("json"); ok {
fieldName = strings.Split(v, ",")[0]
//return fieldName , e
}
return fieldName, enum
}

// params are plucked from 'pact' struct tags as match() traverses
// struct fields. They are passed back into match() along with their
// associated type to serve as parameters for the dsl functions.
Expand Down
116 changes: 95 additions & 21 deletions matchers/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,16 +582,32 @@ func TestMatch(t *testing.T) {
Integer int `json:"integer" pact:"example=42"`
Float float32 `json:"float" pact:"example=6.66"`
}
// mixedDTO in order to reuse protoc-gen-go where structs are compatible with protobuf and json
type mixedDTO struct {
// has tag and should be in output
OnlyJsonTag string `json:"onlyJsonTag"`
// no tag, skip
NoTagString string
// no tag, skip - this covers case of proto compatible structs that contain func fields
NoTagFunc func()
BothUseJsonTag int32 `protobuf:"varint,1,opt,name=both_use_json_tag,json=bothNameFromProtobufTag,proto3" json:"bothNameFromJsonTag,omitempty"`
ProtoWithoutJsonTag *struct {
OnlyJsonTag string `json:"onlyJsonTagNested"`
// no tag, skip
NoTag func()
} `protobuf:"bytes,7,opt,name=proto_without_json_tag,json=onlyProtobufTag,proto3,oneof"`
}
str := "str"
type args struct {
src interface{}
}
tests := []struct {
type matchTest struct {
name string
args args
want Matcher
wantPanic bool
}{
}
defaultTests := []matchTest{
{
name: "recursive case - ptr",
args: args{
Expand Down Expand Up @@ -774,27 +790,85 @@ func TestMatch(t *testing.T) {
},
wantPanic: true,
},
//{
// name: "structs mixed for compatibility with proto3 and json types",
// args: args{
// src: mixedDTO{},
// },
// want: StructMatcher{
// "onlyJsonTag": Like("string"),
// "bothNameFromJsonTag": Like(1),
// "onlyProtobufTag": StructMatcher{"onlyJsonTagNested": Like("string")},
// },
//},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got Matcher
var didPanic bool
defer func() {
if rec := recover(); rec != nil {
fmt.Println(rec)
didPanic = true
}
if tt.wantPanic != didPanic {
t.Errorf("Match() - '%s': didPanic = %v, want %v", tt.name, didPanic, tt.wantPanic)
} else if !didPanic && !reflect.DeepEqual(got, tt.want) {
t.Errorf("Match() - '%s': = %v, want %v", tt.name, got, tt.want)
}
}()
matchV2Tests := append(defaultTests, matchTest{
name: "structs mixed for compatibility with proto3 and json types",
args: args{
src: mixedDTO{},
},
want: StructMatcher{
"onlyJsonTag": Like("string"),
"bothNameFromJsonTag": Like(1),
//"onlyProtobufTag": StructMatcher{"onlyJsonTagNested": Like("string")},
},
})
t.Run("MatchV2", func(t *testing.T) {
for _, tt := range matchV2Tests {
t.Run(tt.name, func(t *testing.T) {
var got Matcher
var didPanic bool
defer func() {
if rec := recover(); rec != nil {
fmt.Println(rec)
didPanic = true
}
if tt.wantPanic != didPanic {
t.Errorf("Match() - '%s': didPanic = %v, want %v", tt.name, didPanic, tt.wantPanic)
} else if !didPanic && !reflect.DeepEqual(got, tt.want) {
t.Errorf("Match() - '%s': = %v, want %v", tt.name, got, tt.want)
}
}()

got = MatchV2(tt.args.src)
log.Println("Got matcher: ", got)
})
}
got = MatchV2(tt.args.src)
log.Println("Got matcher: ", got)
})
}
})

matchV2ProtoTests := append(defaultTests, matchTest{
name: "structs mixed for compatibility with proto3 and json types",
args: args{
src: mixedDTO{},
},
want: StructMatcher{
"onlyJsonTag": Like("string"),
"bothNameFromJsonTag": Like(1),
"onlyProtobufTag": StructMatcher{"onlyJsonTagNested": Like("string")},
},
})
t.Run("MatchV2WithProtoJsonStrategy", func(t *testing.T) {
for _, tt := range matchV2ProtoTests {
t.Run(tt.name, func(t *testing.T) {
var got Matcher
var didPanic bool
defer func() {
if rec := recover(); rec != nil {
fmt.Println(rec)
didPanic = true
}
if tt.wantPanic != didPanic {
t.Errorf("Match() - '%s': didPanic = %v, want %v", tt.name, didPanic, tt.wantPanic)
} else if !didPanic && !reflect.DeepEqual(got, tt.want) {
t.Errorf("Match() - '%s': = %v, want %v", tt.name, got, tt.want)
}
}()

got = MatchV2WithProtoJsonStrategy(tt.args.src)
log.Println("Got matcher: ", got)
})
}
})
}

func Test_pluckParams(t *testing.T) {
Expand Down

0 comments on commit e895411

Please sign in to comment.