Skip to content

Commit dd1c14e

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.
1 parent 9a9fbeb commit dd1c14e

File tree

3 files changed

+213
-29
lines changed

3 files changed

+213
-29
lines changed

internal/app/pactproxy/interaction.go

Lines changed: 27 additions & 24 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"
@@ -272,21 +271,29 @@ func parseMediaType(request map[string]interface{}) (string, error) {
272271
// have a corresponding matching rule
273272
func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, values map[string]interface{}) {
274273
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-
}
274+
i.addJSONConstraintsFromPactAny(path+"."+k, matchingRules, v)
275+
}
276+
}
277+
278+
func (i *Interaction) addJSONConstraintsFromPactAny(path string, matchingRules map[string]bool, value interface{}) {
279+
switch val := value.(type) {
280+
case map[string]interface{}:
281+
if _, exists := val["json_class"]; exists {
282+
return
283+
}
284+
i.addJSONConstraintsFromPact(path, matchingRules, val)
285+
case []interface{}:
286+
// Otherwise, create constraints for each element in the array. This allows matching rules to override them.
287+
for j := range val {
288+
i.addJSONConstraintsFromPactAny(fmt.Sprintf("%v[%v]", path, j), matchingRules, val[j])
289+
}
290+
default:
291+
if _, hasRule := matchingRules[path]; !hasRule {
292+
i.AddConstraint(interactionConstraint{
293+
Path: path,
294+
Format: "%v",
295+
Values: []interface{}{val},
296+
})
290297
}
291298
}
292299
}
@@ -352,20 +359,16 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions
352359
}
353360
}
354361

355-
actual := ""
356362
val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request))
357363
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)
364+
violations = append(violations,
365+
fmt.Sprintf("constraint path %q cannot be resolved within request: %q", constraint.Path, err))
366+
result = false
362367
continue
363368
}
364-
if err == nil {
365-
actual = fmt.Sprintf("%v", val)
366-
}
367369

368370
expected := fmt.Sprintf(constraint.Format, values...)
371+
actual := fmt.Sprintf("%v", val)
369372
if actual != expected {
370373
violations = append(violations, fmt.Sprintf("value '%s' at path '%s' does not match constraint '%s'", actual, constraint.Path, expected))
371374
result = false

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

internal/app/proxy_test.go

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package app
33
import (
44
"net/http"
55
"testing"
6+
7+
"github.com/pact-foundation/pact-go/dsl"
68
)
79

810
func TestLargePactResponse(t *testing.T) {
@@ -592,9 +594,9 @@ func TestArrayBodyRequestUnmatchedRequestBody(t *testing.T) {
592594
a_request_is_sent_with("application/json", tc.unmatchedReqBody)
593595

594596
then.
595-
// Pact Mock Server returns 500 if request body does not match,
596-
// so the response status code is not checked
597-
pact_verification_is_not_successful()
597+
// Pact Mock Server returns 500 if request body does not match
598+
pact_verification_is_not_successful().and().
599+
the_response_is_(http.StatusInternalServerError)
598600
})
599601
}
600602
}
@@ -637,3 +639,183 @@ func TestArrayBodyRequestConstraintDoesNotMatch(t *testing.T) {
637639
})
638640
}
639641
}
642+
643+
func TestArrayNestedWithinBody(t *testing.T) {
644+
pactReqBody := map[string]interface{}{
645+
"entries": []interface{}{
646+
map[string]string{"key": "val"},
647+
map[string]string{"key": "val"},
648+
},
649+
}
650+
const respContentType = "application/json"
651+
const respBody = `[{"status":"ok"}]`
652+
653+
const matchedReqBody = `{"entries": [ {"key": "val"}, {"key": "val"} ]}`
654+
const unmatchedReqBody = `{"entries": [ {"key": "val"}, {"key": "unexpected value"} ]}`
655+
656+
const matchedConstraintPath = "$.body.entries[0].key"
657+
const matchedConstraintValue = "val"
658+
659+
const unmatchedConstraintPath = "$.body.entries[1].key"
660+
const unmatchedConstraintValue = "wrong value"
661+
662+
const outOfBoundsConstraintPath = "$.body.entries[2].key"
663+
664+
t.Run("Matches", func(t *testing.T) {
665+
given, when, then := NewProxyStage(t)
666+
667+
given.
668+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody)
669+
670+
when.
671+
a_request_is_sent_with("application/json", matchedReqBody)
672+
673+
then.
674+
pact_verification_is_successful().and().
675+
the_response_is_(http.StatusOK).and().
676+
the_response_body_is(respBody)
677+
})
678+
679+
t.Run("Does not match", func(t *testing.T) {
680+
given, when, then := NewProxyStage(t)
681+
682+
given.
683+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody)
684+
685+
when.
686+
a_request_is_sent_with("application/json", unmatchedReqBody)
687+
688+
then.
689+
pact_verification_is_not_successful().and().
690+
the_response_is_(http.StatusBadRequest)
691+
})
692+
693+
t.Run("Matches with additional constraint", func(t *testing.T) {
694+
given, when, then := NewProxyStage(t)
695+
696+
given.
697+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and().
698+
an_additional_constraint_is_added(matchedConstraintPath, matchedConstraintValue)
699+
700+
when.
701+
a_request_is_sent_with("application/json", matchedReqBody)
702+
703+
then.
704+
pact_verification_is_successful().and().
705+
the_response_is_(http.StatusOK).and().
706+
the_response_body_is(respBody)
707+
})
708+
709+
t.Run("Does not match due to additional constraint with different value", func(t *testing.T) {
710+
given, when, then := NewProxyStage(t)
711+
712+
given.
713+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and().
714+
an_additional_constraint_is_added(unmatchedConstraintPath, unmatchedConstraintValue)
715+
716+
when.
717+
a_request_is_sent_with("application/json", matchedReqBody)
718+
719+
then.
720+
pact_verification_is_not_successful().and().
721+
the_response_is_(http.StatusBadRequest)
722+
})
723+
724+
t.Run("Does not match due to additional constraint out of array bounds", func(t *testing.T) {
725+
given, when, then := NewProxyStage(t)
726+
727+
given.
728+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and().
729+
an_additional_constraint_is_added(outOfBoundsConstraintPath, matchedConstraintValue)
730+
731+
when.
732+
a_request_is_sent_with("application/json", matchedReqBody)
733+
734+
then.
735+
pact_verification_is_not_successful().and().
736+
the_response_is_(http.StatusBadRequest)
737+
})
738+
}
739+
740+
func TestArrayNestedWithinBodyContainingMatchers(t *testing.T) {
741+
pactReqBody := map[string]interface{}{
742+
"entries": []interface{}{
743+
map[string]any{"key": "val"},
744+
map[string]any{"key": dsl.Term("a", "(a|b)")},
745+
},
746+
}
747+
const respContentType = "application/json"
748+
const respBody = `[{"status":"ok"}]`
749+
750+
const matchedReqBody = `{"entries": [ {"key": "val"}, {"key": "a"} ]}`
751+
const unmatchedReqBody = `{"entries": [ {"key": "val"}, {"key": "c"} ]}`
752+
753+
t.Run("Matches", func(t *testing.T) {
754+
given, when, then := NewProxyStage(t)
755+
756+
given.
757+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody)
758+
759+
when.
760+
a_request_is_sent_with("application/json", matchedReqBody)
761+
762+
then.
763+
pact_verification_is_successful().and().
764+
the_response_is_(http.StatusOK).and().
765+
the_response_body_is(respBody)
766+
})
767+
768+
t.Run("Does not match", func(t *testing.T) {
769+
given, when, then := NewProxyStage(t)
770+
771+
given.
772+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody)
773+
774+
when.
775+
a_request_is_sent_with("application/json", unmatchedReqBody)
776+
777+
then.
778+
pact_verification_is_not_successful().and().
779+
the_response_is_(http.StatusInternalServerError)
780+
})
781+
}
782+
783+
func TestEmptyArrayNestedWithinBody(t *testing.T) {
784+
pactReqBody := map[string]interface{}{
785+
"entries": []interface{}{},
786+
}
787+
const respContentType = "application/json"
788+
const respBody = `[{"status":"ok"}]`
789+
790+
const matchedReqBody = `{"entries": []}`
791+
const unmatchedReqBody = `{"entries": [ {"key": "val"} ]}`
792+
793+
t.Run("Matches", func(t *testing.T) {
794+
given, when, then := NewProxyStage(t)
795+
796+
given.
797+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody)
798+
799+
when.
800+
a_request_is_sent_with("application/json", matchedReqBody)
801+
802+
then.
803+
pact_verification_is_successful().and().
804+
the_response_is_(http.StatusOK).and().
805+
the_response_body_is(respBody)
806+
})
807+
808+
t.Run("Does not match", func(t *testing.T) {
809+
given, when, then := NewProxyStage(t)
810+
811+
given.
812+
a_pact_that_expects("application/json", pactReqBody, respContentType, respBody)
813+
814+
when.
815+
a_request_is_sent_with("application/json", unmatchedReqBody)
816+
817+
then.
818+
pact_verification_is_not_successful().and().
819+
the_response_is_(http.StatusInternalServerError)
820+
})
821+
}

0 commit comments

Comments
 (0)