From 45b72aef0f764a2ace3f59ed44e75eada5aeaf78 Mon Sep 17 00:00:00 2001 From: Alex Ramey Date: Tue, 8 May 2018 16:39:00 -0400 Subject: [PATCH] feat(matching): add auto-match capability (#78) --- README.md | 39 +++++ dsl/matcher.go | 117 +++++++++++++- dsl/matcher_test.go | 364 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 519 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e7d97b2c..da78284e1 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,45 @@ This example will result in a response body from the mock server that looks like ] ``` +#### Auto-Generate Match String (Consumer Tests) + +Furthermore, if you isolate your Data Transfer Objects (DTOs) to an adapters package so that they exactly reflect the interface between you and your provider, then you can leverage `dsl.Match` to auto-generate the expected response body in your contract tests. Under the hood, `Match` recursively traverses the DTO struct and uses `Term, Like, and EachLike` to create the contract. + +This saves the trouble of declaring the contract by hand. It also maintains one source of truth. To change the consumer-provider interface, you only have to update your DTO struct and the contract will automatically follow suit. + +*Example:* + +```go +type DTO struct { + ID string `json:"id"` + Title string `json:"title"` + Tags []string `json:"tags" pact:"min=2"` + Date string `json:"date" pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"` +} +``` +then specifying a response body is as simple as: +```go + // Set up our expected interactions. + pact. + AddInteraction(). + Given("User foo exists"). + UponReceiving("A request to get foo"). + WithRequest(dsl.Request{ + Method: "GET", + Path: "/foobar", + Headers: map[string]string{"Content-Type": "application/json"}, + }). + WillRespondWith(dsl.Response{ + Status: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: Match(DTO{}), // That's it!!! + }) +``` + +The `pact` struct tags shown above are optional. By default, dsl.Match just asserts that the JSON shape matches the struct and that the field types match. + +See [dsl.Match](https://github.com/pact-foundation/pact-go/blob/master/dsl/matcher.go) for more information. + See the [matcher tests](https://github.com/pact-foundation/pact-go/blob/master/dsl/matcher_test.go) for more matching examples. diff --git a/dsl/matcher.go b/dsl/matcher.go index eff22a476..d84daafdb 100644 --- a/dsl/matcher.go +++ b/dsl/matcher.go @@ -1,6 +1,10 @@ package dsl -import "fmt" +import ( + "fmt" + "reflect" + "strings" +) // EachLike specifies that a given element in a JSON body can be repeated // "minRequired" times. Number needs to be 1 or greater @@ -39,3 +43,114 @@ func Term(generate string, matcher string) string { } }`, generate, matcher) } + +// 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. +// For concrete types, it uses `dsl.Like` to assert that types match. +// Optionally, you may override these defaults by supplying custom +// pact tags on your structs. +// +// Supported Tag Formats +// Minimum Slice Size: `pact:"min=2"` +// String RegEx: `pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"` +func Match(src interface{}) string { + return 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) string { + switch kind := srcType.Kind(); kind { + case reflect.Ptr: + return match(srcType.Elem(), params) + case reflect.Slice, reflect.Array: + return EachLike(match(srcType.Elem(), getDefaults()), params.slice.min) + case reflect.Struct: + result := `{` + for i := 0; i < srcType.NumField(); i++ { + field := srcType.Field(i) + result += fmt.Sprintf( + `"%s": %s,`, + field.Tag.Get("json"), + match(field.Type, pluckParams(field.Type, field.Tag.Get("pact"))), + ) + } + return strings.TrimSuffix(result, ",") + `}` + case reflect.String: + if params.str.regEx != "" { + return Term(params.str.example, params.str.regEx) + } + return Like(`"string"`) + case reflect.Bool: + return Like(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return Like(1) + default: + panic(fmt.Sprintf("match: unhandled type: %v", srcType)) + } +} + +// 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. +type params struct { + slice sliceParams + str stringParams +} + +type sliceParams struct { + min int +} + +type stringParams struct { + example string + regEx string +} + +// getDefaults returns the default params +func getDefaults() params { + return params{ + slice: sliceParams{ + min: 1, + }, + } +} + +// pluckParams converts a 'pact' tag into a pactParams struct +// Supported Tag Formats +// Minimum Slice Size: `pact:"min=2"` +// String RegEx: `pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"` +func pluckParams(srcType reflect.Type, pactTag string) params { + params := getDefaults() + if pactTag == "" { + return params + } + + switch kind := srcType.Kind(); kind { + case reflect.Slice: + if _, err := fmt.Sscanf(pactTag, "min=%d", ¶ms.slice.min); err != nil { + triggerInvalidPactTagPanic(pactTag, err) + } + case reflect.String: + components := strings.Split(pactTag, ",regex=") + + if len(components) != 2 { + triggerInvalidPactTagPanic(pactTag, fmt.Errorf("invalid format: unable to split on ',regex='")) + } else if len(components[1]) == 0 { + triggerInvalidPactTagPanic(pactTag, fmt.Errorf("invalid format: regex must not be empty")) + } else if _, err := fmt.Sscanf(components[0], "example=%s", ¶ms.str.example); err != nil { + triggerInvalidPactTagPanic(pactTag, err) + } + + params.str.regEx = strings.Replace(components[1], `\`, `\\`, -1) + } + + return params +} + +func triggerInvalidPactTagPanic(tag string, err error) { + panic(fmt.Sprintf("match: encountered invalid pact tag %q . . . parsing failed with error: %v", tag, err)) +} diff --git a/dsl/matcher_test.go b/dsl/matcher_test.go index 811894d56..220e449b1 100644 --- a/dsl/matcher_test.go +++ b/dsl/matcher_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "reflect" "testing" ) @@ -415,3 +416,366 @@ func ExampleEachLike_nested() { // "min": 1 //} } + +func TestMatch(t *testing.T) { + type wordDTO struct { + Word string `json:"word"` + Length int `json:"length"` + } + type dateDTO struct { + Date string `json:"date" pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"` + } + type wordsDTO struct { + Words []string `json:"words" pact:"min=2"` + } + str := "str" + type args struct { + src interface{} + } + tests := []struct { + name string + args args + want string + wantPanic bool + }{ + { + name: "recursive case - ptr", + args: args{ + src: &str, + }, + want: Like(`"string"`), + }, + { + name: "recursive case - slice", + args: args{ + src: []string{}, + }, + want: EachLike(Like(`"string"`), 1), + }, + { + name: "recursive case - array", + args: args{ + src: [1]string{}, + }, + want: EachLike(Like(`"string"`), 1), + }, + { + name: "recursive case - struct", + args: args{ + src: wordDTO{}, + }, + want: fmt.Sprintf("{\"word\": %s,\"length\": %s}", Like(`"string"`), Like(1)), + }, + { + name: "recursive case - struct with custom string tag", + args: args{ + src: dateDTO{}, + }, + want: fmt.Sprintf("{\"date\": %s}", Term("2000-01-01", `^\\d{4}-\\d{2}-\\d{2}$`)), + }, + { + name: "recursive case - struct with custom slice tag", + args: args{ + src: wordsDTO{}, + }, + want: fmt.Sprintf("{\"words\": %s}", EachLike(Like(`"string"`), 2)), + }, + { + 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 - int8", + args: args{ + src: int8(1), + }, + want: Like(1), + }, + { + name: "base case - int16", + args: args{ + src: int16(1), + }, + want: Like(1), + }, + { + name: "base case - int32", + args: args{ + src: int32(1), + }, + want: Like(1), + }, + { + name: "base case - int64", + args: args{ + src: int64(1), + }, + want: Like(1), + }, + { + name: "base case - uint", + args: args{ + src: uint(1), + }, + want: Like(1), + }, + { + name: "base case - uint8", + args: args{ + src: uint8(1), + }, + want: Like(1), + }, + { + name: "base case - uint16", + args: args{ + src: uint16(1), + }, + want: Like(1), + }, + { + name: "base case - uint32", + args: args{ + src: uint32(1), + }, + want: Like(1), + }, + { + name: "base case - uint64", + args: args{ + src: uint64(1), + }, + want: Like(1), + }, + { + name: "base case - float32", + args: args{ + src: float32(1), + }, + want: Like(1), + }, + { + name: "base case - float64", + args: args{ + src: float64(1), + }, + want: Like(1), + }, + { + name: "error - unhandled type", + args: args{ + src: make(map[string]string), + }, + wantPanic: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got string + var didPanic bool + defer func() { + if rec := recover(); rec != nil { + didPanic = true + } + if tt.wantPanic != didPanic { + t.Errorf("Match() didPanic = %v, want %v", didPanic, tt.wantPanic) + } else if !didPanic && !reflect.DeepEqual(got, tt.want) { + t.Errorf("Match() = %v, want %v", got, tt.want) + } + }() + got = Match(tt.args.src) + }) + } +} + +func Test_pluckParams(t *testing.T) { + type args struct { + srcType reflect.Type + pactTag string + } + tests := []struct { + name string + args args + want params + wantPanic bool + }{ + { + name: "expected use - slice tag", + args: args{ + srcType: reflect.TypeOf([]string{}), + pactTag: "min=2", + }, + want: params{ + slice: sliceParams{ + min: 2, + }, + str: stringParams{ + example: getDefaults().str.example, + regEx: getDefaults().str.regEx, + }, + }, + }, + { + name: "empty slice tag", + args: args{ + srcType: reflect.TypeOf([]string{}), + pactTag: "", + }, + want: getDefaults(), + }, + { + name: "invalid slice tag - no min", + args: args{ + srcType: reflect.TypeOf([]string{}), + pactTag: "min=", + }, + wantPanic: true, + }, + { + name: "invalid slice tag - min typo capital letter", + args: args{ + srcType: reflect.TypeOf([]string{}), + pactTag: "Min=2", + }, + wantPanic: true, + }, + { + name: "invalid slice tag - min typo non-number", + args: args{ + srcType: reflect.TypeOf([]string{}), + pactTag: "min=a", + }, + wantPanic: true, + }, + { + name: "expected use - string tag", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=aBcD123,regex=[A-Za-z0-9]", + }, + want: params{ + slice: sliceParams{ + min: getDefaults().slice.min, + }, + str: stringParams{ + example: "aBcD123", + regEx: "[A-Za-z0-9]", + }, + }, + }, + { + name: "expected use - string tag with backslash", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=33,regex=\\d{2}", + }, + want: params{ + slice: sliceParams{ + min: getDefaults().slice.min, + }, + str: stringParams{ + example: "33", + regEx: `\\d{2}`, + }, + }, + }, + { + name: "empty string tag", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "", + }, + want: getDefaults(), + }, + { + name: "invalid string tag - no example value", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=,regex=[A-Za-z0-9]", + }, + wantPanic: true, + }, + { + name: "invalid string tag - no example", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "regex=[A-Za-z0-9]", + }, + wantPanic: true, + }, + { + name: "invalid string tag - example typo", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "exmple=aBcD123,regex=[A-Za-z0-9]", + }, + wantPanic: true, + }, + { + name: "invalid string tag - no regex value", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=aBcD123,regex=", + }, + wantPanic: true, + }, + { + name: "invalid string tag - no regex", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=aBcD123", + }, + wantPanic: true, + }, + { + name: "invalid string tag - regex typo", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=aBcD123,regx=[A-Za-z0-9]", + }, + wantPanic: true, + }, + { + name: "invalid string tag - space inserted", + args: args{ + srcType: reflect.TypeOf(""), + pactTag: "example=aBcD123 regex=[A-Za-z0-9]", + }, + wantPanic: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got params + var didPanic bool + defer func() { + if rec := recover(); rec != nil { + didPanic = true + } + if tt.wantPanic != didPanic { + t.Errorf("pluckParams() didPanic = %v, want %v", didPanic, tt.wantPanic) + } else if !didPanic && !reflect.DeepEqual(got, tt.want) { + t.Errorf("pluckParams() = %v, want %v", got, tt.want) + } + }() + got = pluckParams(tt.args.srcType, tt.args.pactTag) + }) + } +}