Skip to content

Commit 31d0387

Browse files
Merge pull request #48 from form3tech-oss/jk-arr-constraints
Support matching on arrays nested in the body
2 parents 9a9fbeb + c36a434 commit 31d0387

File tree

5 files changed

+469
-184
lines changed

5 files changed

+469
-184
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: 42 additions & 44 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"
@@ -118,18 +117,8 @@ func LoadInteraction(data []byte, alias string) (*Interaction, error) {
118117

119118
switch mediaType {
120119
case mediaTypeJSON:
121-
if jsonRequestBody, ok := requestBody.(map[string]interface{}); ok {
122-
interaction.addJSONConstraintsFromPact("$.body", propertiesWithMatchingRule, jsonRequestBody)
123-
return interaction, nil
124-
}
125-
126-
if _, ok := requestBody.([]interface{}); ok {
127-
// An array request body should be accepted for application/json media type.
128-
// However, no constraint is added for it
129-
return interaction, nil
130-
}
131-
132-
return nil, fmt.Errorf("media type is %s but body is not json", mediaType)
120+
interaction.addJSONConstraintsFromPact("$.body", propertiesWithMatchingRule, requestBody)
121+
return interaction, nil
133122
case mediaTypeText, mediaTypeCsv, mediaTypeXml:
134123
if body, ok := requestBody.(string); ok {
135124
interaction.addTextConstraintsFromPact(propertiesWithMatchingRule, body)
@@ -202,6 +191,8 @@ func getPathRegex(matchingRules map[string]interface{}) (string, error) {
202191
return regexString, nil
203192
}
204193

194+
// Gets the pact JSON file style matching rules from the "matchingRules" property of the request.
195+
// Note that Pact DSL style matching rules within the body are identified later when adding JSON constraints.
205196
func getMatchingRules(request map[string]interface{}) map[string]interface{} {
206197
rules, hasRules := request["matchingRules"]
207198
if !hasRules {
@@ -270,24 +261,37 @@ func parseMediaType(request map[string]interface{}) (string, error) {
270261

271262
// This function adds constraints for all the fields in the JSON request body which do not
272263
// have a corresponding matching rule
273-
func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, values map[string]interface{}) {
274-
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-
}
264+
func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, value interface{}) {
265+
if _, hasRule := matchingRules[path]; hasRule {
266+
return
267+
}
268+
switch val := value.(type) {
269+
case map[string]interface{}:
270+
// json_class is used to test for a Pact DSL-style matching rule within the body. The matchingRules passed
271+
// to this method will not include these.
272+
if _, exists := val["json_class"]; exists {
273+
return
290274
}
275+
for k, v := range val {
276+
i.addJSONConstraintsFromPact(path+"."+k, matchingRules, v)
277+
}
278+
case []interface{}:
279+
// Create constraints for each element in the array. This allows matching rules to override them.
280+
for j := range val {
281+
i.addJSONConstraintsFromPact(fmt.Sprintf("%s[%d]", path, j), matchingRules, val[j])
282+
}
283+
// Length constraint so that requests with additional elements at the end of the array will not match
284+
i.AddConstraint(interactionConstraint{
285+
Path: path,
286+
Format: fmtLen,
287+
Values: []interface{}{len(val)},
288+
})
289+
default:
290+
i.AddConstraint(interactionConstraint{
291+
Path: path,
292+
Format: "%v",
293+
Values: []interface{}{val},
294+
})
291295
}
292296
}
293297

@@ -341,33 +345,27 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions
341345
i.mu.RLock()
342346
defer i.mu.RUnlock()
343347
for _, constraint := range i.constraints {
344-
values := constraint.Values
348+
expected := constraint.Values
345349
if constraint.Source != "" {
346350
var err error
347-
values, err = i.loadValuesFromSource(constraint, interactions)
351+
expected, err = i.loadValuesFromSource(constraint, interactions)
348352
if err != nil {
349353
violations = append(violations, err.Error())
350354
result = false
351355
continue
352356
}
353357
}
354358

355-
actual := ""
356-
val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
359+
actual, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
357360
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)
361+
violations = append(violations,
362+
fmt.Sprintf("constraint path %q cannot be resolved within request: %q", constraint.Path, err))
363+
result = false
362364
continue
363365
}
364-
if err == nil {
365-
actual = fmt.Sprintf("%v", val)
366-
}
367366

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))
367+
if err := constraint.check(expected, actual); err != nil {
368+
violations = append(violations, err.Error())
371369
result = false
372370
}
373371
}

0 commit comments

Comments
 (0)