Skip to content

Commit 2e72d35

Browse files
support matching on arrays nested in the body
If there is an array nested within a JSON body on the pact, when a request was received and the interaction had its constraints evaluated, the constraint for the value of the array got skipped. This meant that if pact-proxy had interactions that differed only in the content of arrays, when requests were received it would match all of those interactions rather than the specific one that would have been matched had the contents of the array been considered. This was causing flaky tests in a project that used pact-proxy to wait for N of a specific interaction. The interaction was the same as another apart from the content of an array in the body. Since pact-proxy could not tell these apart, it would continue after N of either of these interactions, not N of the one the test needed to wait for. That randomly caused the next test to fail because interactions from the previous test were still ongoing. Now when constraints are added from the Pact, we generate constraints for each element in the array, as well as a length check. When evaluating constraints, we can then run these like we do for any other type. This allows for two interactions to be created that differ only in the contents of an array and pact-proxy is able to tell them apart. Generating constraints on a per-element basis is done to also allow for matching rules that apply to an individual array element. When matching rules are specified, pact-proxy does not enforce these so the constraint must not be generated for that element. As with non-array matching rules, if a request is received and the remaining constraints are met, the request is considered a match and proxied to Pact server, which will check matching rules.
1 parent 9a9fbeb commit 2e72d35

File tree

5 files changed

+392
-35
lines changed

5 files changed

+392
-35
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: 43 additions & 30 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("%s[%d]", 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

@@ -341,33 +360,27 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions
341360
i.mu.RLock()
342361
defer i.mu.RUnlock()
343362
for _, constraint := range i.constraints {
344-
values := constraint.Values
363+
expected := constraint.Values
345364
if constraint.Source != "" {
346365
var err error
347-
values, err = i.loadValuesFromSource(constraint, interactions)
366+
expected, err = i.loadValuesFromSource(constraint, interactions)
348367
if err != nil {
349368
violations = append(violations, err.Error())
350369
result = false
351370
continue
352371
}
353372
}
354373

355-
actual := ""
356-
val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
374+
actual, 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(expected, actual); 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)