diff --git a/examples/extensions/match_ext_test.go b/examples/extensions/match_ext_test.go new file mode 100644 index 000000000..187cb8dbc --- /dev/null +++ b/examples/extensions/match_ext_test.go @@ -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) +} diff --git a/go.mod b/go.mod index 1a6310115..30bdd6f41 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1b4c569dc..a7a016058 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/matchers/ext.go b/matchers/ext.go new file mode 100644 index 000000000..d47013c5a --- /dev/null +++ b/matchers/ext.go @@ -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()) +} diff --git a/matchers/matcher.go b/matchers/matcher.go index c550ee5e2..4dec722f8 100644 --- a/matchers/matcher.go +++ b/matchers/matcher.go @@ -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. @@ -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: