Skip to content

Commit

Permalink
feat(matching): add auto-match capability (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
Alex Ramey authored and mefellows committed May 8, 2018
1 parent 8bad006 commit 45b72ae
Show file tree
Hide file tree
Showing 3 changed files with 519 additions and 1 deletion.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
117 changes: 116 additions & 1 deletion dsl/matcher.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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", &params.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", &params.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))
}
Loading

0 comments on commit 45b72ae

Please sign in to comment.