Skip to content

Commit

Permalink
feat(api): uplift API to support better matching
Browse files Browse the repository at this point in the history
BREAKING CHANGE: significant type modifications for the
Request and Response bodies, to accommodate type safe matching.

Request/Response types now accept union types to discriminate
between true objects with Matchers, or primitive types.

Introduction of dsl.String, dsl.StringMatcher, dsl.MapMatcher,
and dsl.Matcher.

Relateds to #73
  • Loading branch information
mefellows committed Mar 24, 2018
1 parent d76c83d commit 00e7d7f
Show file tree
Hide file tree
Showing 13 changed files with 195 additions and 296 deletions.
56 changes: 24 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,12 @@ func TestConsumer(t *testing.T) {
WithRequest(dsl.Request{
Method: "GET",
Path: "/foobar",
Headers: map[string]string{"Content-Type": "application/json"},
Headers: dsl.MapMatcher{"Content-Type": "application/json"},
Body: `{"s":"foo"}`,
}).
WillRespondWith(dsl.Response{
Status: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Headers: dsl.MapMatcher{"Content-Type": "application/json"},
Body: `{"s":"bar"}`,
})

Expand All @@ -182,44 +182,36 @@ as the element _type_ (valid JSON number, string, object etc.) itself matches.
consisting of elements like those passed in. `min` must be >= 1. `content` may
be a valid JSON value: e.g. strings, numbers and objects.

Matchers can be used on the `Body`, `Headers`, `Path` and `Query` fields of the `dsl.Request`
type, and the `Body` and `Headers` fields of the `dsl.Response` type.

*Example:*

Here is a complex example that shows how all 3 terms can be used together:
Here is a more complex example that shows how all 3 terms can be used together:

```go
colour := Term("red", "red|green|blue")

match := EachLike(
EachLike(
fmt.Sprintf(`{
"size": 10,
"colour": %s,
"tag": [["jumper", "shirt]]
}`, colour)
1),
1))
body :=
Like(map[string]interface{}{
"response": map[string]interface{}{
"name": Like("Billy"),
"type": Term("admin", "admin|user|guest"),
"items": EachLike("cat", 2)
},
})
```

This example will result in a response body from the mock server that looks like:
```json
[
[
{
"size": 10,
"colour": "red",
"tag": [
[
"jumper",
"shirt"
],
[
"jumper",
"shirt"
]
]
}
]
]
{
"response": {
"name": "Billy",
"type": "admin",
"items": [
"cat",
"cat"
]
}
}
```

See the [matcher tests](https://github.com/pact-foundation/pact-go/blob/master/dsl/matcher_test.go)
Expand Down
49 changes: 19 additions & 30 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,38 +71,27 @@ cases.
Here is a complex example that shows how all 3 terms can be used together:
colour := Term("red", "red|green|blue")
match := EachLike(
EachLike(
fmt.Sprintf(`{
"size": 10,
"colour": %s,
"tag": [["jumper", "shirt]]
}`, colour)
1),
1))
body :=
Like(map[string]interface{}{
"response": map[string]interface{}{
"name": Like("Billy"),
"type": Term("admin", "admin|user|guest"),
"items": EachLike("cat", 2)
},
})
This example will result in a response body from the mock server that looks like:
[
[
{
"size": 10,
"colour": "red",
"tag": [
[
"jumper",
"shirt"
],
[
"jumper",
"shirt"
]
]
}
]
]
{
"response": {
"name": "Billy",
"type": "admin",
"items": [
"cat",
"cat"
]
}
}
See the examples in the dsl package and the matcher tests
(https://github.com/pact-foundation/pact-go/blob/master/dsl/matcher_test.go)
Expand Down
12 changes: 4 additions & 8 deletions dsl/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,7 @@ func (p *Interaction) WithRequest(request Request) *Interaction {
// Need to fix any weird JSON marshalling issues with the body Here
// If body is a string, not an object, we need to put it back into an object
// so that it's not double encoded
switch content := request.Body.(type) {
case string:
p.Request.Body = toObject([]byte(content))
default:
// leave alone
}
p.Request.Body = toObject(request.Body)

return p
}
Expand All @@ -69,13 +64,14 @@ func (p *Interaction) WillRespondWith(response Response) *Interaction {
func toObject(stringOrObject interface{}) interface{} {

switch content := stringOrObject.(type) {
case []byte:
case string:
var obj interface{}
err := json.Unmarshal([]byte(content), &obj)

if err != nil {
log.Println("[DEBUG] interaction: error unmarshaling object into string:", err.Error())
return stringOrObject
log.Printf("[DEBUG] interaction: error unmarshaling string '%v' into an object. Probably not an object: %v\n", stringOrObject, err.Error())
return content
}

return obj
Expand Down
4 changes: 2 additions & 2 deletions dsl/interaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func TestInteraction_WillRespondWith(t *testing.T) {

func TestInteraction_toObject(t *testing.T) {
// unstructured string should not be changed
res := toObject([]byte("somestring"))
res := toObject("somestring")
content, ok := res.(string)

if !ok {
Expand All @@ -133,7 +133,7 @@ func TestInteraction_toObject(t *testing.T) {
}

// errors should return a string repro of original interface{}
res = toObject([]byte(""))
res = toObject("")
content, ok = res.(string)

if !ok {
Expand Down
116 changes: 85 additions & 31 deletions dsl/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,29 @@ import (
"log"
)

// type Matcher interface{}

// 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) string {
// TODO: should we just be marshalling these things as map[string]interface{} JSON objects anyway?
// this might remove the need for this ugly string/object combination
// TODO: if content is a string, it should probably be immediately converted to an object
// TODO: the above seems to have been fixed, but perhaps best to just _only_ allow objects
// instead of allowing string and other nonsense??
return objectToString(map[string]interface{}{
func EachLike(content interface{}, minRequired int) Matcher {
return Matcher{
"json_class": "Pact::ArrayLike",
"contents": toObject(content),
"min": minRequired,
})
// return fmt.Sprintf(`
// {
// "json_class": "Pact::ArrayLike",
// "contents": %v,
// "min": %d
// }`, objectToString(content), 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{}) string {
return objectToString(map[string]interface{}{
func Like(content interface{}) Matcher {
return Matcher{
"json_class": "Pact::SomethingLike",
"contents": toObject(content),
})
// return fmt.Sprintf(`
// {
// "json_class": "Pact::SomethingLike",
// "contents": %v
// }`, objectToString(content))
}
}

// Term specifies that the matching should generate a value
// and also match using a regular expression.
func Term(generate string, matcher string) MatcherString {
return MatcherString(objectToString(map[string]interface{}{
func Term(generate string, matcher string) Matcher {
return Matcher{
"json_class": "Pact::Term",
"data": map[string]interface{}{
"generate": toObject(generate),
Expand All @@ -55,21 +37,93 @@ func Term(generate string, matcher string) MatcherString {
"s": toObject(matcher),
},
},
}))
}
}

// Regex is a more appropriately named alias for the "Term" matcher
var Regex = Term

// StringMatcher allows a string or Matcher to be provided in
// when matching with the DSL
// We use the strategy outlined at http://www.jerf.org/iri/post/2917
// to create a "sum" or "union" type.
type StringMatcher interface {
// isMatcher is how we tell the compiler that strings
// and other types are the same / allowed
isMatcher()
}

// S is the string primitive wrapper (alias) for the StringMatcher type,
// it allows plain strings to be matched
type S string

func (s S) isMatcher() {}

// String is the longer named form of the string primitive wrapper,
// it allows plain strings to be matched
type String string

func (s String) isMatcher() {}

// Matcher matches a complex object structure, which may itself
// contain nested Matchers
type Matcher map[string]interface{}

func (m Matcher) isMatcher() {}

// MarshalJSON is a custom encoder for Header type
func (m Matcher) MarshalJSON() ([]byte, error) {
obj := map[string]interface{}{}

for header, value := range m {
obj[header] = toObject(value)
}

return json.Marshal(obj)
}

// UnmarshalJSON is a custom decoder for Header type
func (m *Matcher) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &m); err != nil {
return err
}

return nil
}

// MapMatcher allows a map[string]string-like object
// to also contain complex matchers
type MapMatcher map[string]StringMatcher

// MarshalJSON is a custom encoder for Header type
func (h MapMatcher) MarshalJSON() ([]byte, error) {
obj := map[string]interface{}{}

for header, value := range h {
obj[header] = toObject(value)
}

return json.Marshal(obj)
}

// UnmarshalJSON is a custom decoder for Header type
func (h *MapMatcher) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &h); err != nil {
return err
}

return nil
}

// Takes an object and converts it to a JSON representation
func objectToString(obj interface{}) string {
switch content := obj.(type) {
case string:
log.Println("STRING VALUE:", content)
return content
default:
log.Printf("OBJECT VALUE: %v", obj)
jsonString, err := json.Marshal(obj)
log.Println("OBJECT -> JSON VALUE:", string(jsonString))
if err != nil {
log.Println("[DEBUG] interaction: error unmarshaling object into string:", err.Error())
log.Println("[DEBUG] objectToString: error unmarshaling object into string:", err.Error())
return ""
}
return string(jsonString)
Expand Down
Loading

0 comments on commit 00e7d7f

Please sign in to comment.