Skip to content

Commit

Permalink
feat(api): use static types for built-in matchers
Browse files Browse the repository at this point in the history
Define EachLike, Like, Term matchers as structs instead of using generic
Matcher and provide respective overrides for GetValue method to replace
the switch with polymorphism and create a more expressive type system

BREAKING CHANGE: The return type of Match func as well as the return
type  of various matcher constructors  such  as `Decimal`, `Date`,
`IPAddress`, etc. has changed from Matcher struct to StringMatcher
interface
  • Loading branch information
Alex Tsibulya committed Nov 28, 2018
1 parent 6ec2571 commit 633eb1f
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 64 deletions.
121 changes: 66 additions & 55 deletions dsl/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,114 +24,139 @@ const (
var timeExample = time.Date(2000, 2, 1, 12, 30, 0, 0, time.UTC)

type eachLike struct {
Type string `json:"json_class"`
Contents interface{} `json:"contents"`
Type string `json:"json_class"`
Min int `json:"min"`
}

func (m eachLike) GetValue() interface{} {
return m.Contents
}

func (m eachLike) isMatcher() {
}

type like struct {
Type string `json:"json_class"`
Contents interface{} `json:"contents"`
Type string `json:"json_class"`
}

func (m like) GetValue() interface{} {
return m.Contents
}

func (m like) isMatcher() {
}

type term struct {
Type string `json:"json_class"`
Data struct {
Generate interface{} `json:"generate"`
Matcher struct {
Type string `json:"json_class"`
O int `json:"o"`
Regex interface{} `json:"s"`
} `json:"matcher"`
} `json:"data"`
Data termData `json:"data"`
Type string `json:"json_class"`
}

func (m term) GetValue() interface{} {
return m.Data.Generate
}

func (m term) isMatcher() {
}

type termData struct {
Generate interface{} `json:"generate"`
Matcher termMatcher `json:"matcher"`
}

type termMatcher struct {
Type string `json:"json_class"`
O int `json:"o"`
Regex interface{} `json:"s"`
}

// EachLike specifies that a given element in a JSON body can be repeated
// "minRequired" times. Number needs to be 1 or greater
func EachLike(content interface{}, minRequired int) Matcher {
return Matcher{
"json_class": "Pact::ArrayLike",
"contents": content,
"min": minRequired,
func EachLike(content interface{}, minRequired int) StringMatcher {
return eachLike{
Type: "Pact::ArrayLike",
Contents: content,
Min: minRequired,
}
}

// Like specifies that the given content type should be matched based
// on type (int, string etc.) instead of a verbatim match.
func Like(content interface{}) Matcher {
return Matcher{
"json_class": "Pact::SomethingLike",
"contents": content,
func Like(content interface{}) StringMatcher {
return like{
Type: "Pact::SomethingLike",
Contents: content,
}
}

// Term specifies that the matching should generate a value
// and also match using a regular expression.
func Term(generate string, matcher string) Matcher {
return Matcher{
"json_class": "Pact::Term",
"data": map[string]interface{}{
"generate": generate,
"matcher": map[string]interface{}{
"json_class": "Regexp",
"o": 0,
"s": matcher,
func Term(generate string, matcher string) StringMatcher {
return term{
Type: "Pact::Term",
Data: termData{
Generate: generate,
Matcher: termMatcher{
Type: "Regexp",
O: 0,
Regex: matcher,
},
},
}
}

// HexValue defines a matcher that accepts hexidecimal values.
func HexValue() Matcher {
func HexValue() StringMatcher {
return Regex("3F", hexadecimal)
}

// Identifier defines a matcher that accepts integer values.
func Identifier() Matcher {
func Identifier() StringMatcher {
return Like(42)
}

// Integer defines a matcher that accepts ints. Identical to Identifier.
var Integer = Identifier

// IPAddress defines a matcher that accepts valid IPv4 addresses.
func IPAddress() Matcher {
func IPAddress() StringMatcher {
return Regex("127.0.0.1", ipAddress)
}

// IPv4Address matches valid IPv4 addresses.
var IPv4Address = IPAddress

// IPv6Address defines a matcher that accepts IP addresses.
func IPv6Address() Matcher {
func IPv6Address() StringMatcher {
return Regex("::ffff:192.0.2.128", ipAddress)
}

// Decimal defines a matcher that accepts any decimal value.
func Decimal() Matcher {
func Decimal() StringMatcher {
return Like(42.0)
}

// Timestamp matches a pattern corresponding to the ISO_DATETIME_FORMAT, which
// is "yyyy-MM-dd'T'HH:mm:ss". The current date and time is used as the eaxmple.
func Timestamp() Matcher {
func Timestamp() StringMatcher {
return Regex(timeExample.Format(time.RFC3339), timestamp)
}

// Date matches a pattern corresponding to the ISO_DATE_FORMAT, which
// is "yyyy-MM-dd". The current date is used as the eaxmple.
func Date() Matcher {
func Date() StringMatcher {
return Regex(timeExample.Format("2006-01-02"), date)
}

// Time matches a pattern corresponding to the ISO_DATE_FORMAT, which
// is "'T'HH:mm:ss". The current tem is used as the eaxmple.
func Time() Matcher {
func Time() StringMatcher {
return Regex(timeExample.Format("T15:04:05"), timeRegex)
}

// UUID defines a matcher that accepts UUIDs. Produces a v4 UUID as the example.
func UUID() Matcher {
func UUID() StringMatcher {
return Regex("fc763eba-0905-41c5-a27f-3934ab26786c", uuid)
}

Expand Down Expand Up @@ -187,20 +212,6 @@ func (m Matcher) isMatcher() {}
// GetValue returns the raw generated value for the matcher
// without any of the matching detail context
func (m Matcher) GetValue() interface{} {
switch m["json_class"] {
default:
return nil
case "Pact::ArrayLike":
return m["contents"]
case "Pact::SomethingLike":
return m["contents"]
case "Pact::Term":
data, ok := m["data"].(map[string]interface{})
if ok {
return data["generate"]
}
}

return nil
}

Expand Down Expand Up @@ -233,20 +244,20 @@ func objectToString(obj interface{}) string {
// 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 {
func Match(src interface{}) StringMatcher {
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 {
func match(srcType reflect.Type, params params) StringMatcher {
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{})
result := Matcher{}

for i := 0; i < srcType.NumField(); i++ {
field := srcType.Field(i)
Expand Down
18 changes: 9 additions & 9 deletions dsl/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ func TestMatcher_NestAllTheThings(t *testing.T) {
Matcher{
"colour": Term("red", "red|green|blue"),
"size": Like(10),
"tag": EachLike([]Matcher{Like("jumper"), Like("shirt")}, 2),
"tag": EachLike([]StringMatcher{Like("jumper"), Like("shirt")}, 2),
},
1),
1))
Expand Down Expand Up @@ -346,7 +346,7 @@ func formatJSON(object interface{}) interface{} {

// Instrument the Matcher type to be able to assert the
// values and regexs contained within!
func (m Matcher) getValue() interface{} {
func getMatcherValue(m interface{}) interface{} {
mString := objectToString(m)

// try like
Expand All @@ -369,7 +369,7 @@ func (m Matcher) getValue() interface{} {
func TestMatcher_SugarMatchers(t *testing.T) {

type matcherTestCase struct {
matcher Matcher
matcher StringMatcher
testCase func(val interface{}) error
}
matchers := map[string]matcherTestCase{
Expand Down Expand Up @@ -483,7 +483,7 @@ func TestMatcher_SugarMatchers(t *testing.T) {
}
var err error
for k, v := range matchers {
if err = v.testCase(v.matcher.getValue()); err != nil {
if err = v.testCase(getMatcherValue(v.matcher)); err != nil {
t.Fatalf("error validating matcher '%s': %v", k, err)
}
}
Expand Down Expand Up @@ -570,7 +570,7 @@ func TestMatch(t *testing.T) {
tests := []struct {
name string
args args
want Matcher
want StringMatcher
wantPanic bool
}{
{
Expand Down Expand Up @@ -599,7 +599,7 @@ func TestMatch(t *testing.T) {
args: args{
src: wordDTO{},
},
want: map[string]interface{}{
want: Matcher{
"word": Like(`"string"`),
"length": Like(1),
},
Expand All @@ -609,7 +609,7 @@ func TestMatch(t *testing.T) {
args: args{
src: dateDTO{},
},
want: map[string]interface{}{
want: Matcher{
"date": Term("2000-01-01", `^\\d{4}-\\d{2}-\\d{2}$`),
},
},
Expand All @@ -618,7 +618,7 @@ func TestMatch(t *testing.T) {
args: args{
src: wordsDTO{},
},
want: map[string]interface{}{
want: Matcher{
"words": EachLike(Like(`"string"`), 2),
},
},
Expand Down Expand Up @@ -730,7 +730,7 @@ func TestMatch(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got Matcher
var got StringMatcher
var didPanic bool
defer func() {
if rec := recover(); rec != nil {
Expand Down

0 comments on commit 633eb1f

Please sign in to comment.