Skip to content

Commit

Permalink
support matching on arrays nested in the body
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
joshkeegan-form3 committed Apr 5, 2024
1 parent 9a9fbeb commit 2e72d35
Show file tree
Hide file tree
Showing 5 changed files with 392 additions and 35 deletions.
34 changes: 34 additions & 0 deletions internal/app/pactproxy/constraint.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package pactproxy

import (
"fmt"
"strings"
)

const fmtLen = "_length_"

type interactionConstraint struct {
Interaction string `json:"interaction"`
Path string `json:"path"`
Expand All @@ -15,3 +18,34 @@ type interactionConstraint struct {
func (i interactionConstraint) Key() string {
return strings.Join([]string{i.Interaction, i.Path}, "_")
}

func (i interactionConstraint) check(expectedValues []interface{}, actualValue interface{}) error {
if i.Format == fmtLen {
if len(expectedValues) != 1 {
return fmt.Errorf(
"expected single positive integer value for path %q length constraint, but there are %v expected values",
i.Path, len(expectedValues))
}
expected, ok := expectedValues[0].(int)
if !ok || expected < 0 {
return fmt.Errorf("expected value for %q length constraint must be a positive integer", i.Path)
}

actualSlice, ok := actualValue.([]interface{})
if !ok {
return fmt.Errorf("value at path %q must be an array due to length constraint", i.Path)
}
if expected != len(actualSlice) {
return fmt.Errorf("value of length %v at path %q does not match length constraint %v",
expected, i.Path, len(actualSlice))
}
return nil
}

expected := fmt.Sprintf(i.Format, expectedValues...)
actual := fmt.Sprintf("%v", actualValue)
if expected != actual {
return fmt.Errorf("value %q at path %q does not match constraint %q", actual, i.Path, expected)
}
return nil
}
73 changes: 43 additions & 30 deletions internal/app/pactproxy/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"mime"
"reflect"
"regexp"
"strings"
"sync"
Expand Down Expand Up @@ -202,6 +201,8 @@ func getPathRegex(matchingRules map[string]interface{}) (string, error) {
return regexString, nil
}

// Gets the pact JSON file style matching rules from the "matchingRules" property of the request.
// Note that Pact DSL style matching rules within the body are identified later when adding JSON constraints.
func getMatchingRules(request map[string]interface{}) map[string]interface{} {
rules, hasRules := request["matchingRules"]
if !hasRules {
Expand Down Expand Up @@ -272,22 +273,40 @@ func parseMediaType(request map[string]interface{}) (string, error) {
// have a corresponding matching rule
func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, values map[string]interface{}) {
for k, v := range values {
switch val := v.(type) {
case map[string]interface{}:
if _, exists := val["json_class"]; exists {
continue
}
i.addJSONConstraintsFromPact(path+"."+k, matchingRules, val)
default:
p := path + "." + k
if _, hasRule := matchingRules[p]; !hasRule {
i.AddConstraint(interactionConstraint{
Path: p,
Format: "%v",
Values: []interface{}{val},
})
}
i.addJSONConstraintsFromPactAny(path+"."+k, matchingRules, v)
}
}

func (i *Interaction) addJSONConstraintsFromPactAny(path string, matchingRules map[string]bool, value interface{}) {
if _, hasRule := matchingRules[path]; hasRule {
return
}

switch val := value.(type) {
case map[string]interface{}:
// json_class is used to test for a Pact DSL-style matching rule within the body. The matchingRules passed
// to this method will not include these.
if _, exists := val["json_class"]; exists {
return
}
i.addJSONConstraintsFromPact(path, matchingRules, val)
case []interface{}:
// Create constraints for each element in the array. This allows matching rules to override them.
for j := range val {
i.addJSONConstraintsFromPactAny(fmt.Sprintf("%s[%d]", path, j), matchingRules, val[j])
}
// Length constraint so that requests with additional elements at the end of the array will not match
i.AddConstraint(interactionConstraint{
Path: path,
Format: fmtLen,
Values: []interface{}{len(val)},
})
default:
i.AddConstraint(interactionConstraint{
Path: path,
Format: "%v",
Values: []interface{}{val},
})
}
}

Expand Down Expand Up @@ -341,33 +360,27 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions
i.mu.RLock()
defer i.mu.RUnlock()
for _, constraint := range i.constraints {
values := constraint.Values
expected := constraint.Values
if constraint.Source != "" {
var err error
values, err = i.loadValuesFromSource(constraint, interactions)
expected, err = i.loadValuesFromSource(constraint, interactions)
if err != nil {
violations = append(violations, err.Error())
result = false
continue
}
}

actual := ""
val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
actual, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
if err != nil {
log.Warn(err)
}
if reflect.TypeOf(val) == reflect.TypeOf([]interface{}{}) {
log.Infof("skipping matching on []interface{} type for path '%s'", constraint.Path)
violations = append(violations,
fmt.Sprintf("constraint path %q cannot be resolved within request: %q", constraint.Path, err))
result = false
continue
}
if err == nil {
actual = fmt.Sprintf("%v", val)
}

expected := fmt.Sprintf(constraint.Format, values...)
if actual != expected {
violations = append(violations, fmt.Sprintf("value '%s' at path '%s' does not match constraint '%s'", actual, constraint.Path, expected))
if err := constraint.check(expected, actual); err != nil {
violations = append(violations, err.Error())
result = false
}
}
Expand Down
108 changes: 108 additions & 0 deletions internal/app/pactproxy/interaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,114 @@ func TestLoadInteractionPlainTextConstraints(t *testing.T) {
}
}

func TestLoadInteractionJSONConstraints(t *testing.T) {
arrMatchersNotPresent := `{
"description": "A request to create an address",
"request": {
"method": "POST",
"path": "/addresses",
"headers": {
"Content-Type": "application/json"
},
"body": {
"addressLines": ["line 1", "line 2"]
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"addressLines": ["line 1", "line 2"]
}
}
}`
arrMatcherPresent :=
`{
"description": "A request to create an address",
"request": {
"method": "POST",
"path": "/addresses",
"headers": {
"Content-Type": "application/json"
},
"body": {
"addressLines": ["line 1", "line 2"]
},
"matchingRules": {
"$.body.addressLines[0]": {
"regex": ".*"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"addressLines": ["line 1", "line 2"]
}
}
}`
tests := []struct {
name string
interaction []byte
wantConstraints []interactionConstraint
}{
{
name: "array and matcher not present - interactions are created per element",
interaction: []byte(arrMatchersNotPresent),
wantConstraints: []interactionConstraint{
{
Path: "$.body.addressLines[0]",
Format: "%v",
Values: []interface{}{"line 1"},
},
{
Path: "$.body.addressLines[1]",
Format: "%v",
Values: []interface{}{"line 2"},
},
{
Path: "$.body.addressLines",
Format: fmtLen,
Values: []interface{}{2},
},
},
},
{
name: "array and matcher present - interaction is not created for matched element",
interaction: []byte(arrMatcherPresent),
wantConstraints: []interactionConstraint{
{
Path: "$.body.addressLines[1]",
Format: "%v",
Values: []interface{}{"line 2"},
},
{
Path: "$.body.addressLines",
Format: fmtLen,
Values: []interface{}{2},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
interaction, err := LoadInteraction(tt.interaction, "alias")
require.NoError(t, err)

actual := make([]interactionConstraint, 0, len(interaction.constraints))
for _, constraint := range interaction.constraints {
actual = append(actual, constraint)
}
assert.ElementsMatch(t, tt.wantConstraints, actual)
})
}
}

// This test asserts that given a pact v3-style nested matching rule, a constraint
// is not created for the corresponding property
func TestV3MatchingRulesLeadToCorrectConstraints(t *testing.T) {
Expand Down
3 changes: 1 addition & 2 deletions internal/app/proxy_stage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,7 @@ func (s *ProxyStage) the_nth_response_body_is(n int, data []byte) *ProxyStage {
s.assert.GreaterOrEqual(len(s.responseBodies), n, "number of request bodies is les than expected")

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

Expand Down
Loading

0 comments on commit 2e72d35

Please sign in to comment.