diff --git a/README.md b/README.md index 01e35d170..54c4856e7 100644 --- a/README.md +++ b/README.md @@ -34,43 +34,43 @@ including [flexible matching](http://docs.pact.io/documentation/matching.html). -- [Pact Go](#pact-go) - - [Introduction](#introduction) - - [Table of Contents](#table-of-contents) - - [Installation](#installation) - - [Installation on \*nix](#installation-on-\nix) - - [Using Pact](#using-pact) - - [HTTP API Testing](#http-api-testing) - - [Consumer Side Testing](#consumer-side-testing) - - [Provider API Testing](#provider-api-testing) - - [Provider Verification](#provider-verification) - - [API with Authorization](#api-with-authorization) - - [Publishing pacts to a Pact Broker and Tagging Pacts](#publishing-pacts-to-a-pact-broker-and-tagging-pacts) - - [Publishing from Go code](#publishing-from-go-code) - - [Publishing Provider Verification Results to a Pact Broker](#publishing-provider-verification-results-to-a-pact-broker) - - [Publishing from the CLI](#publishing-from-the-cli) - - [Using the Pact Broker with Basic authentication](#using-the-pact-broker-with-basic-authentication) - - [Asynchronous API Testing](#asynchronous-api-testing) - - [Consumer](#consumer) - - [Provider (Producer)](#provider-producer) - - [Pact Broker Integration](#pact-broker-integration) - - [Matching](#matching) - - [Matching on types](#matching-on-types) - - [Matching on arrays](#matching-on-arrays) - - [Matching by regular expression](#matching-by-regular-expression) - - [Match common formats](#match-common-formats) - - [Examples](#examples) - - [HTTP APIs](#http-apis) - - [Asynchronous APIs](#asynchronous-apis) - - [Integrated examples](#integrated-examples) - - [Troubleshooting](#troubleshooting) - - [Splitting tests across multiple files](#splitting-tests-across-multiple-files) - - [Output Logging](#output-logging) - - [Contact](#contact) - - [Documentation](#documentation) - - [Troubleshooting](#troubleshooting-1) - - [Roadmap](#roadmap) - - [Contributing](#contributing) +* [Introduction](#introduction) +* [Table of Contents](#table-of-contents) +* [Installation](#installation) + * [Installation on \*nix](#installation-on-\nix) +* [Using Pact](#using-pact) +* [HTTP API Testing](#http-api-testing) + * [Consumer Side Testing](#consumer-side-testing) + * [Provider API Testing](#provider-api-testing) + * [Provider Verification](#provider-verification) + * [API with Authorization](#api-with-authorization) + * [Publishing pacts to a Pact Broker and Tagging Pacts](#publishing-pacts-to-a-pact-broker-and-tagging-pacts) + * [Publishing from Go code](#publishing-from-go-code) + * [Publishing Provider Verification Results to a Pact Broker](#publishing-provider-verification-results-to-a-pact-broker) + * [Publishing from the CLI](#publishing-from-the-cli) + * [Using the Pact Broker with Basic authentication](#using-the-pact-broker-with-basic-authentication) +* [Asynchronous API Testing](#asynchronous-api-testing) + * [Consumer](#consumer) + * [Provider (Producer)](#provider-producer) + * [Pact Broker Integration](#pact-broker-integration) +* [Matching](#matching) + * [Matching on types](#matching-on-types) + * [Matching on arrays](#matching-on-arrays) + * [Matching by regular expression](#matching-by-regular-expression) + * [Match common formats](#match-common-formats) + * [Auto-generate matchers from struct tags](#auto-generate-matchers-from-struct-tags) +* [Examples](#examples) + * [HTTP APIs](#http-apis) + * [Asynchronous APIs](#asynchronous-apis) + * [Integrated examples](#integrated-examples) +* [Troubleshooting](#troubleshooting) + * [Splitting tests across multiple files](#splitting-tests-across-multiple-files) + * [Output Logging](#output-logging) +* [Contact](#contact) +* [Documentation](#documentation) +* [Troubleshooting](#troubleshooting-1) +* [Roadmap](#roadmap) +* [Contributing](#contributing) @@ -469,7 +469,7 @@ func TestMessageConsumer_Success(t *testing.T) { **Explanation**: -1. The API - a contrived API handler example. Expects a User object and throws an `Error` if it can't handle it. +1. The API - a contrived API handler example. Expects a User object and throws an `Error` if it can't handle it. * In most applications, some form of transactionality exists and communication with a MQ/broker happens. * It's important we separate out the protocol bits from the message handling bits, so that we can test that in isolation. 1. Creates the MessageConsumer class @@ -597,6 +597,50 @@ Often times, you find yourself having to re-write regular expressions for common | `IPv6Address()` | Match string containing IP6 formatted address | | `UUID()` | Match strings containing UUIDs | +#### Auto-generate matchers from struct tags + +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. + ## Examples ### HTTP APIs diff --git a/dsl/matcher.go b/dsl/matcher.go index 164a77cd8..1134fcfbc 100644 --- a/dsl/matcher.go +++ b/dsl/matcher.go @@ -4,7 +4,9 @@ import ( "encoding/json" "fmt" "log" + "reflect" "strconv" + "strings" "time" ) @@ -306,3 +308,111 @@ func objectToString(obj interface{}) string { return string(jsonString) } } + +// 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{}) Matcher { + 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) Matcher { + 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 := make(map[string]interface{}) + + for i := 0; i < srcType.NumField(); i++ { + field := srcType.Field(i) + result[field.Tag.Get("json")] = match(field.Type, pluckParams(field.Type, field.Tag.Get("pact"))) + } + return 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 f87809c86..ea65bf45b 100644 --- a/dsl/matcher_test.go +++ b/dsl/matcher_test.go @@ -524,3 +524,376 @@ func ExampleEachLike() { // "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 Matcher + 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: map[string]interface{}{ + "word": Like(`"string"`), + "length": Like(1), + }, + }, + { + name: "recursive case - struct with custom string tag", + args: args{ + src: dateDTO{}, + }, + want: map[string]interface{}{ + "date": Term("2000-01-01", `^\\d{4}-\\d{2}-\\d{2}$`), + }, + }, + { + name: "recursive case - struct with custom slice tag", + args: args{ + src: wordsDTO{}, + }, + want: map[string]interface{}{ + "words": 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 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 = Match(tt.args.src) + log.Println("Got matcher: ", got) + }) + } +} + +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) + }) + } +}