Skip to content

Commit 324fa3e

Browse files
feat: support matching on arrays nested in the body
If there is an array nested within a JSON body on the pact, the interaction was previously skipped. Now it can be matched on, allowing for functionality such as waiting for N interactions to work with these requests.
1 parent 9a9fbeb commit 324fa3e

File tree

5 files changed

+389
-32
lines changed

5 files changed

+389
-32
lines changed

internal/app/pactproxy/constraint.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package pactproxy
22

33
import (
4+
"fmt"
45
"strings"
56
)
67

8+
const fmtLen = "_length_"
9+
710
type interactionConstraint struct {
811
Interaction string `json:"interaction"`
912
Path string `json:"path"`
@@ -15,3 +18,34 @@ type interactionConstraint struct {
1518
func (i interactionConstraint) Key() string {
1619
return strings.Join([]string{i.Interaction, i.Path}, "_")
1720
}
21+
22+
func (i interactionConstraint) Check(expectedValues []interface{}, actualValue interface{}) error {
23+
if i.Format == fmtLen {
24+
if len(expectedValues) != 1 {
25+
return fmt.Errorf(
26+
"expected single positive integer value for path %q length constraint, but there are %v expected values",
27+
i.Path, len(expectedValues))
28+
}
29+
expected, ok := expectedValues[0].(int)
30+
if !ok || expected < 0 {
31+
return fmt.Errorf("expected value for %q length constraint must be a positive integer", i.Path)
32+
}
33+
34+
actualSlice, ok := actualValue.([]interface{})
35+
if !ok {
36+
return fmt.Errorf("value at path %q must be an array due to length constraint", i.Path)
37+
}
38+
if expected != len(actualSlice) {
39+
return fmt.Errorf("value of length %v at path %q does not match length constraint %v",
40+
expected, i.Path, len(actualSlice))
41+
}
42+
return nil
43+
}
44+
45+
expected := fmt.Sprintf(i.Format, expectedValues...)
46+
actual := fmt.Sprintf("%v", actualValue)
47+
if expected != actual {
48+
return fmt.Errorf("value %q at path %q does not match constraint %q", actual, i.Path, expected)
49+
}
50+
return nil
51+
}

internal/app/pactproxy/interaction.go

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"encoding/json"
55
"fmt"
66
"mime"
7-
"reflect"
87
"regexp"
98
"strings"
109
"sync"
@@ -202,6 +201,8 @@ func getPathRegex(matchingRules map[string]interface{}) (string, error) {
202201
return regexString, nil
203202
}
204203

204+
// Gets the pact JSON file style matching rules from the "matchingRules" property of the request.
205+
// Note that Pact DSL style matching rules within the body are identified later when adding JSON constraints.
205206
func getMatchingRules(request map[string]interface{}) map[string]interface{} {
206207
rules, hasRules := request["matchingRules"]
207208
if !hasRules {
@@ -272,22 +273,40 @@ func parseMediaType(request map[string]interface{}) (string, error) {
272273
// have a corresponding matching rule
273274
func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, values map[string]interface{}) {
274275
for k, v := range values {
275-
switch val := v.(type) {
276-
case map[string]interface{}:
277-
if _, exists := val["json_class"]; exists {
278-
continue
279-
}
280-
i.addJSONConstraintsFromPact(path+"."+k, matchingRules, val)
281-
default:
282-
p := path + "." + k
283-
if _, hasRule := matchingRules[p]; !hasRule {
284-
i.AddConstraint(interactionConstraint{
285-
Path: p,
286-
Format: "%v",
287-
Values: []interface{}{val},
288-
})
289-
}
276+
i.addJSONConstraintsFromPactAny(path+"."+k, matchingRules, v)
277+
}
278+
}
279+
280+
func (i *Interaction) addJSONConstraintsFromPactAny(path string, matchingRules map[string]bool, value interface{}) {
281+
if _, hasRule := matchingRules[path]; hasRule {
282+
return
283+
}
284+
285+
switch val := value.(type) {
286+
case map[string]interface{}:
287+
// json_class is used to test for a Pact DSL-style matching rule within the body. The matchingRules passed
288+
// to this method will not include these.
289+
if _, exists := val["json_class"]; exists {
290+
return
291+
}
292+
i.addJSONConstraintsFromPact(path, matchingRules, val)
293+
case []interface{}:
294+
// Create constraints for each element in the array. This allows matching rules to override them.
295+
for j := range val {
296+
i.addJSONConstraintsFromPactAny(fmt.Sprintf("%v[%v]", path, j), matchingRules, val[j])
290297
}
298+
// Length constraint so that requests with additional elements at the end of the array will not match
299+
i.AddConstraint(interactionConstraint{
300+
Path: path,
301+
Format: fmtLen,
302+
Values: []interface{}{len(val)},
303+
})
304+
default:
305+
i.AddConstraint(interactionConstraint{
306+
Path: path,
307+
Format: "%v",
308+
Values: []interface{}{val},
309+
})
291310
}
292311
}
293312

@@ -352,22 +371,16 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions
352371
}
353372
}
354373

355-
actual := ""
356374
val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
357375
if err != nil {
358-
log.Warn(err)
359-
}
360-
if reflect.TypeOf(val) == reflect.TypeOf([]interface{}{}) {
361-
log.Infof("skipping matching on []interface{} type for path '%s'", constraint.Path)
376+
violations = append(violations,
377+
fmt.Sprintf("constraint path %q cannot be resolved within request: %q", constraint.Path, err))
378+
result = false
362379
continue
363380
}
364-
if err == nil {
365-
actual = fmt.Sprintf("%v", val)
366-
}
367381

368-
expected := fmt.Sprintf(constraint.Format, values...)
369-
if actual != expected {
370-
violations = append(violations, fmt.Sprintf("value '%s' at path '%s' does not match constraint '%s'", actual, constraint.Path, expected))
382+
if err := constraint.Check(values, val); err != nil {
383+
violations = append(violations, err.Error())
371384
result = false
372385
}
373386
}

internal/app/pactproxy/interaction_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,114 @@ func TestLoadInteractionPlainTextConstraints(t *testing.T) {
120120
}
121121
}
122122

123+
func TestLoadInteractionJSONConstraints(t *testing.T) {
124+
arrMatchersNotPresent := `{
125+
"description": "A request to create an address",
126+
"request": {
127+
"method": "POST",
128+
"path": "/addresses",
129+
"headers": {
130+
"Content-Type": "application/json"
131+
},
132+
"body": {
133+
"addressLines": ["line 1", "line 2"]
134+
}
135+
},
136+
"response": {
137+
"status": 200,
138+
"headers": {
139+
"Content-Type": "application/json"
140+
},
141+
"body": {
142+
"addressLines": ["line 1", "line 2"]
143+
}
144+
}
145+
}`
146+
arrMatcherPresent :=
147+
`{
148+
"description": "A request to create an address",
149+
"request": {
150+
"method": "POST",
151+
"path": "/addresses",
152+
"headers": {
153+
"Content-Type": "application/json"
154+
},
155+
"body": {
156+
"addressLines": ["line 1", "line 2"]
157+
},
158+
"matchingRules": {
159+
"$.body.addressLines[0]": {
160+
"regex": ".*"
161+
}
162+
}
163+
},
164+
"response": {
165+
"status": 200,
166+
"headers": {
167+
"Content-Type": "application/json"
168+
},
169+
"body": {
170+
"addressLines": ["line 1", "line 2"]
171+
}
172+
}
173+
}`
174+
tests := []struct {
175+
name string
176+
interaction []byte
177+
wantConstraints []interactionConstraint
178+
}{
179+
{
180+
name: "array and matcher not present - interactions are created per element",
181+
interaction: []byte(arrMatchersNotPresent),
182+
wantConstraints: []interactionConstraint{
183+
{
184+
Path: "$.body.addressLines[0]",
185+
Format: "%v",
186+
Values: []interface{}{"line 1"},
187+
},
188+
{
189+
Path: "$.body.addressLines[1]",
190+
Format: "%v",
191+
Values: []interface{}{"line 2"},
192+
},
193+
{
194+
Path: "$.body.addressLines",
195+
Format: fmtLen,
196+
Values: []interface{}{2},
197+
},
198+
},
199+
},
200+
{
201+
name: "array and matcher present - interaction is not created for matched element",
202+
interaction: []byte(arrMatcherPresent),
203+
wantConstraints: []interactionConstraint{
204+
{
205+
Path: "$.body.addressLines[1]",
206+
Format: "%v",
207+
Values: []interface{}{"line 2"},
208+
},
209+
{
210+
Path: "$.body.addressLines",
211+
Format: fmtLen,
212+
Values: []interface{}{2},
213+
},
214+
},
215+
},
216+
}
217+
for _, tt := range tests {
218+
t.Run(tt.name, func(t *testing.T) {
219+
interaction, err := LoadInteraction(tt.interaction, "alias")
220+
require.NoError(t, err)
221+
222+
actual := make([]interactionConstraint, 0, len(interaction.constraints))
223+
for _, constraint := range interaction.constraints {
224+
actual = append(actual, constraint)
225+
}
226+
assert.ElementsMatch(t, tt.wantConstraints, actual)
227+
})
228+
}
229+
}
230+
123231
// This test asserts that given a pact v3-style nested matching rule, a constraint
124232
// is not created for the corresponding property
125233
func TestV3MatchingRulesLeadToCorrectConstraints(t *testing.T) {

internal/app/proxy_stage_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,8 +451,7 @@ func (s *ProxyStage) the_nth_response_body_is(n int, data []byte) *ProxyStage {
451451
s.assert.GreaterOrEqual(len(s.responseBodies), n, "number of request bodies is les than expected")
452452

453453
body := s.responseBodies[n-1]
454-
c := bytes.Compare(body, data)
455-
s.assert.Equal(0, c, "Expected body did not match")
454+
s.assert.Equal(data, body, "Expected body did not match")
456455
return s
457456
}
458457

0 commit comments

Comments
 (0)