From e8954118d3a228d0cc821c62d992144b3426dbfa Mon Sep 17 00:00:00 2001 From: "hazem.borham" Date: Thu, 19 Jan 2023 15:54:25 -0800 Subject: [PATCH] feat: add MatchV2WithProtoJsonStrategy to support structs generated by 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 --- go.mod | 1 + go.sum | 1 + matchers/matcher.go | 113 ++++++++++++++++++++++++++++++++++++-- matchers/matcher_test.go | 116 ++++++++++++++++++++++++++++++++------- 4 files changed, 205 insertions(+), 26 deletions(-) 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/matcher.go b/matchers/matcher.go index c550ee5e2..a1953cb32 100644 --- a/matchers/matcher.go +++ b/matchers/matcher.go @@ -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" @@ -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. @@ -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: @@ -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. diff --git a/matchers/matcher_test.go b/matchers/matcher_test.go index 923881522..a544d913f 100644 --- a/matchers/matcher_test.go +++ b/matchers/matcher_test.go @@ -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{ @@ -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) {