Skip to content

Commit

Permalink
feat: add custom user package extension for MatchV2 for parsing of st…
Browse files Browse the repository at this point in the history
…ructs field tags

this allows customized support for struct field parsing.

the example custom implementation could parse struct fields used to support protojson structs
  • Loading branch information
hborham committed Jan 26, 2023
1 parent 6e805a5 commit 62d394d
Show file tree
Hide file tree
Showing 5 changed files with 242 additions and 5 deletions.
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 {
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
}
result[args.Name] = m.match(args.MatchType, pluckParams(args.MatchType, args.PactTag))
}
return result
case reflect.String:
Expand Down

0 comments on commit 62d394d

Please sign in to comment.