Skip to content

Commit

Permalink
fix: BodyContainsBytes & BodyContainsString used with And/Or
Browse files Browse the repository at this point in the history
Both "rearm" the body before (re-)reading it, so they can be used
several times in a same matcher (combined with Or & And).

At the same time, simplify the body copy and make the "rearming" more
performant.

Closes #145.

Signed-off-by: Maxime Soulé <[email protected]>
  • Loading branch information
maxatome committed Aug 16, 2023
1 parent 497153d commit 8b32cd6
Show file tree
Hide file tree
Showing 4 changed files with 65 additions and 58 deletions.
4 changes: 0 additions & 4 deletions export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,6 @@ func (b *bodyCopyOnRead) Body() io.ReadCloser {
return b.body
}

func (b *bodyCopyOnRead) Buf() []byte {
return b.buf
}

func (b *bodyCopyOnRead) Rearm() {
b.rearm()
}
Expand Down
43 changes: 27 additions & 16 deletions match.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ type MatcherFunc func(req *http.Request) bool
func matcherFuncOr(mfs []MatcherFunc) MatcherFunc {
return func(req *http.Request) bool {
for _, mf := range mfs {
rearmBody(req)
if mf(req) {
return true
}
Expand All @@ -120,6 +121,7 @@ func matcherFuncAnd(mfs []MatcherFunc) MatcherFunc {
}
return func(req *http.Request) bool {
for _, mf := range mfs {
rearmBody(req)
if !mf(req) {
return false
}
Expand Down Expand Up @@ -224,6 +226,7 @@ func NewMatcher(name string, fn MatcherFunc) Matcher {
func BodyContainsBytes(subslice []byte) Matcher {
return NewMatcher("",
func(req *http.Request) bool {
rearmBody(req)
b, err := ioutil.ReadAll(req.Body)
return err == nil && bytes.Contains(b, subslice)
})
Expand All @@ -243,6 +246,7 @@ func BodyContainsBytes(subslice []byte) Matcher {
func BodyContainsString(substr string) Matcher {
return NewMatcher("",
func(req *http.Request) bool {
rearmBody(req)
b, err := ioutil.ReadAll(req.Body)
return err == nil && bytes.Contains(b, []byte(substr))
})
Expand Down Expand Up @@ -465,27 +469,40 @@ func (m matchRouteKey) String() string {
return m.RouteKey.String() + " <" + m.name + ">"
}

// bodyCopyOnRead copies body content to buf on first Read(), except
func rearmBody(req *http.Request) {
if req != nil {
if body, ok := req.Body.(interface{ rearm() }); ok {
body.rearm()
}
}
}

type buffer struct {
*bytes.Reader
}

func (b buffer) Close() error {
return nil
}

// bodyCopyOnRead mutates body into a buffer on first Read(), except
// if body is nil or http.NoBody. In this case, EOF is returned for
// each Read() and buf stays to nil.
// each Read() and body stays untouched.
type bodyCopyOnRead struct {
body io.ReadCloser
buf []byte
}

func (b *bodyCopyOnRead) rearm() {
if b.buf != nil {
b.body = ioutil.NopCloser(bytes.NewReader(b.buf))
if buf, ok := b.body.(buffer); ok {
buf.Seek(0, io.SeekStart) //nolint:errcheck
} // else b.body contains the original body, so don't touch
}

func (b *bodyCopyOnRead) copy() {
if b.buf == nil && b.body != nil && b.body != http.NoBody {
var body bytes.Buffer
io.Copy(&body, b.body) //nolint: errcheck
if _, ok := b.body.(buffer); !ok && b.body != nil && b.body != http.NoBody {
buf, _ := ioutil.ReadAll(b.body)
b.body.Close()
b.buf = body.Bytes()
b.body = ioutil.NopCloser(bytes.NewReader(b.buf))
b.body = buffer{bytes.NewReader(buf)}
}
}

Expand All @@ -500,9 +517,3 @@ func (b *bodyCopyOnRead) Read(p []byte) (n int, err error) {
func (b *bodyCopyOnRead) Close() error {
return nil
}

// Len returns the buffer total length, whatever the Read position in body is.
func (b *bodyCopyOnRead) Len() int {
b.copy()
return len(b.buf)
}
55 changes: 19 additions & 36 deletions match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,30 @@ func TestNewMatcher(t *testing.T) {
return req
}

reqCopyBody := func(t testing.TB, body string, header ...string) *http.Request {
req := req(t, body, header...)
req.Body = httpmock.NewBodyCopyOnRead(req.Body)
return req
}

t.Run("BodyContainsBytes", func(t *testing.T) {
m := httpmock.BodyContainsBytes([]byte("ip"))
td.Cmp(t, m.Name(), autogenName)
td.CmpTrue(t, m.Check(req(t, "pipo")))
td.CmpFalse(t, m.Check(req(t, "bingo")))

td.CmpTrue(t, m.Check(reqCopyBody(t, "pipo")))
td.CmpFalse(t, m.Check(reqCopyBody(t, "bingo")))
})

t.Run("BodyContainsString", func(t *testing.T) {
m := httpmock.BodyContainsString("ip")
td.Cmp(t, m.Name(), autogenName)
td.CmpTrue(t, m.Check(req(t, "pipo")))
td.CmpFalse(t, m.Check(req(t, "bingo")))

td.CmpTrue(t, m.Check(reqCopyBody(t, "pipo")))
td.CmpFalse(t, m.Check(reqCopyBody(t, "bingo")))
})

t.Run("HeaderExists", func(t *testing.T) {
Expand Down Expand Up @@ -394,15 +406,17 @@ func TestBodyCopyOnRead(t *testing.T) {
bc := httpmock.NewBodyCopyOnRead(body)

bc.Rearm()
td.CmpNil(t, bc.Buf())
td.Cmp(t, body, bc.Body(), "rearm didn't touch anything")

var buf [4]byte
n, err := bc.Read(buf[:])
td.CmpNoError(t, err)
td.Cmp(t, n, 4)
td.CmpString(t, buf[:], "BODY")

td.CmpString(t, bc.Buf(), "BODY", "Original body has been copied internally")
td.Cmp(t, body, td.Not(bc.Body()), "Original body has been copied internally")

td.CmpNoError(t, bc.Body().Close()) // for coverage... :)

n, err = bc.Read(buf[:])
td.Cmp(t, err, io.EOF)
Expand Down Expand Up @@ -435,55 +449,24 @@ func TestBodyCopyOnRead(t *testing.T) {
bc := httpmock.NewBodyCopyOnRead(tc.body)

bc.Rearm()
td.CmpNil(t, bc.Buf())
td.Cmp(t, tc.body, bc.Body(), "rearm didn't touch anything")

var buf [4]byte
n, err := bc.Read(buf[:])
td.Cmp(t, err, io.EOF)
td.Cmp(t, n, 0)
td.CmpNil(t, bc.Buf())
td.Cmp(t, bc.Body(), tc.body)
td.Cmp(t, bc.Body(), tc.body, "body is not altered")

bc.Rearm()

n, err = bc.Read(buf[:])
td.Cmp(t, err, io.EOF)
td.Cmp(t, n, 0)
td.CmpNil(t, bc.Buf())
td.Cmp(t, bc.Body(), tc.body)
td.Cmp(t, bc.Body(), tc.body, "body is not altered")

td.CmpNoError(t, bc.Close())
})
}

t.Run("len", func(t *testing.T) {
testCases := []struct {
name string
bc interface{ Len() int }
expected int
}{
{
name: "nil",
bc: httpmock.NewBodyCopyOnRead(nil),
expected: 0,
},
{
name: "no body",
bc: httpmock.NewBodyCopyOnRead(http.NoBody),
expected: 0,
},
{
name: "filled",
bc: httpmock.NewBodyCopyOnRead(ioutil.NopCloser(bytes.NewReader([]byte(`BODY`)))),
expected: 4,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
td.Cmp(t, tc.bc.Len(), tc.expected)
})
}
})
}

func TestExtractPackage(t *testing.T) {
Expand Down
21 changes: 19 additions & 2 deletions transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ func TestRegisterMatcherResponder(t *testing.T) {
}),
NewStringResponder(200, "body-FOO"))

RegisterMatcherResponder("POST", "/foo",
BodyContainsString("xxx").
Or(BodyContainsString("yyy")).
WithName("03-body-xxx|yyy"),
NewStringResponder(200, "body-xxx|yyy"))

RegisterResponder("POST", "/foo", NewStringResponder(200, "default"))

RegisterNoResponder(NewNotFoundResponder(nil))
Expand Down Expand Up @@ -156,6 +162,16 @@ func TestRegisterMatcherResponder(t *testing.T) {
body: "FOO",
expectedBody: "body-FOO",
},
{
name: "body xxx",
body: "...xxx...",
expectedBody: "body-xxx|yyy",
},
{
name: "body yyy",
body: "...yyy...",
expectedBody: "body-xxx|yyy",
},
{
name: "default",
body: "ANYTHING",
Expand Down Expand Up @@ -194,12 +210,13 @@ func TestRegisterMatcherResponder(t *testing.T) {
"text/plain",
strings.NewReader("ANYTHING"),
)
assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite 3 matchers: ["00-header-foo=bar" "01-body-BAR" "02-body-FOO"]`)
assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite 4 matchers: ["00-header-foo=bar" "01-body-BAR" "02-body-FOO" "03-body-xxx|yyy"]`)
})

// Remove 2 matcher responders
// Remove 3 matcher responders
RegisterMatcherResponder("POST", "/foo", NewMatcher("01-body-BAR", nil), nil)
RegisterMatcherResponder("POST", "/foo", NewMatcher("02-body-FOO", nil), nil)
RegisterMatcherResponder("POST", "/foo", NewMatcher("03-body-xxx|yyy", nil), nil)

assert.Run("not found despite 1", func(assert *td.T) {
_, err := http.Post(
Expand Down

0 comments on commit 8b32cd6

Please sign in to comment.