Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support to MatchV2 structs generated by proto-gen-go #262

Draft
wants to merge 1 commit into
base: 2.x.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions examples/extensions/match_ext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package extensions

import (
"fmt"
"log"
"reflect"
"strings"
"testing"

"github.com/pact-foundation/pact-go/v2/matchers"
"github.com/pkg/errors"

"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
)

var (
Like = matchers.Like
EachLike = matchers.EachLike
Term = matchers.Term
)

type (
StructMatcher = matchers.StructMatcher
Matcher = matchers.Matcher
)

func TestMatch(t *testing.T) {
// 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"`
}
type args struct {
src interface{}
}
type matchTest struct {
name string
args args
want Matcher
wantPanic bool
}
defaultTests := []matchTest{
{
name: "base case - string",
args: args{
src: "string",
},
want: Like("string"),
},
{
name: "base case - bool",
args: args{
src: true,
},
want: Like(true),
},
{
name: "base case - int",
args: args{
src: 1,
},
want: Like(1),
},
{
name: "base case - uint",
args: args{
src: uint(1),
},
want: Like(1),
},
{
name: "base case - float32",
args: args{
src: float32(1),
},
want: Like(1.1),
},
{
name: "error - unhandled type",
args: args{
src: make(map[string]string),
},
wantPanic: true,
},
}

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")},
},
})
customMatcher := matchers.NewCustomMatchStructV2(matchers.CustomMatchStructV2Args{StrategyFunc: protoJsonFieldStrategyFunc})
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 = customMatcher.MatchV2(tt.args.src)
log.Println("Got matcher: ", got)
})
}
}

// ProtoJsonFieldStrategyFunc provides an example to extend the matchers.CustomMatchStructV2Args.Match
// extension allows for parsing of custom structs tags, and this example demonstrates a
// potential solution relating to protojson
var protoJsonFieldStrategyFunc = func(field reflect.StructField) matchers.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)
}
return matchers.FieldMatchArgs{Name: fieldName, MatchType: reflect.TypeOf("string"), PactTag: pactTag}
} else if fieldName != "" {
return matchers.FieldMatchArgs{Name: fieldName, MatchType: field.Type, PactTag: field.Tag.Get("pact")}
} else {
return matchers.DefaultFieldStrategyFunc(field)
}
}

func fieldNameByTagStrategy(field reflect.StructField) (fieldName string, enum string) {
var v string
var ok bool
if v, ok = field.Tag.Lookup("protobuf"); ok {
arr := strings.Split(v, ",")
for i := 0; i < len(arr); i++ {
if strings.HasPrefix(arr[i], "json=") {
fieldName = strings.Split(arr[i], "=")[1]
}
if strings.HasPrefix(arr[i], "enum=") {
enum = strings.Split(arr[i], "=")[1]
}
}
}

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

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

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, "|")
return fmt.Sprintf("example=%s,regex=^(%s)$", example, regex)
}
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
19 changes: 19 additions & 0 deletions matchers/ext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package matchers

import "reflect"

type CustomMatchStructV2Args struct {
StrategyFunc FieldStrategyFunc
}

type customMatchStructV2 struct {
matchStruct matchStructV2
}

func NewCustomMatchStructV2(args CustomMatchStructV2Args) customMatchStructV2 {
return customMatchStructV2{matchStruct: matchStructV2{args.StrategyFunc}}
}

func (m *customMatchStructV2) MatchV2(src interface{}) Matcher {
return m.matchStruct.match(reflect.TypeOf(src), getDefaults())
}
35 changes: 30 additions & 5 deletions matchers/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,26 @@ func objectToString(obj interface{}) string {
}
}

type FieldMatchArgs struct {
Name string
MatchType reflect.Type
PactTag string
}
type FieldStrategyFunc func(field reflect.StructField) FieldMatchArgs

var DefaultFieldStrategyFunc = func(field reflect.StructField) FieldMatchArgs {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracted your original code as a default strategy and exported so i can fall back on this when needed

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, field.Tag.Get("pact")}
}

type matchStructV2 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 +320,28 @@ 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 := &matchStructV2{FieldStrategyFunc: DefaultFieldStrategyFunc}
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 *matchStructV2) 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
Copy link
Contributor Author

@hborham hborham Jan 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kind of a bug currently and now it will get skipped explicitly

}
result[args.Name] = m.match(args.MatchType, pluckParams(args.MatchType, args.PactTag))
}
return result
case reflect.String:
Expand Down