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) {