diff --git a/matchers.go b/matchers.go index 667160ade..cde14cfa9 100644 --- a/matchers.go +++ b/matchers.go @@ -427,6 +427,13 @@ func HaveHTTPStatus(expected interface{}) types.GomegaMatcher { return &matchers.HaveHTTPStatusMatcher{Expected: expected} } +// HaveHTTPBody matches if the body matches. +// Actual must be either a *http.Response or *httptest.ResponseRecorder. +// Expected must be either a string, []byte, or other matcher +func HaveHTTPBody(expected interface{}) types.GomegaMatcher { + return &matchers.HaveHTTPBodyMatcher{Expected: expected} +} + //And succeeds only if all of the given matchers succeed. //The matchers are tried in order, and will fail-fast if one doesn't succeed. // Expect("hi").To(And(HaveLen(2), Equal("hi")) diff --git a/matchers/have_http_body_matcher.go b/matchers/have_http_body_matcher.go new file mode 100644 index 000000000..66cbb254a --- /dev/null +++ b/matchers/have_http_body_matcher.go @@ -0,0 +1,101 @@ +package matchers + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" +) + +type HaveHTTPBodyMatcher struct { + Expected interface{} + cachedBody []byte +} + +func (matcher *HaveHTTPBodyMatcher) Match(actual interface{}) (bool, error) { + body, err := matcher.body(actual) + if err != nil { + return false, err + } + + switch e := matcher.Expected.(type) { + case string: + return (&EqualMatcher{Expected: e}).Match(string(body)) + case []byte: + return (&EqualMatcher{Expected: e}).Match(body) + case types.GomegaMatcher: + return e.Match(body) + default: + return false, fmt.Errorf("HaveHTTPBody matcher expects string, []byte, or GomegaMatcher. Got:\n%s", format.Object(matcher.Expected, 1)) + } +} + +func (matcher *HaveHTTPBodyMatcher) FailureMessage(actual interface{}) (message string) { + body, err := matcher.body(actual) + if err != nil { + return fmt.Sprintf("failed to read body: %s", err) + } + + switch e := matcher.Expected.(type) { + case string: + return (&EqualMatcher{Expected: e}).FailureMessage(string(body)) + case []byte: + return (&EqualMatcher{Expected: e}).FailureMessage(body) + case types.GomegaMatcher: + return e.FailureMessage(body) + default: + return fmt.Sprintf("HaveHTTPBody matcher expects string, []byte, or GomegaMatcher. Got:\n%s", format.Object(matcher.Expected, 1)) + } +} + +func (matcher *HaveHTTPBodyMatcher) NegatedFailureMessage(actual interface{}) (message string) { + body, err := matcher.body(actual) + if err != nil { + return fmt.Sprintf("failed to read body: %s", err) + } + + switch e := matcher.Expected.(type) { + case string: + return (&EqualMatcher{Expected: e}).NegatedFailureMessage(string(body)) + case []byte: + return (&EqualMatcher{Expected: e}).NegatedFailureMessage(body) + case types.GomegaMatcher: + return e.NegatedFailureMessage(body) + default: + return fmt.Sprintf("HaveHTTPBody matcher expects string, []byte, or GomegaMatcher. Got:\n%s", format.Object(matcher.Expected, 1)) + } +} + +// body returns the body. It is cached because once we read it in Match() +// the Reader is closed and it is not readable again in FailureMessage() +// or NegatedFailureMessage() +func (matcher *HaveHTTPBodyMatcher) body(actual interface{}) ([]byte, error) { + if matcher.cachedBody != nil { + return matcher.cachedBody, nil + } + + body := func(a *http.Response) ([]byte, error) { + if a.Body != nil { + defer a.Body.Close() + var err error + matcher.cachedBody, err = ioutil.ReadAll(a.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + } + return matcher.cachedBody, nil + } + + switch a := actual.(type) { + case *http.Response: + return body(a) + case *httptest.ResponseRecorder: + return body(a.Result()) + default: + return nil, fmt.Errorf("HaveHTTPBody matcher expects *http.Response or *httptest.ResponseRecorder. Got:\n%s", format.Object(actual, 1)) + } + +} diff --git a/matchers/have_http_body_matcher_test.go b/matchers/have_http_body_matcher_test.go new file mode 100644 index 000000000..c3dc80885 --- /dev/null +++ b/matchers/have_http_body_matcher_test.go @@ -0,0 +1,187 @@ +package matchers_test + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("HaveHTTPBody", func() { + When("ACTUAL is *http.Response", func() { + It("matches the body", func() { + const body = "this is the body" + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).To(HaveHTTPBody(body)) + }) + + It("mismatches the body", func() { + const body = "this is the body" + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).NotTo(HaveHTTPBody("something else")) + }) + }) + + When("ACTUAL is *httptest.ResponseRecorder", func() { + It("matches the body", func() { + const body = "this is the body" + resp := &httptest.ResponseRecorder{Body: bytes.NewBufferString(body)} + Expect(resp).To(HaveHTTPBody(body)) + }) + + It("mismatches the body", func() { + const body = "this is the body" + resp := &httptest.ResponseRecorder{Body: bytes.NewBufferString(body)} + Expect(resp).NotTo(HaveHTTPBody("something else")) + }) + }) + + When("ACTUAL is neither *http.Response nor *httptest.ResponseRecorder", func() { + It("errors", func() { + failures := InterceptGomegaFailures(func() { + Expect("foo").To(HaveHTTPBody("bar")) + }) + Expect(failures).To(ConsistOf("HaveHTTPBody matcher expects *http.Response or *httptest.ResponseRecorder. Got:\n : foo")) + }) + }) + + When("EXPECTED is []byte", func() { + It("matches the body", func() { + const body = "this is the body" + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).To(HaveHTTPBody([]byte(body))) + }) + + It("mismatches the body", func() { + const body = "this is the body" + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).NotTo(HaveHTTPBody([]byte("something else"))) + }) + }) + + When("EXPECTED is a submatcher", func() { + It("matches the body", func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(`{"some":"json"}`))} + Expect(resp).To(HaveHTTPBody(MatchJSON(`{ "some": "json" }`))) + }) + + It("mismatches the body", func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(`{"some":"json"}`))} + Expect(resp).NotTo(HaveHTTPBody(MatchJSON(`{ "something": "different" }`))) + }) + }) + + When("EXPECTED is something else", func() { + It("errors", func() { + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader("body"))} + Expect(resp).To(HaveHTTPBody(map[int]bool{})) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(Equal("HaveHTTPBody matcher expects string, []byte, or GomegaMatcher. Got:\n : {}")) + }) + }) + + Describe("FailureMessage", func() { + Context("EXPECTED is string", func() { + It("returns a match failure message", func() { + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader("this is the body"))} + Expect(resp).To(HaveHTTPBody("this is a different body")) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(Equal(`Expected + : this is the body +to equal + : this is a different body`), failures[0]) + }) + }) + + Context("EXPECTED is []byte", func() { + It("returns a match failure message", func() { + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader("this is the body"))} + Expect(resp).To(HaveHTTPBody([]byte("this is a different body"))) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchRegexp(`^Expected + <\[\]uint8 \| len:\d+, cap:\d+>: this is the body +to equal + <\[\]uint8 ]| len:\d+, cap:\d+>: this is a different body$`)) + }) + }) + + Context("EXPECTED is submatcher", func() { + It("returns a match failure message", func() { + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(`{"some":"json"}`))} + Expect(resp).To(HaveHTTPBody(MatchJSON(`{"other":"stuff"}`))) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(Equal(`Expected + : { + "some": "json" + } +to match JSON of + : { + "other": "stuff" + }`)) + }) + }) + }) + + Describe("NegatedFailureMessage", func() { + Context("EXPECTED is string", func() { + It("returns a negated failure message", func() { + const body = "this is the body" + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).NotTo(HaveHTTPBody(body)) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(Equal(`Expected + : this is the body +not to equal + : this is the body`)) + }) + }) + + Context("EXPECTED is []byte", func() { + It("returns a match failure message", func() { + const body = "this is the body" + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).NotTo(HaveHTTPBody([]byte(body))) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchRegexp(`^Expected + <\[\]uint8 \| len:\d+, cap:\d+>: this is the body +not to equal + <\[\]uint8 \| len:\d+, cap:\d+>: this is the body$`)) + }) + }) + + Context("EXPECTED is submatcher", func() { + It("returns a match failure message", func() { + const body = `{"some":"json"}` + failures := InterceptGomegaFailures(func() { + resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(body))} + Expect(resp).NotTo(HaveHTTPBody(MatchJSON(body))) + }) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(Equal(`Expected + : { + "some": "json" + } +not to match JSON of + : { + "some": "json" + }`)) + }) + }) + }) +})