diff --git a/README.md b/README.md index b5fc793..97ebe35 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,7 @@ func TestFetchArticles(t *testing.T) { return httpmock.NewStringResponse(500, ""), nil } return resp, nil - }, - ) + }) // return an article related to the request with the help of regexp submatch (\d+) httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/(\d+)\z`, @@ -77,8 +76,7 @@ func TestFetchArticles(t *testing.T) { "id": id, "name": "My Great Article", }) - }, - ) + }) // mock to add a new article httpmock.RegisterResponder("POST", "https://api.mybiz.com/articles", @@ -95,8 +93,13 @@ func TestFetchArticles(t *testing.T) { return httpmock.NewStringResponse(500, ""), nil } return resp, nil - }, - ) + }) + + // mock to add a specific article, send a Bad Request response + // when the request body contains `"type":"toy"` + httpmock.RegisterMatcherResponder("POST", "https://api.mybiz.com/articles", + httpmock.BodyContainsString(`"type":"toy"`), + httpmock.NewStringResponder(400, `{"reason":"Invalid article type"}`)) // do stuff that adds and checks articles } diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..e9c2aff --- /dev/null +++ b/export_test.go @@ -0,0 +1,87 @@ +package httpmock + +import ( + "io" + "net/http" + "reflect" + "sync/atomic" + + "github.com/jarcoal/httpmock/internal" +) + +var ( + GetPackage = getPackage + ExtractPackage = extractPackage + CalledFrom = calledFrom +) + +type ( + MatchResponder = matchResponder + MatchResponders = matchResponders +) + +func init() { + atomic.AddInt64(&matcherID, 0xabcdef) +} + +func GetIgnorePackages() map[string]bool { + return ignorePackages +} + +// bodyCopyOnRead + +func NewBodyCopyOnRead(body io.ReadCloser) *bodyCopyOnRead { //nolint: revive + return &bodyCopyOnRead{body: body} +} + +func (b *bodyCopyOnRead) Body() io.ReadCloser { + return b.body +} + +func (b *bodyCopyOnRead) Buf() []byte { + return b.buf +} + +func (b *bodyCopyOnRead) Rearm() { + b.rearm() +} + +// matchRouteKey + +func NewMatchRouteKey(rk internal.RouteKey, name string) matchRouteKey { //nolint: revive + return matchRouteKey{RouteKey: rk, name: name} +} + +// matchResponder + +func NewMatchResponder(matcher Matcher, resp Responder) matchResponder { //nolint: revive + return matchResponder{matcher: matcher, responder: resp} +} + +func (mr matchResponder) ResponderPointer() uintptr { + return reflect.ValueOf(mr.responder).Pointer() +} + +func (mr matchResponder) Matcher() Matcher { + return mr.matcher +} + +// matchResponders + +func (mrs matchResponders) Add(mr matchResponder) matchResponders { + return mrs.add(mr) +} + +func (mrs matchResponders) Remove(name string) matchResponders { + return mrs.remove(name) +} + +func (mrs matchResponders) FindMatchResponder(req *http.Request) *matchResponder { + return mrs.findMatchResponder(req) +} + +// Matcher + +func (m Matcher) FnPointer() uintptr { + return reflect.ValueOf(m.fn).Pointer() +} diff --git a/internal/error.go b/internal/error.go index 25764fd..3a38046 100644 --- a/internal/error.go +++ b/internal/error.go @@ -12,7 +12,7 @@ var NoResponderFound = errors.New("no responder found") // nolint: revive // ErrorNoResponderFoundMistake encapsulates a NoResponderFound // error probably due to a user error on the method or URL path. type ErrorNoResponderFoundMistake struct { - Kind string // "method" or "URL" + Kind string // "method", "URL" or "matcher" Orig string // original wrong method/URL, without any matching responder Suggested string // suggested method/URL with a matching responder } @@ -26,6 +26,12 @@ func (e *ErrorNoResponderFoundMistake) Unwrap() error { // Error implements error interface. func (e *ErrorNoResponderFoundMistake) Error() string { + if e.Kind == "matcher" { + return fmt.Sprintf("%s despite %s", + NoResponderFound, + e.Suggested, + ) + } return fmt.Sprintf("%[1]s for %[2]s %[3]q, but one matches %[2]s %[4]q", NoResponderFound, e.Kind, diff --git a/internal/error_test.go b/internal/error_test.go index f0e7729..31774b9 100644 --- a/internal/error_test.go +++ b/internal/error_test.go @@ -14,8 +14,14 @@ func TestErrorNoResponderFoundMistake(t *testing.T) { Orig: "pipo", Suggested: "BINGO", } - td.Cmp(t, e.Error(), `no responder found for method "pipo", but one matches method "BINGO"`) + td.Cmp(t, e.Unwrap(), internal.NoResponderFound) + e = &internal.ErrorNoResponderFoundMistake{ + Kind: "matcher", + Orig: "--not--used--", + Suggested: "BINGO", + } + td.Cmp(t, e.Error(), `no responder found despite BINGO`) td.Cmp(t, e.Unwrap(), internal.NoResponderFound) } diff --git a/match.go b/match.go new file mode 100644 index 0000000..c91d337 --- /dev/null +++ b/match.go @@ -0,0 +1,502 @@ +package httpmock + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" //nolint: staticcheck + "net/http" + "runtime" + "strings" + "sync/atomic" + + "github.com/jarcoal/httpmock/internal" +) + +var ignorePackages = map[string]bool{} + +func init() { + IgnoreMatcherHelper() +} + +// IgnoreMatcherHelper should be called by external helpers building +// [Matcher], typically in an init() function, to avoid they appear in +// the autogenerated [Matcher] names. +func IgnoreMatcherHelper(skip ...int) { + sk := 2 + if len(skip) > 0 { + sk += skip[0] + } + if pkg := getPackage(sk); pkg != "" { + ignorePackages[pkg] = true + } +} + +// Copied from github.com/maxatome/go-testdeep/internal/trace.getPackage. +func getPackage(skip int) string { + if pc, _, _, ok := runtime.Caller(skip); ok { + if fn := runtime.FuncForPC(pc); fn != nil { + return extractPackage(fn.Name()) + } + } + return "" +} + +// extractPackage extracts package part from a fully qualified function name: +// +// "foo/bar/test.fn" → "foo/bar/test" +// "foo/bar/test.X.fn" → "foo/bar/test" +// "foo/bar/test.(*X).fn" → "foo/bar/test" +// "foo/bar/test.(*X).fn.func1" → "foo/bar/test" +// "weird" → "" +// +// Derived from github.com/maxatome/go-testdeep/internal/trace.SplitPackageFunc. +func extractPackage(fn string) string { + sp := strings.LastIndexByte(fn, '/') + if sp < 0 { + sp = 0 // std package + } + + dp := strings.IndexByte(fn[sp:], '.') + if dp < 0 { + return "" + } + + return fn[:sp+dp] +} + +// calledFrom returns a string like "@PKG.FUNC() FILE:LINE". +func calledFrom(skip int) string { + pc := make([]uintptr, 128) + npc := runtime.Callers(skip+1, pc) + pc = pc[:npc] + + frames := runtime.CallersFrames(pc) + + var lastFrame runtime.Frame + + for { + frame, more := frames.Next() + + // If testing package is encountered, it is too late + if strings.HasPrefix(frame.Function, "testing.") { + break + } + lastFrame = frame + // Stop if httpmock is not the caller + if !ignorePackages[extractPackage(frame.Function)] || !more { + break + } + } + + if lastFrame.Line == 0 { + return "" + } + return fmt.Sprintf(" @%s() %s:%d", + lastFrame.Function, lastFrame.File, lastFrame.Line) +} + +// MatcherFunc type is the function to use to check a [Matcher] +// matches an incoming request. When httpmock calls a function of this +// type, it is guaranteed req.Body is never nil. If req.Body is nil in +// the original request, it is temporarily replaced by an instance +// returning always [io.EOF] for each Read() call, during the call. +type MatcherFunc func(req *http.Request) bool + +func matcherFuncOr(mfs []MatcherFunc) MatcherFunc { + return func(req *http.Request) bool { + for _, mf := range mfs { + if mf(req) { + return true + } + } + return false + } +} + +func matcherFuncAnd(mfs []MatcherFunc) MatcherFunc { + if len(mfs) == 0 { + return nil + } + return func(req *http.Request) bool { + for _, mf := range mfs { + if !mf(req) { + return false + } + } + return true + } +} + +// Check returns true if mf is nil, otherwise it returns mf(req). +func (mf MatcherFunc) Check(req *http.Request) bool { + return mf == nil || mf(req) +} + +// Or combines mf and all mfs in a new [MatcherFunc]. This new +// [MatcherFunc] succeeds if one of mf or mfs succeeds. Note that as a +// a nil [MatcherFunc] is considered succeeding, if mf or one of mfs +// items is nil, nil is returned. +func (mf MatcherFunc) Or(mfs ...MatcherFunc) MatcherFunc { + if len(mfs) == 0 || mf == nil { + return mf + } + cmfs := make([]MatcherFunc, len(mfs)+1) + cmfs[0] = mf + for i, cur := range mfs { + if cur == nil { + return nil + } + cmfs[i+1] = cur + } + return matcherFuncOr(cmfs) +} + +// And combines mf and all mfs in a new [MatcherFunc]. This new +// [MatcherFunc] succeeds if all of mf and mfs succeed. Note that a +// [MatcherFunc] also succeeds if it is nil, so if mf and all mfs +// items are nil, nil is returned. +func (mf MatcherFunc) And(mfs ...MatcherFunc) MatcherFunc { + if len(mfs) == 0 { + return mf + } + cmfs := make([]MatcherFunc, 0, len(mfs)+1) + if mf != nil { + cmfs = append(cmfs, mf) + } + for _, cur := range mfs { + if cur != nil { + cmfs = append(cmfs, cur) + } + } + return matcherFuncAnd(cmfs) +} + +// Matcher type defines a match case. The zero Matcher{} corresponds +// to the default case. Otherwise, use [NewMatcher] or any helper +// building a [Matcher] like [BodyContainsBytes], [BodyContainsBytes], +// [HeaderExists], [HeaderIs], [HeaderContains] or any of +// [github.com/maxatome/tdhttpmock] functions. +type Matcher struct { + name string + fn MatcherFunc // can be nil → means always true +} + +var matcherID int64 + +// NewMatcher returns a [Matcher]. If name is empty and fn is non-nil, +// a name is automatically generated. When fn is nil, it is a default +// [Matcher]: its name can be empty. +// +// Automatically generated names have the form: +// +// ~HEXANUMBER@PKG.FUNC() FILE:LINE +// +// Legend: +// - HEXANUMBER is a unique 10 digit hexadecimal number, always increasing; +// - PKG is the NewMatcher caller package (except if +// [IgnoreMatcherHelper] has been previously called, in this case it +// is the caller of the caller package and so on); +// - FUNC is the function name of the caller in the previous PKG package; +// - FILE and LINE are the location of the call in FUNC function. +func NewMatcher(name string, fn MatcherFunc) Matcher { + if name == "" && fn != nil { + // Auto-name the matcher + name = fmt.Sprintf("~%010x%s", atomic.AddInt64(&matcherID, 1), calledFrom(1)) + } + return Matcher{ + name: name, + fn: fn, + } +} + +// BodyContainsBytes returns a [Matcher] checking that request body +// contains subslice. +// +// The name of the returned [Matcher] is auto-generated (see [NewMatcher]). +// To name it explicitly, use [Matcher.WithName] as in: +// +// BodyContainsBytes([]byte("foo")).WithName("10-body-contains-foo") +// +// See also [github.com/maxatome/tdhttpmock.Body], +// [github.com/maxatome/tdhttpmock.JSONBody] and +// [github.com/maxatome/tdhttpmock.XMLBody] for powerful body testing. +func BodyContainsBytes(subslice []byte) Matcher { + return NewMatcher("", + func(req *http.Request) bool { + b, err := ioutil.ReadAll(req.Body) + return err == nil && bytes.Contains(b, subslice) + }) +} + +// BodyContainsString returns a [Matcher] checking that request body +// contains substr. +// +// The name of the returned [Matcher] is auto-generated (see [NewMatcher]). +// To name it explicitly, use [Matcher.WithName] as in: +// +// BodyContainsString("foo").WithName("10-body-contains-foo") +// +// See also [github.com/maxatome/tdhttpmock.Body], +// [github.com/maxatome/tdhttpmock.JSONBody] and +// [github.com/maxatome/tdhttpmock.XMLBody] for powerful body testing. +func BodyContainsString(substr string) Matcher { + return NewMatcher("", + func(req *http.Request) bool { + b, err := ioutil.ReadAll(req.Body) + return err == nil && bytes.Contains(b, []byte(substr)) + }) +} + +// HeaderExists returns a [Matcher] checking that request contains +// key header. +// +// The name of the returned [Matcher] is auto-generated (see [NewMatcher]). +// To name it explicitly, use [Matcher.WithName] as in: +// +// HeaderExists("X-Custom").WithName("10-custom-exists") +// +// See also [github.com/maxatome/tdhttpmock.Header] for powerful +// header testing. +func HeaderExists(key string) Matcher { + return NewMatcher("", + func(req *http.Request) bool { + _, ok := req.Header[key] + return ok + }) +} + +// HeaderIs returns a [Matcher] checking that request contains +// key header set to value. +// +// The name of the returned [Matcher] is auto-generated (see [NewMatcher]). +// To name it explicitly, use [Matcher.WithName] as in: +// +// HeaderIs("X-Custom", "VALUE").WithName("10-custom-is-value") +// +// See also [github.com/maxatome/tdhttpmock.Header] for powerful +// header testing. +func HeaderIs(key, value string) Matcher { + return NewMatcher("", + func(req *http.Request) bool { + return req.Header.Get(key) == value + }) +} + +// HeaderContains returns a [Matcher] checking that request contains key +// header itself containing substr. +// +// The name of the returned [Matcher] is auto-generated (see [NewMatcher]). +// To name it explicitly, use [Matcher.WithName] as in: +// +// HeaderContains("X-Custom", "VALUE").WithName("10-custom-contains-value") +// +// See also [github.com/maxatome/tdhttpmock.Header] for powerful +// header testing. +func HeaderContains(key, substr string) Matcher { + return NewMatcher("", + func(req *http.Request) bool { + return strings.Contains(req.Header.Get(key), substr) + }) +} + +// Name returns the m's name. +func (m Matcher) Name() string { + return m.name +} + +// WithName returns a new [Matcher] based on m with name name. +func (m Matcher) WithName(name string) Matcher { + return NewMatcher(name, m.fn) +} + +// Check returns true if req is matched by m. +func (m Matcher) Check(req *http.Request) bool { + return m.fn.Check(req) +} + +// Or combines m and all ms in a new [Matcher]. This new [Matcher] +// succeeds if one of m or ms succeeds. Note that as a [Matcher] +// succeeds if internal fn is nil, if m's internal fn or any of ms +// item's internal fn is nil, the returned [Matcher] always +// succeeds. The name of returned [Matcher] is m's one. +func (m Matcher) Or(ms ...Matcher) Matcher { + if len(ms) == 0 || m.fn == nil { + return m + } + mfs := make([]MatcherFunc, 1, len(ms)+1) + mfs[0] = m.fn + for _, cur := range ms { + if cur.fn == nil { + return Matcher{} + } + mfs = append(mfs, cur.fn) + } + m.fn = matcherFuncOr(mfs) + return m +} + +// And combines m and all ms in a new [Matcher]. This new [Matcher] +// succeeds if all of m and ms succeed. Note that a [Matcher] also +// succeeds if [Matcher] [MatcherFunc] is nil. The name of returned +// [Matcher] is m's one if the empty/default [Matcher] is returned. +func (m Matcher) And(ms ...Matcher) Matcher { + if len(ms) == 0 { + return m + } + mfs := make([]MatcherFunc, 0, len(ms)+1) + if m.fn != nil { + mfs = append(mfs, m.fn) + } + for _, cur := range ms { + if cur.fn != nil { + mfs = append(mfs, cur.fn) + } + } + m.fn = matcherFuncAnd(mfs) + if m.fn != nil { + return m + } + return Matcher{} +} + +type matchResponder struct { + matcher Matcher + responder Responder +} + +type matchResponders []matchResponder + +// add adds or replaces a matchResponder. +func (mrs matchResponders) add(mr matchResponder) matchResponders { + // default is always at end + if mr.matcher.fn == nil { + if len(mrs) > 0 && (mrs)[len(mrs)-1].matcher.fn == nil { + mrs[len(mrs)-1] = mr + return mrs + } + return append(mrs, mr) + } + + for i, cur := range mrs { + if cur.matcher.name == mr.matcher.name { + mrs[i] = mr + return mrs + } + } + + for i, cur := range mrs { + if cur.matcher.fn == nil || cur.matcher.name > mr.matcher.name { + mrs = append(mrs, matchResponder{}) + copy(mrs[i+1:], mrs[i:len(mrs)-1]) + mrs[i] = mr + return mrs + } + } + return append(mrs, mr) +} + +func (mrs matchResponders) checkEmptiness() matchResponders { + if len(mrs) == 0 { + return nil + } + return mrs +} + +func (mrs matchResponders) shrink() matchResponders { + mrs[len(mrs)-1] = matchResponder{} + mrs = mrs[:len(mrs)-1] + return mrs.checkEmptiness() +} + +func (mrs matchResponders) remove(name string) matchResponders { + // Special case, even if default has been renamed, we consider "" + // matching this default + if name == "" { + // default is always at end + if len(mrs) > 0 && mrs[len(mrs)-1].matcher.fn == nil { + return mrs.shrink() + } + return mrs.checkEmptiness() + } + + for i, cur := range mrs { + if cur.matcher.name == name { + copy(mrs[i:], mrs[i+1:]) + return mrs.shrink() + } + } + return mrs.checkEmptiness() +} + +func (mrs matchResponders) findMatchResponder(req *http.Request) *matchResponder { + if len(mrs) == 0 { + return nil + } + if mrs[0].matcher.fn == nil { // nil match is always the last + return &mrs[0] + } + + copyBody := &bodyCopyOnRead{body: req.Body} + req.Body = copyBody + defer func() { + copyBody.rearm() + req.Body = copyBody.body + }() + + for _, mr := range mrs { + copyBody.rearm() + if mr.matcher.Check(req) { + return &mr + } + } + return nil +} + +type matchRouteKey struct { + internal.RouteKey + name string +} + +func (m matchRouteKey) String() string { + if m.name == "" { + return m.RouteKey.String() + } + return m.RouteKey.String() + " <" + m.name + ">" +} + +// bodyCopyOnRead copies body content to buf on first Read(), except +// if body is nil. In this case, EOF is returned for each Read() and +// buf stays to nil. +type bodyCopyOnRead struct { + body io.ReadCloser + buf []byte +} + +func (b *bodyCopyOnRead) rearm() { + if b.buf != nil { + b.body = ioutil.NopCloser(bytes.NewReader(b.buf)) + } // else b.body contains the original body, so don't touch +} + +func (b *bodyCopyOnRead) copy() { + if b.buf == nil && b.body != nil { + var body bytes.Buffer + io.Copy(&body, b.body) //nolint: errcheck + b.body.Close() + b.buf = body.Bytes() + b.body = ioutil.NopCloser(bytes.NewReader(b.buf)) + } +} + +func (b *bodyCopyOnRead) Read(p []byte) (n int, err error) { + b.copy() + if b.body == nil { + return 0, io.EOF + } + return b.body.Read(p) +} + +func (b *bodyCopyOnRead) Close() error { + return nil +} diff --git a/match_test.go b/match_test.go new file mode 100644 index 0000000..57cabb7 --- /dev/null +++ b/match_test.go @@ -0,0 +1,475 @@ +package httpmock_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" //nolint: staticcheck + "net/http" + "reflect" + "strings" + "testing" + + "github.com/maxatome/go-testdeep/td" + + "github.com/jarcoal/httpmock" + "github.com/jarcoal/httpmock/internal" +) + +func TestMatcherFunc_AndOr(t *testing.T) { + ok := httpmock.MatcherFunc(func(*http.Request) bool { return true }) + bad := httpmock.MatcherFunc(func(*http.Request) bool { return false }) + + td.CmpTrue(t, ok(nil)) + td.CmpFalse(t, bad(nil)) + + t.Run("Or", func(t *testing.T) { + td.CmpTrue(t, ok.Or(bad).Or(bad).Or(bad)(nil)) + td.CmpTrue(t, bad.Or(bad).Or(bad).Or(ok)(nil)) + td.CmpFalse(t, bad.Or(bad).Or(bad).Or(bad)(nil)) + td.CmpNil(t, bad.Or(bad).Or(bad).Or(nil)) + td.CmpNil(t, (httpmock.MatcherFunc)(nil).Or(bad).Or(bad).Or(bad)) + td.CmpTrue(t, ok.Or()(nil)) + }) + + t.Run("And", func(t *testing.T) { + td.CmpTrue(t, ok.And(ok).And(ok).And(ok)(nil)) + td.CmpTrue(t, ok.And(ok).And(nil).And(ok)(nil)) + td.CmpFalse(t, ok.And(ok).And(bad).And(ok)(nil)) + td.CmpFalse(t, bad.And(ok).And(ok).And(nil)(nil)) + td.CmpTrue(t, ok.And()(nil)) + td.CmpTrue(t, ok.And(nil)(nil)) + td.CmpNil(t, (httpmock.MatcherFunc)(nil).And(nil).And(nil).And(nil)) + td.CmpTrue(t, (httpmock.MatcherFunc)(nil).And(ok)(nil)) + }) +} + +func TestMatcherFunc_Check(t *testing.T) { + ok := httpmock.MatcherFunc(func(*http.Request) bool { return true }) + bad := httpmock.MatcherFunc(func(*http.Request) bool { return false }) + + td.CmpTrue(t, ok.Check(nil)) + td.CmpTrue(t, (httpmock.MatcherFunc)(nil).Check(nil)) + td.CmpFalse(t, bad.Check(nil)) +} + +func TestNewMatcher(t *testing.T) { + autogenName := td.Re(`^~[0-9a-f]{10} @.*/httpmock_test\.TestNewMatcher.*/match_test.go:\d+\z`) + + t.Run("NewMatcher", func(t *testing.T) { + td.Cmp(t, + httpmock.NewMatcher("xxx", func(*http.Request) bool { return true }), + td.Struct(httpmock.Matcher{}, td.StructFields{ + "name": "xxx", + "fn": td.NotNil(), + })) + + td.Cmp(t, httpmock.NewMatcher("", nil), httpmock.Matcher{}) + + td.Cmp(t, httpmock.NewMatcher("", func(*http.Request) bool { return true }), + td.Struct(httpmock.Matcher{}, td.StructFields{ + "name": autogenName, + "fn": td.NotNil(), + })) + }) + + req := func(t testing.TB, body string, header ...string) *http.Request { + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + td.Require(t).CmpNoError(err) + req.Header.Set("Content-Type", "text/plain") + for i := 0; i < len(header)-1; i += 2 { + req.Header.Set(header[i], header[i+1]) + } + 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"))) + }) + + 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"))) + }) + + t.Run("HeaderExists", func(t *testing.T) { + m := httpmock.HeaderExists("X-Custom") + td.Cmp(t, m.Name(), autogenName) + td.CmpTrue(t, m.Check(req(t, "pipo", "X-Custom", "zzz"))) + td.CmpFalse(t, m.Check(req(t, "bingo"))) + }) + + t.Run("HeaderIs", func(t *testing.T) { + m := httpmock.HeaderIs("X-Custom", "zzz") + td.Cmp(t, m.Name(), autogenName) + td.CmpTrue(t, m.Check(req(t, "pipo", "X-Custom", "zzz"))) + td.CmpFalse(t, m.Check(req(t, "bingo", "X-Custom", "aaa"))) + td.CmpFalse(t, m.Check(req(t, "bingo"))) + }) + + t.Run("HeaderContains", func(t *testing.T) { + m := httpmock.HeaderContains("X-Custom", "zzz") + td.Cmp(t, m.Name(), autogenName) + td.CmpTrue(t, m.Check(req(t, "pipo", "X-Custom", "aaa zzz bbb"))) + td.CmpFalse(t, m.Check(req(t, "bingo"))) + }) +} + +func TestMatcher_NameWithName(t *testing.T) { + autogenName := td.Re(`^~[0-9a-f]{10} @.*/httpmock_test\.TestMatcher_NameWithName.*/match_test.go:\d+\z`) + + t.Run("default", func(t *testing.T) { + m := httpmock.NewMatcher("", nil) + td.Cmp(t, m.Name(), "", "no autogen for nil fn (= default)") + + td.Cmp(t, m.WithName("pipo").Name(), "pipo") + td.Cmp(t, m.Name(), "", "original Matcher stay untouched") + + td.Cmp(t, m.WithName("pipo").WithName("").Name(), "", "no autogen for nil fn") + }) + + t.Run("non-default", func(t *testing.T) { + m := httpmock.NewMatcher("xxx", func(*http.Request) bool { return true }) + td.Cmp(t, m.Name(), "xxx") + + td.Cmp(t, m.WithName("pipo").Name(), "pipo") + td.Cmp(t, m.Name(), "xxx", "original Matcher stay untouched") + + td.Cmp(t, m.WithName("pipo").WithName("").Name(), autogenName) + }) +} + +func TestMatcher_AndOr(t *testing.T) { + ok := httpmock.MatcherFunc(func(*http.Request) bool { return true }) + bad := httpmock.MatcherFunc(func(*http.Request) bool { return false }) + + t.Run("Or", func(t *testing.T) { + m := httpmock.NewMatcher("a", ok). + Or(httpmock.NewMatcher("b", bad)). + Or(httpmock.NewMatcher("c", ok)) + td.Cmp(t, m.Name(), "a") + td.CmpTrue(t, m.Check(nil)) + + m = httpmock.NewMatcher("a", ok). + Or(httpmock.NewMatcher("", nil)). + Or(httpmock.NewMatcher("c", ok)) + td.Cmp(t, m.Name(), "") + td.CmpZero(t, m.FnPointer()) + + m = httpmock.NewMatcher("a", ok).Or() + td.Cmp(t, m.Name(), "a") + td.CmpTrue(t, m.Check(nil)) + + m = httpmock.NewMatcher("a", bad). + Or(httpmock.NewMatcher("b", bad)). + Or(httpmock.NewMatcher("c", ok)) + td.Cmp(t, m.Name(), "a") + td.CmpTrue(t, m.Check(nil)) + + m = httpmock.NewMatcher("a", bad). + Or(httpmock.NewMatcher("b", bad)). + Or(httpmock.NewMatcher("c", bad)) + td.Cmp(t, m.Name(), "a") + td.CmpFalse(t, m.Check(nil)) + }) + + t.Run("And", func(t *testing.T) { + m := httpmock.NewMatcher("a", ok). + And(httpmock.NewMatcher("b", ok)). + And(httpmock.NewMatcher("c", ok)) + td.Cmp(t, m.Name(), "a") + td.CmpTrue(t, m.Check(nil)) + + m = httpmock.NewMatcher("a", ok). + And(httpmock.NewMatcher("b", bad)). + And(httpmock.NewMatcher("c", ok)) + td.Cmp(t, m.Name(), "a") + td.CmpFalse(t, m.Check(nil)) + + mInit := httpmock.NewMatcher("", nil) + m = mInit.And(httpmock.NewMatcher("", nil)). + And(httpmock.NewMatcher("", nil)) + td.Cmp(t, m.Name(), mInit.Name()) + td.CmpZero(t, m.FnPointer()) + + m = httpmock.NewMatcher("a", ok).And() + td.Cmp(t, m.Name(), "a") + td.CmpTrue(t, m.Check(nil)) + }) +} + +var matchers = []httpmock.MatcherFunc{ + func(*http.Request) bool { return false }, + func(*http.Request) bool { return true }, +} + +func findMatcher(fnPtr uintptr) int { + if fnPtr == 0 { + return -1 + } + for i, gm := range matchers { + if fnPtr == reflect.ValueOf(gm).Pointer() { + return i + } + } + return -2 +} + +func newMR(name string, num int) httpmock.MatchResponder { + if num < 0 { + // default matcher + return httpmock.NewMatchResponder(httpmock.NewMatcher(name, nil), nil) + } + return httpmock.NewMatchResponder(httpmock.NewMatcher(name, matchers[num]), nil) +} + +func checkMRs(t testing.TB, mrs httpmock.MatchResponders, names ...string) { + td.Cmp(t, mrs, td.Smuggle( + func(mrs httpmock.MatchResponders) []string { + var ns []string + for _, mr := range mrs { + ns = append(ns, fmt.Sprintf("%s:%d", + mr.Matcher().Name(), findMatcher(mr.Matcher().FnPointer()))) + } + return ns + }, + names)) +} + +func TestMatchResponders_add_remove(t *testing.T) { + var mrs httpmock.MatchResponders + mrs = mrs.Add(newMR("foo", 0)) + mrs = mrs.Add(newMR("bar", 0)) + checkMRs(t, mrs, "bar:0", "foo:0") + mrs = mrs.Add(newMR("bar", 1)) + mrs = mrs.Add(newMR("", -1)) + checkMRs(t, mrs, "bar:1", "foo:0", ":-1") + + mrs = mrs.Remove("foo") + checkMRs(t, mrs, "bar:1", ":-1") + mrs = mrs.Remove("foo") + checkMRs(t, mrs, "bar:1", ":-1") + + mrs = mrs.Remove("") + checkMRs(t, mrs, "bar:1") + mrs = mrs.Remove("") + checkMRs(t, mrs, "bar:1") + + mrs = mrs.Remove("bar") + td.CmpNil(t, mrs) + mrs = mrs.Remove("bar") + td.CmpNil(t, mrs) + + mrs = nil + mrs = mrs.Add(newMR("DEFAULT", -1)) + mrs = mrs.Add(newMR("foo", 0)) + checkMRs(t, mrs, "foo:0", "DEFAULT:-1") + mrs = mrs.Add(newMR("bar", 0)) + mrs = mrs.Add(newMR("bar", 1)) + checkMRs(t, mrs, "bar:1", "foo:0", "DEFAULT:-1") + + mrs = mrs.Remove("") // remove DEFAULT + checkMRs(t, mrs, "bar:1", "foo:0") + mrs = mrs.Remove("") + checkMRs(t, mrs, "bar:1", "foo:0") + + mrs = mrs.Remove("bar") + checkMRs(t, mrs, "foo:0") + + mrs = mrs.Remove("foo") + td.CmpNil(t, mrs) +} + +func TestMatchResponders_findMatchResponder(t *testing.T) { + newReq := func() *http.Request { + req, _ := http.NewRequest("GET", "/foo", ioutil.NopCloser(bytes.NewReader([]byte(`BODY`)))) + req.Header.Set("X-Foo", "bar") + return req + } + + assert := td.Assert(t). + WithCmpHooks( + func(a, b httpmock.MatchResponder) error { + if a.Matcher().Name() != b.Matcher().Name() { + return errors.New("name field mismatch") + } + if a.Matcher().FnPointer() != b.Matcher().FnPointer() { + return errors.New("fn field mismatch") + } + if a.ResponderPointer() != b.ResponderPointer() { + return errors.New("responder field mismatch") + } + return nil + }) + + var mrs httpmock.MatchResponders + + resp := httpmock.NewStringResponder(200, "OK") + + req := newReq() + assert.Nil(mrs.FindMatchResponder(req)) + + mrDefault := httpmock.NewMatchResponder(httpmock.Matcher{}, resp) + mrs = mrs.Add(mrDefault) + assert.Cmp(mrs.FindMatchResponder(req), &mrDefault) + + mrHeader1 := httpmock.NewMatchResponder( + httpmock.NewMatcher("header-foo-zip", func(req *http.Request) bool { + return req.Header.Get("X-Foo") == "zip" + }), + resp) + mrs = mrs.Add(mrHeader1) + assert.Cmp(mrs.FindMatchResponder(req), &mrDefault) + + mrHeader2 := httpmock.NewMatchResponder( + httpmock.NewMatcher("header-foo-bar", func(req *http.Request) bool { + return req.Header.Get("X-Foo") == "bar" + }), + resp) + mrs = mrs.Add(mrHeader2) + assert.Cmp(mrs.FindMatchResponder(req), &mrHeader2) + + mrs = mrs.Remove(mrHeader2.Matcher().Name()). + Remove(mrDefault.Matcher().Name()) + assert.Nil(mrs.FindMatchResponder(req)) + + mrBody1 := httpmock.NewMatchResponder( + httpmock.NewMatcher("body-FOO", func(req *http.Request) bool { + b, err := ioutil.ReadAll(req.Body) + return err == nil && bytes.Equal(b, []byte("FOO")) + }), + resp) + mrs = mrs.Add(mrBody1) + + req = newReq() + assert.Nil(mrs.FindMatchResponder(req)) + + mrBody2 := httpmock.NewMatchResponder( + httpmock.NewMatcher("body-BODY", func(req *http.Request) bool { + b, err := ioutil.ReadAll(req.Body) + return err == nil && bytes.Equal(b, []byte("BODY")) + }), + resp) + mrs = mrs.Add(mrBody2) + + req = newReq() + assert.Cmp(mrs.FindMatchResponder(req), &mrBody2) + + // The request body should still be readable + b, err := ioutil.ReadAll(req.Body) + assert.CmpNoError(err) + assert.String(b, "BODY") +} + +func TestMatchRouteKey(t *testing.T) { + td.Cmp(t, httpmock.NewMatchRouteKey( + internal.RouteKey{ + Method: "GET", + URL: "/foo", + }, + ""). + String(), + "GET /foo") + + td.Cmp(t, httpmock.NewMatchRouteKey( + internal.RouteKey{ + Method: "GET", + URL: "/foo", + }, + "check-header"). + String(), + "GET /foo ") +} + +func TestBodyCopyOnRead(t *testing.T) { + t.Run("non-nil body", func(t *testing.T) { + body := ioutil.NopCloser(bytes.NewReader([]byte(`BODY`))) + + bc := httpmock.NewBodyCopyOnRead(body) + + bc.Rearm() + td.CmpNil(t, bc.Buf()) + + 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") + + n, err = bc.Read(buf[:]) + td.Cmp(t, err, io.EOF) + td.Cmp(t, n, 0) + + bc.Rearm() + + n, err = bc.Read(buf[:]) + td.CmpNoError(t, err) + td.Cmp(t, n, 4) + td.CmpString(t, buf[:], "BODY") + + td.CmpNoError(t, bc.Close()) + }) + + t.Run("nil body", func(t *testing.T) { + bc := httpmock.NewBodyCopyOnRead(nil) + + bc.Rearm() + td.CmpNil(t, bc.Buf()) + + 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(), nil) + + 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(), nil) + + td.CmpNoError(t, bc.Close()) + }) +} + +func TestExtractPackage(t *testing.T) { + td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.fn"), "foo/bar/test") + td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.X.fn"), "foo/bar/test") + td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.(*X).fn"), "foo/bar/test") + td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.(*X).fn.func1"), "foo/bar/test") + td.Cmp(t, httpmock.ExtractPackage("weird"), "") +} + +func TestIgnorePackages(t *testing.T) { + ignorePackages := httpmock.GetIgnorePackages() + + td.Cmp(t, ignorePackages, td.Len(1)) + td.Cmp(t, ignorePackages, td.ContainsKey(td.HasSuffix("/httpmock"))) + + httpmock.IgnoreMatcherHelper() + td.Cmp(t, ignorePackages, td.Len(2), "current httpmock_test package added") + td.Cmp(t, ignorePackages, td.ContainsKey(td.HasSuffix("/httpmock_test"))) + + httpmock.IgnoreMatcherHelper(1) + td.Cmp(t, ignorePackages, td.Len(3), "caller of TestIgnorePackages() → testing") + td.Cmp(t, ignorePackages, td.ContainsKey("testing")) + + td.Cmp(t, httpmock.GetPackage(1000), "") +} + +func TestCalledFrom(t *testing.T) { + td.Cmp(t, httpmock.CalledFrom(0), td.Re(`^ @.*/httpmock_test\.TestCalledFrom\(\) .*/match_test.go:\d+\z`)) + + td.Cmp(t, httpmock.CalledFrom(1000), "") +} diff --git a/response.go b/response.go index caf1c11..b6274e7 100644 --- a/response.go +++ b/response.go @@ -357,7 +357,11 @@ func NewNotFoundResponder(fn func(...any)) Responder { var extra string suggested, _ := req.Context().Value(suggestedKey).(*suggestedInfo) if suggested != nil { - extra = fmt.Sprintf(`, but one matches %s %q`, suggested.kind, suggested.suggested) + if suggested.kind == "matcher" { + extra = fmt.Sprintf(` despite %s`, suggested.suggested) + } else { + extra = fmt.Sprintf(`, but one matches %s %q`, suggested.kind, suggested.suggested) + } } return nil, internal.StackTracer{ CustomFn: fn, diff --git a/transport.go b/transport.go index 844b99f..64b8535 100644 --- a/transport.go +++ b/transport.go @@ -49,16 +49,16 @@ func ConnectionFailure(*http.Request) (*http.Response, error) { // NewMockTransport creates a new [*MockTransport] with no responders. func NewMockTransport() *MockTransport { return &MockTransport{ - responders: make(map[internal.RouteKey]Responder), - callCountInfo: make(map[internal.RouteKey]int), + responders: make(map[internal.RouteKey]matchResponders), + callCountInfo: make(map[matchRouteKey]int), } } type regexpResponder struct { - origRx string - method string - rx *regexp.Regexp - responder Responder + origRx string + method string + rx *regexp.Regexp + responders matchResponders } // MockTransport implements [http.RoundTripper] interface, which @@ -72,48 +72,58 @@ type MockTransport struct { // as it is probably a mistake. DontCheckMethod bool mu sync.RWMutex - responders map[internal.RouteKey]Responder + responders map[internal.RouteKey]matchResponders regexpResponders []regexpResponder noResponder Responder - callCountInfo map[internal.RouteKey]int + callCountInfo map[matchRouteKey]int totalCallCount int } -func (m *MockTransport) findResponder(method string, url *url.URL) ( - responder Responder, - key, respKey internal.RouteKey, - submatches []string, +var findForKey = []func(*MockTransport, internal.RouteKey) respondersFound{ + (*MockTransport).respondersForKey, // Exact match + (*MockTransport).regexpRespondersForKey, // Regexp match +} + +type respondersFound struct { + responders matchResponders + key, respKey internal.RouteKey + submatches []string +} + +func (m *MockTransport) findResponders(method string, url *url.URL, fromIdx int) ( + found respondersFound, + findForKeyIndex int, ) { urlStr := url.String() - key = internal.RouteKey{ + key := internal.RouteKey{ Method: method, } - for _, getResponder := range []func(internal.RouteKey) (Responder, internal.RouteKey, []string){ - m.responderForKey, // Exact match - m.regexpResponderForKey, // Regexp match - } { + + for findForKeyIndex = fromIdx; findForKeyIndex <= len(findForKey)-1; findForKeyIndex++ { + getResponders := findForKey[findForKeyIndex] + // try and get a responder that matches the method and URL with // query params untouched: http://z.tld/path?q... key.URL = urlStr - responder, respKey, submatches = getResponder(key) - if responder != nil { + found = getResponders(m, key) + if found.responders != nil { break } - // if we weren't able to find a responder, try with the URL *and* + // if we weren't able to find some responders, try with the URL *and* // sorted query params query := sortedQuery(url.Query()) if query != "" { // Replace unsorted query params by sorted ones: // http://z.tld/path?sorted_q... key.URL = strings.Replace(urlStr, url.RawQuery, query, 1) - responder, respKey, submatches = getResponder(key) - if responder != nil { + found = getResponders(m, key) + if found.responders != nil { break } } - // if we weren't able to find a responder, try without any query params + // if we weren't able to find some responders, try without any query params strippedURL := *url strippedURL.RawQuery = "" strippedURL.Fragment = "" @@ -128,13 +138,13 @@ func (m *MockTransport) findResponder(method string, url *url.URL) ( // querystring and try again: http://z.tld/path if hasQueryString { key.URL = surl - responder, respKey, submatches = getResponder(key) - if responder != nil { + found = getResponders(m, key) + if found.responders != nil { break } } - // if we weren't able to find a responder for the full URL, try with + // if we weren't able to find some responders for the full URL, try with // the path part only pathAlone := url.RawPath if pathAlone == "" { @@ -144,8 +154,8 @@ func (m *MockTransport) findResponder(method string, url *url.URL) ( // First with unsorted querystring: /path?q... if hasQueryString { key.URL = pathAlone + strings.TrimPrefix(urlStr, surl) // concat after-path part - responder, respKey, submatches = getResponder(key) - if responder != nil { + found = getResponders(m, key) + if found.responders != nil { break } @@ -154,22 +164,81 @@ func (m *MockTransport) findResponder(method string, url *url.URL) ( if url.Fragment != "" { key.URL += "#" + url.Fragment } - responder, respKey, submatches = getResponder(key) - if responder != nil { + found = getResponders(m, key) + if found.responders != nil { break } } // Then using path alone: /path key.URL = pathAlone - responder, respKey, submatches = getResponder(key) - if responder != nil { + found = getResponders(m, key) + if found.responders != nil { break } } + found.key = key return } +// suggestResponder is typically called after a findResponders failure +// to suggest a user mistake. +func (m *MockTransport) suggestResponder(method string, url *url.URL) *internal.ErrorNoResponderFoundMistake { + // Responder not found, try to detect some common user mistakes on + // method then on path + var found respondersFound + + // On method first + if methodProbablyWrong(method) { + // Get → GET + found, _ = m.findResponders(strings.ToUpper(method), url, 0) + } + if found.responders == nil { + // Search for any other method + found, _ = m.findResponders("", url, 0) + } + if found.responders != nil { + return &internal.ErrorNoResponderFoundMistake{ + Kind: "method", + Orig: method, + Suggested: found.respKey.Method, + } + } + + // Then on path + if strings.HasSuffix(url.Path, "/") { + // Try without final "/" + u := *url + u.Path = strings.TrimSuffix(u.Path, "/") + found, _ = m.findResponders("", &u, 0) + } + if found.responders == nil && strings.Contains(url.Path, "//") { + // Try without double "/" + u := *url + squash := false + u.Path = strings.Map(func(r rune) rune { + if r == '/' { + if squash { + return -1 + } + squash = true + } else { + squash = false + } + return r + }, u.Path) + found, _ = m.findResponders("", &u, 0) + } + if found.responders != nil { + return &internal.ErrorNoResponderFoundMistake{ + Kind: "URL", + Orig: url.String(), + Suggested: found.respKey.URL, + } + } + return nil +} + // RoundTrip receives HTTP requests and routes them to the appropriate // responder. It is required to implement the [http.RoundTripper] // interface. You will not interact with this directly, instead the @@ -181,89 +250,92 @@ func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { method = http.MethodGet } - var suggested *internal.ErrorNoResponderFoundMistake - - responder, key, respKey, submatches := m.findResponder(method, req.URL) - if responder == nil { - // Responder not found, try to detect some common user mistakes on - // method then on path - var altResp Responder - var altKey internal.RouteKey - - // On method first - if methodProbablyWrong(method) { - // Get → GET - altResp, _, altKey, _ = m.findResponder(strings.ToUpper(method), req.URL) - } - if altResp == nil { - // Search for any other method - altResp, _, altKey, _ = m.findResponder("", req.URL) - } - if altResp != nil { - suggested = &internal.ErrorNoResponderFoundMistake{ - Kind: "method", - Orig: method, - Suggested: altKey.Method, - } - } else { - // Then on path - if altResp == nil && strings.HasSuffix(req.URL.Path, "/") { - // Try without final "/" - u := *req.URL - u.Path = strings.TrimSuffix(u.Path, "/") - altResp, _, altKey, _ = m.findResponder("", &u) + var ( + suggested *internal.ErrorNoResponderFoundMistake + responder Responder + fail bool + found respondersFound + findIdx int + ) + for fromFindIdx := 0; ; { + found, findIdx = m.findResponders(method, req.URL, fromFindIdx) + if found.responders == nil { + if suggested == nil { // a suggestion is already available, no need of a new one + suggested = m.suggestResponder(method, req.URL) + fail = true } - if altResp == nil && strings.Contains(req.URL.Path, "//") { - // Try without double "/" - u := *req.URL - squash := false - u.Path = strings.Map(func(r rune) rune { - if r == '/' { - if squash { - return -1 - } - squash = true - } else { - squash = false + break + } + + // we found some responders, check for one matcher + mr := func() *matchResponder { + m.mu.RLock() + defer m.mu.RUnlock() + return found.responders.findMatchResponder(req) + }() + if mr == nil { + if suggested == nil { + // a suggestion is not already available, do it now + fail = true + + if len(found.responders) == 1 { + suggested = &internal.ErrorNoResponderFoundMistake{ + Kind: "matcher", + Suggested: fmt.Sprintf("matcher %q", found.responders[0].matcher.name), + } + } else { + names := make([]string, len(found.responders)) + for i, mr := range found.responders { + names[i] = mr.matcher.name + } + suggested = &internal.ErrorNoResponderFoundMistake{ + Kind: "matcher", + Suggested: fmt.Sprintf("%d matchers: %q", len(found.responders), names), } - return r - }, u.Path) - altResp, _, altKey, _ = m.findResponder("", &u) - } - if altResp != nil { - suggested = &internal.ErrorNoResponderFoundMistake{ - Kind: "URL", - Orig: req.URL.String(), - Suggested: altKey.URL, } } + + // No Matcher found for exact match, retry for regexp match + if findIdx < len(findForKey)-1 { + fromFindIdx = findIdx + 1 + continue + } + break } - } - m.mu.Lock() - // if we found a responder, call it - if responder != nil { - m.callCountInfo[key]++ - if key != respKey { - m.callCountInfo[respKey]++ + // OK responder found + fail = false + responder = mr.responder + + m.mu.Lock() + m.callCountInfo[matchRouteKey{RouteKey: found.key, name: mr.matcher.name}]++ + if found.key != found.respKey { + m.callCountInfo[matchRouteKey{RouteKey: found.respKey, name: mr.matcher.name}]++ } m.totalCallCount++ - } else if m.noResponder != nil { - // we didn't find a responder, so fire the 'no responder' responder - m.callCountInfo[internal.NoResponder]++ - m.totalCallCount++ + m.mu.Unlock() + break + } - // give a hint to NewNotFoundResponder() if it is a possible - // method or URL error - if suggested != nil { - req = req.WithContext(context.WithValue(req.Context(), suggestedKey, &suggestedInfo{ - kind: suggested.Kind, - suggested: suggested.Suggested, - })) + if fail { + m.mu.Lock() + if m.noResponder != nil { + // we didn't find a responder, so fire the 'no responder' responder + m.callCountInfo[matchRouteKey{RouteKey: internal.NoResponder}]++ + m.totalCallCount++ + + // give a hint to NewNotFoundResponder() if it is a possible + // method or URL error, or missing matcher + if suggested != nil { + req = req.WithContext(context.WithValue(req.Context(), suggestedKey, &suggestedInfo{ + kind: suggested.Kind, + suggested: suggested.Suggested, + })) + } + responder = m.noResponder } - responder = m.noResponder + m.mu.Unlock() } - m.mu.Unlock() if responder == nil { if suggested != nil { @@ -271,7 +343,18 @@ func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { } return ConnectionFailure(req) } - return runCancelable(responder, internal.SetSubmatches(req, submatches)) + return runCancelable(responder, internal.SetSubmatches(req, found.submatches)) +} + +func (m *MockTransport) numResponders() int { + num := 0 + for _, mrs := range m.responders { + num += len(mrs) + } + for _, rr := range m.regexpResponders { + num += len(rr.responders) + } + return num } // NumResponders returns the number of responders currently in use. @@ -280,7 +363,7 @@ func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { func (m *MockTransport) NumResponders() int { m.mu.RLock() defer m.mu.RUnlock() - return len(m.responders) + len(m.regexpResponders) + return m.numResponders() } // Responders returns the list of currently registered responders. @@ -306,12 +389,25 @@ func (m *MockTransport) Responders() []string { return rks[i].URL < rks[j].URL }) - rs := make([]string, 0, len(m.responders)+len(m.regexpResponders)) + rs := make([]string, 0, m.numResponders()) for _, rk := range rks { - rs = append(rs, rk.String()) + for _, mr := range m.responders[rk] { + rs = append(rs, matchRouteKey{ + RouteKey: rk, + name: mr.matcher.name, + }.String()) + } } for _, rr := range m.regexpResponders { - rs = append(rs, rr.method+" "+rr.origRx) + for _, mr := range rr.responders { + rs = append(rs, matchRouteKey{ + RouteKey: internal.RouteKey{ + Method: rr.method, + URL: rr.origRx, + }, + name: mr.matcher.name, + }.String()) + } } return rs } @@ -377,25 +473,31 @@ func runCancelable(responder Responder, req *http.Request) (*http.Response, erro return r.response, internal.CheckStackTracer(req, r.err) } -// responderForKey returns a responder for a given key. -func (m *MockTransport) responderForKey(key internal.RouteKey) (Responder, internal.RouteKey, []string) { +// respondersForKey returns a responder for a given key. +func (m *MockTransport) respondersForKey(key internal.RouteKey) respondersFound { m.mu.RLock() defer m.mu.RUnlock() if key.Method != "" { - return m.responders[key], key, nil + return respondersFound{ + responders: m.responders[key], + respKey: key, + } } for k, resp := range m.responders { if key.URL == k.URL { - return resp, k, nil + return respondersFound{ + responders: resp, + respKey: k, + } } } - return nil, key, nil + return respondersFound{} } -// responderForKeyUsingRegexp returns the first responder matching a +// respondersForKeyUsingRegexp returns the first responder matching a // given key using regexps. -func (m *MockTransport) regexpResponderForKey(key internal.RouteKey) (Responder, internal.RouteKey, []string) { +func (m *MockTransport) regexpRespondersForKey(key internal.RouteKey) respondersFound { m.mu.RLock() defer m.mu.RUnlock() for _, regInfo := range m.regexpResponders { @@ -406,21 +508,25 @@ func (m *MockTransport) regexpResponderForKey(key internal.RouteKey) (Responder, } else { sm = sm[1:] } - return regInfo.responder, internal.RouteKey{ - Method: regInfo.method, - URL: regInfo.origRx, - }, sm + return respondersFound{ + responders: regInfo.responders, + respKey: internal.RouteKey{ + Method: regInfo.method, + URL: regInfo.origRx, + }, + submatches: sm, + } } } } - return nil, key, nil + return respondersFound{} } func isRegexpURL(url string) bool { return strings.HasPrefix(url, regexpPrefix) } -func (m *MockTransport) checkMethod(method string) { +func (m *MockTransport) checkMethod(method string, matcher Matcher) { if !m.DontCheckMethod && methodProbablyWrong(method) { panic(fmt.Sprintf("You probably want to use method %q instead of %q? If not and so want to disable this check, set MockTransport.DontCheckMethod field to true", strings.ToUpper(method), @@ -429,8 +535,8 @@ func (m *MockTransport) checkMethod(method string) { } } -// RegisterResponder adds a new responder, associated with a given -// HTTP method and URL (or path). +// RegisterMatcherResponder adds a new responder, associated with a given +// HTTP method, URL (or path) and [Matcher]. // // When a request comes in that matches, the responder is called and // the response returned to the client. @@ -458,45 +564,51 @@ func (m *MockTransport) checkMethod(method string) { // Registering a nil [Responder] removes the existing one and the // corresponding statistics as returned by // [MockTransport.GetCallCountInfo]. It does nothing if it does not -// already exist. -// -// See [MockTransport.RegisterRegexpResponder] to directly pass a -// [*regexp.Regexp]. -// -// Example: +// already exist. The original matcher can be passed but also a new +// [Matcher] with the same name and a nil match function as in: // -// func TestFetchArticles(t *testing.T) { -// httpmock.Activate() -// defer httpmock.DeactivateAndReset() +// NewMatcher("original matcher name", nil) // -// httpmock.RegisterResponder("GET", "http://example.com/", -// httpmock.NewStringResponder(200, "hello world")) -// -// httpmock.RegisterResponder("GET", "/path/only", -// httpmock.NewStringResponder("any host hello world", 200)) +// See [MockTransport.RegisterRegexpMatcherResponder] to directly pass a +// [*regexp.Regexp]. // -// httpmock.RegisterResponder("GET", `=~^/item/id/\d+\z`, -// httpmock.NewStringResponder("any item get", 200)) -// -// // requests to http://example.com/ now return "hello world" and -// // requests to any host with path /path/only return "any host hello world" -// // requests to any host with path matching ^/item/id/\d+\z regular expression return "any item get" -// } +// If several responders are registered for a same method and url +// couple, but with different matchers, they are ordered depending on +// the following rules: +// - the zero matcher, Matcher{} (or responder set using +// [MockTransport.RegisterResponder]) is always called lastly; +// - other matchers are ordered by their name. If a matcher does not +// have an explicit name ([NewMatcher] called with an empty name and +// [Matcher.WithName] method not called), a name is automatically +// computed so all anonymous matchers are sorted by their creation +// order. An automatically computed name has always the form +// "~HEXANUMBER@PKG.FUNC() FILE:LINE". See [NewMatcher] for details. // // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible // mistake. This panic can be disabled by setting m.DontCheckMethod to // true prior to this call. -func (m *MockTransport) RegisterResponder(method, url string, responder Responder) { - m.checkMethod(method) +// +// See also [MockTransport.RegisterResponder] if a matcher is not needed. +// +// Note that [github.com/maxatome/tdhttpmock] provides powerful helpers +// to create matchers with the help of [github.com/maxatome/go-testdeep]. +func (m *MockTransport) RegisterMatcherResponder(method, url string, matcher Matcher, responder Responder) { + m.checkMethod(method, matcher) + + mr := matchResponder{ + matcher: matcher, + responder: responder, + } if isRegexpURL(url) { - m.registerRegexpResponder(regexpResponder{ - origRx: url, - method: method, - rx: regexp.MustCompile(url[2:]), - responder: responder, - }) + rr := regexpResponder{ + origRx: url, + method: method, + rx: regexp.MustCompile(url[2:]), + responders: matchResponders{mr}, + } + m.registerRegexpResponder(rr) return } @@ -507,50 +619,172 @@ func (m *MockTransport) RegisterResponder(method, url string, responder Responde m.mu.Lock() if responder == nil { - delete(m.responders, key) - delete(m.callCountInfo, key) + if mrs := m.responders[key].remove(matcher.name); mrs == nil { + delete(m.responders, key) + } else { + m.responders[key] = mrs + } + delete(m.callCountInfo, matchRouteKey{RouteKey: key, name: matcher.name}) } else { - m.responders[key] = responder - m.callCountInfo[key] = 0 + m.responders[key] = m.responders[key].add(mr) + m.callCountInfo[matchRouteKey{RouteKey: key, name: matcher.name}] = 0 } m.mu.Unlock() } +// RegisterResponder adds a new responder, associated with a given +// HTTP method and URL (or path). +// +// When a request comes in that matches, the responder is called and +// the response returned to the client. +// +// If url contains query parameters, their order matters as well as +// their content. All following URLs are here considered as different: +// +// http://z.tld?a=1&b=1 +// http://z.tld?b=1&a=1 +// http://z.tld?a&b +// http://z.tld?a=&b= +// +// If url begins with "=~", the following chars are considered as a +// regular expression. If this regexp can not be compiled, it panics. +// Note that the "=~" prefix remains in statistics returned by +// [MockTransport.GetCallCountInfo]. As 2 regexps can match the same +// URL, the regexp responders are tested in the order they are +// registered. Registering an already existing regexp responder (same +// method & same regexp string) replaces its responder, but does not +// change its position. +// +// Registering an already existing responder resets the corresponding +// statistics as returned by [MockTransport.GetCallCountInfo]. +// +// Registering a nil [Responder] removes the existing one and the +// corresponding statistics as returned by +// [MockTransport.GetCallCountInfo]. It does nothing if it does not +// already exist. +// +// See [MockTransport.RegisterRegexpResponder] to directly pass a +// [*regexp.Regexp]. +// +// If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, +// OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible +// mistake. This panic can be disabled by setting m.DontCheckMethod to +// true prior to this call. +// +// See [MockTransport.RegisterMatcherResponder] to also match on +// request header and/or body. +func (m *MockTransport) RegisterResponder(method, url string, responder Responder) { + m.RegisterMatcherResponder(method, url, Matcher{}, responder) +} + +// It is the caller responsibility that len(rxResp.reponders) == 1. func (m *MockTransport) registerRegexpResponder(rxResp regexpResponder) { m.mu.Lock() defer m.mu.Unlock() + mr := rxResp.responders[0] + found: for { for i, rr := range m.regexpResponders { if rr.method == rxResp.method && rr.origRx == rxResp.origRx { - if rxResp.responder == nil { - copy(m.regexpResponders[:i], m.regexpResponders[i+1:]) - m.regexpResponders[len(m.regexpResponders)-1] = regexpResponder{} - m.regexpResponders = m.regexpResponders[:len(m.regexpResponders)-1] + if mr.responder == nil { + rr.responders = rr.responders.remove(mr.matcher.name) + if rr.responders == nil { + copy(m.regexpResponders[:i], m.regexpResponders[i+1:]) + m.regexpResponders[len(m.regexpResponders)-1] = regexpResponder{} + m.regexpResponders = m.regexpResponders[:len(m.regexpResponders)-1] + } else { + m.regexpResponders[i] = rr + } } else { - m.regexpResponders[i] = rxResp + rr.responders = rr.responders.add(mr) + m.regexpResponders[i] = rr } break found } } - if rxResp.responder != nil { + if mr.responder != nil { m.regexpResponders = append(m.regexpResponders, rxResp) } break // nolint: staticcheck } - key := internal.RouteKey{ - Method: rxResp.method, - URL: rxResp.origRx, + mrk := matchRouteKey{ + RouteKey: internal.RouteKey{ + Method: rxResp.method, + URL: rxResp.origRx, + }, + name: mr.matcher.name, } - if rxResp.responder == nil { - delete(m.callCountInfo, key) + if mr.responder == nil { + delete(m.callCountInfo, mrk) } else { - m.callCountInfo[key] = 0 + m.callCountInfo[mrk] = 0 } } +// RegisterRegexpMatcherResponder adds a new responder, associated +// with a given HTTP method, URL (or path) regular expression and +// [Matcher]. +// +// When a request comes in that matches, the responder is called and +// the response returned to the client. +// +// As 2 regexps can match the same URL, the regexp responders are +// tested in the order they are registered. Registering an already +// existing regexp responder (same method, same regexp string and same +// [Matcher] name) replaces its responder, but does not change its +// position, and resets the corresponding statistics as returned by +// [MockTransport.GetCallCountInfo]. +// +// If several responders are registered for a same method and urlRegexp +// couple, but with different matchers, they are ordered depending on +// the following rules: +// - the zero matcher, Matcher{} (or responder set using +// [MockTransport.RegisterRegexpResponder]) is always called lastly; +// - other matchers are ordered by their name. If a matcher does not +// have an explicit name ([NewMatcher] called with an empty name and +// [Matcher.WithName] method not called), a name is automatically +// computed so all anonymous matchers are sorted by their creation +// order. An automatically computed name has always the form +// "~HEXANUMBER@PKG.FUNC() FILE:LINE". See [NewMatcher] for details. +// +// Registering a nil [Responder] removes the existing one and the +// corresponding statistics as returned by +// [MockTransport.GetCallCountInfo]. It does nothing if it does not +// already exist. The original matcher can be passed but also a new +// [Matcher] with the same name and a nil match function as in: +// +// NewMatcher("original matcher name", nil) +// +// A "=~" prefix is added to the stringified regexp in the statistics +// returned by [MockTransport.GetCallCountInfo]. +// +// See [MockTransport.RegisterMatcherResponder] function and the "=~" +// prefix in its url parameter to avoid compiling the regexp by +// yourself. +// +// If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, +// OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible +// mistake. This panic can be disabled by setting m.DontCheckMethod to +// true prior to this call. +// +// See [MockTransport.RegisterRegexpResponder] if a matcher is not needed. +// +// Note that [github.com/maxatome/tdhttpmock] provides powerful helpers +// to create matchers with the help of [github.com/maxatome/go-testdeep]. +func (m *MockTransport) RegisterRegexpMatcherResponder(method string, urlRegexp *regexp.Regexp, matcher Matcher, responder Responder) { + m.checkMethod(method, matcher) + + m.registerRegexpResponder(regexpResponder{ + origRx: regexpPrefix + urlRegexp.String(), + method: method, + rx: urlRegexp, + responders: matchResponders{{matcher: matcher, responder: responder}}, + }) +} + // RegisterRegexpResponder adds a new responder, associated with a given // HTTP method and URL (or path) regular expression. // @@ -579,20 +813,16 @@ found: // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible // mistake. This panic can be disabled by setting m.DontCheckMethod to // true prior to this call. +// +// See [MockTransport.RegisterRegexpMatcherResponder] to also match on +// request header and/or body. func (m *MockTransport) RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder Responder) { - m.checkMethod(method) - - m.registerRegexpResponder(regexpResponder{ - origRx: regexpPrefix + urlRegexp.String(), - method: method, - rx: urlRegexp, - responder: responder, - }) + m.RegisterRegexpMatcherResponder(method, urlRegexp, Matcher{}, responder) } -// RegisterResponderWithQuery is same as -// [MockTransport.RegisterResponder], but it doesn't depend on query -// items order. +// RegisterMatcherResponderWithQuery is same as +// [MockTransport.RegisterMatcherResponder], but it doesn't depend on +// query items order. // // If query is non-nil, its type can be: // @@ -603,8 +833,8 @@ func (m *MockTransport) RegisterRegexpResponder(method string, urlRegexp *regexp // If the query type is not recognized or the string cannot be parsed // using [url.ParseQuery], a panic() occurs. // -// Unlike [MockTransport.RegisterResponder], path cannot be prefixed -// by "=~" to say it is a regexp. If it is, a panic occurs. +// Unlike [MockTransport.RegisterMatcherResponder], path cannot be +// prefixed by "=~" to say it is a regexp. If it is, a panic occurs. // // Registering an already existing responder resets the corresponding // statistics as returned by [MockTransport.GetCallCountInfo]. @@ -612,13 +842,34 @@ func (m *MockTransport) RegisterRegexpResponder(method string, urlRegexp *regexp // Registering a nil [Responder] removes the existing one and the // corresponding statistics as returned by // [MockTransport.GetCallCountInfo]. It does nothing if it does not -// already exist. +// already exist. The original matcher can be passed but also a new +// [Matcher] with the same name and a nil match function as in: +// +// NewMatcher("original matcher name", nil) +// +// If several responders are registered for a same method, path and +// query tuple, but with different matchers, they are ordered +// depending on the following rules: +// - the zero matcher, Matcher{} (or responder set using +// [MockTransport.RegisterResponderWithQuery]) is always called lastly; +// - other matchers are ordered by their name. If a matcher does not +// have an explicit name ([NewMatcher] called with an empty name and +// [Matcher.WithName] method not called), a name is automatically +// computed so all anonymous matchers are sorted by their creation +// order. An automatically computed name has always the form +// "~HEXANUMBER@PKG.FUNC() FILE:LINE". See [NewMatcher] for details. // // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible // mistake. This panic can be disabled by setting m.DontCheckMethod to // true prior to this call. -func (m *MockTransport) RegisterResponderWithQuery(method, path string, query any, responder Responder) { +// +// See also [MockTransport.RegisterResponderWithQuery] if a matcher is +// not needed. +// +// Note that [github.com/maxatome/tdhttpmock] provides powerful helpers +// to create matchers with the help of [github.com/maxatome/go-testdeep]. +func (m *MockTransport) RegisterMatcherResponderWithQuery(method, path string, query any, matcher Matcher, responder Responder) { if isRegexpURL(path) { panic(`path begins with "=~", RegisterResponder should be used instead of RegisterResponderWithQuery`) } @@ -650,7 +901,42 @@ func (m *MockTransport) RegisterResponderWithQuery(method, path string, query an if queryString := sortedQuery(mapQuery); queryString != "" { path += "?" + queryString } - m.RegisterResponder(method, path, responder) + m.RegisterMatcherResponder(method, path, matcher, responder) +} + +// RegisterResponderWithQuery is same as +// [MockTransport.RegisterResponder], but it doesn't depend on query +// items order. +// +// If query is non-nil, its type can be: +// +// - [url.Values] +// - map[string]string +// - string, a query string like "a=12&a=13&b=z&c" (see [url.ParseQuery] function) +// +// If the query type is not recognized or the string cannot be parsed +// using [url.ParseQuery], a panic() occurs. +// +// Unlike [MockTransport.RegisterResponder], path cannot be prefixed +// by "=~" to say it is a regexp. If it is, a panic occurs. +// +// Registering an already existing responder resets the corresponding +// statistics as returned by [MockTransport.GetCallCountInfo]. +// +// Registering a nil [Responder] removes the existing one and the +// corresponding statistics as returned by +// [MockTransport.GetCallCountInfo]. It does nothing if it does not +// already exist. +// +// If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, +// OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible +// mistake. This panic can be disabled by setting m.DontCheckMethod to +// true prior to this call. +// +// See [MockTransport.RegisterMatcherResponderWithQuery] to also match on +// request header and/or body. +func (m *MockTransport) RegisterResponderWithQuery(method, path string, query any, responder Responder) { + m.RegisterMatcherResponderWithQuery(method, path, query, Matcher{}, responder) } func sortedQuery(m url.Values) string { @@ -731,10 +1017,10 @@ func (m *MockTransport) RegisterNoResponder(responder Responder) { // responder) from the [MockTransport]. It zeroes call counters too. func (m *MockTransport) Reset() { m.mu.Lock() - m.responders = make(map[internal.RouteKey]Responder) + m.responders = make(map[internal.RouteKey]matchResponders) m.regexpResponders = nil m.noResponder = nil - m.callCountInfo = make(map[internal.RouteKey]int) + m.callCountInfo = make(map[matchRouteKey]int) m.totalCallCount = 0 m.mu.Unlock() } @@ -951,6 +1237,90 @@ func DeactivateAndReset() { Reset() } +// RegisterMatcherResponder adds a new responder, associated with a given +// HTTP method, URL (or path) and [Matcher]. +// +// When a request comes in that matches, the responder is called and +// the response returned to the client. +// +// If url contains query parameters, their order matters as well as +// their content. All following URLs are here considered as different: +// +// http://z.tld?a=1&b=1 +// http://z.tld?b=1&a=1 +// http://z.tld?a&b +// http://z.tld?a=&b= +// +// If url begins with "=~", the following chars are considered as a +// regular expression. If this regexp can not be compiled, it panics. +// Note that the "=~" prefix remains in statistics returned by +// [GetCallCountInfo]. As 2 regexps can match the same +// URL, the regexp responders are tested in the order they are +// registered. Registering an already existing regexp responder (same +// method & same regexp string) replaces its responder, but does not +// change its position. +// +// Registering an already existing responder resets the corresponding +// statistics as returned by [GetCallCountInfo]. +// +// Registering a nil [Responder] removes the existing one and the +// corresponding statistics as returned by +// [GetCallCountInfo]. It does nothing if it does not +// already exist. The original matcher can be passed but also a new +// [Matcher] with the same name and a nil match function as in: +// +// NewMatcher("original matcher name", nil) +// +// See [RegisterRegexpMatcherResponder] to directly pass a +// [*regexp.Regexp]. +// +// Example: +// +// func TestCreateArticle(t *testing.T) { +// httpmock.Activate() +// defer httpmock.DeactivateAndReset() +// +// // Mock POST /item only if `"name":"Bob"` is found in request body +// httpmock.RegisterMatcherResponder("POST", "/item", +// httpmock.BodyContainsString(`"name":"Bob"`), +// httpmock.NewStringResponder(201, `{"id":1234}`)) +// +// // Can be more acurate with github.com/maxatome/tdhttpmock package +// // paired with github.com/maxatome/go-testdeep/td operators as in +// httpmock.RegisterMatcherResponder("POST", "/item", +// tdhttpmock.JSONBody(td.JSONPointer("/name", "Alice")), +// httpmock.NewStringResponder(201, `{"id":4567}`)) +// +// // POST requests to http://anything/item with body containing either +// // `"name":"Bob"` or a JSON message with key "name" set to "Alice" +// // value return the corresponding "id" response +// } +// +// If several responders are registered for a same method and url +// couple, but with different matchers, they are ordered depending on +// the following rules: +// - the zero matcher, Matcher{} (or responder set using +// [RegisterResponder]) is always called lastly; +// - other matchers are ordered by their name. If a matcher does not +// have an explicit name ([NewMatcher] called with an empty name and +// [Matcher.WithName] method not called), a name is automatically +// computed so all anonymous matchers are sorted by their creation +// order. An automatically computed name has always the form +// "~HEXANUMBER@PKG.FUNC() FILE:LINE". See [NewMatcher] for details. +// +// If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, +// OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible +// mistake. This panic can be disabled by setting m.DontCheckMethod to +// true prior to this call. +// +// See also [RegisterResponder] if a matcher is not needed. +// +// Note that [github.com/maxatome/tdhttpmock] provides powerful helpers +// to create matchers with the help of [github.com/maxatome/go-testdeep]. +func RegisterMatcherResponder(method, url string, matcher Matcher, responder Responder) { + DefaultTransport.RegisterMatcherResponder(method, url, matcher, responder) +} + // RegisterResponder adds a new responder, associated with a given // HTTP method and URL (or path). // @@ -992,10 +1362,10 @@ func DeactivateAndReset() { // httpmock.NewStringResponder(200, "hello world")) // // httpmock.RegisterResponder("GET", "/path/only", -// httpmock.NewStringResponder("any host hello world", 200)) +// httpmock.NewStringResponder(200, "any host hello world")) // // httpmock.RegisterResponder("GET", `=~^/item/id/\d+\z`, -// httpmock.NewStringResponder("any item get", 200)) +// httpmock.NewStringResponder(200, "any item get")) // // // requests to http://example.com/ now return "hello world" and // // requests to any host with path /path/only return "any host hello world" @@ -1010,6 +1380,59 @@ func RegisterResponder(method, url string, responder Responder) { DefaultTransport.RegisterResponder(method, url, responder) } +// RegisterRegexpMatcherResponder adds a new responder, associated +// with a given HTTP method, URL (or path) regular expression and +// [Matcher]. +// +// When a request comes in that matches, the responder is called and +// the response returned to the client. +// +// As 2 regexps can match the same URL, the regexp responders are +// tested in the order they are registered. Registering an already +// existing regexp responder (same method, same regexp string and same +// [Matcher] name) replaces its responder, but does not change its +// position, and resets the corresponding statistics as returned by +// [GetCallCountInfo]. +// +// If several responders are registered for a same method and urlRegexp +// couple, but with different matchers, they are ordered depending on +// the following rules: +// - the zero matcher, Matcher{} (or responder set using +// [RegisterRegexpResponder]) is always called lastly; +// - other matchers are ordered by their name. If a matcher does not +// have an explicit name ([NewMatcher] called with an empty name and +// [Matcher.WithName] method not called), a name is automatically +// computed so all anonymous matchers are sorted by their creation +// order. An automatically computed name has always the form +// "~HEXANUMBER@PKG.FUNC() FILE:LINE". See [NewMatcher] for details. +// +// Registering a nil [Responder] removes the existing one and the +// corresponding statistics as returned by [GetCallCountInfo]. It does +// nothing if it does not already exist. The original matcher can be +// passed but also a new [Matcher] with the same name and a nil match +// function as in: +// +// NewMatcher("original matcher name", nil) +// +// A "=~" prefix is added to the stringified regexp in the statistics +// returned by [GetCallCountInfo]. +// +// See [RegisterMatcherResponder] function and the "=~" prefix in its +// url parameter to avoid compiling the regexp by yourself. +// +// If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, +// OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible +// mistake. This panic can be disabled by setting m.DontCheckMethod to +// true prior to this call. +// +// See [RegisterRegexpResponder] if a matcher is not needed. +// +// Note that [github.com/maxatome/tdhttpmock] provides powerful helpers +// to create matchers with the help of [github.com/maxatome/go-testdeep]. +func RegisterRegexpMatcherResponder(method string, urlRegexp *regexp.Regexp, matcher Matcher, responder Responder) { + DefaultTransport.RegisterRegexpMatcherResponder(method, urlRegexp, matcher, responder) +} + // RegisterRegexpResponder adds a new responder, associated with a given // HTTP method and URL (or path) regular expression. // @@ -1040,6 +1463,58 @@ func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder DefaultTransport.RegisterRegexpResponder(method, urlRegexp, responder) } +// RegisterMatcherResponderWithQuery is same as +// [RegisterMatcherResponder], but it doesn't depend on query items +// order. +// +// If query is non-nil, its type can be: +// +// - [url.Values] +// - map[string]string +// - string, a query string like "a=12&a=13&b=z&c" (see [url.ParseQuery] function) +// +// If the query type is not recognized or the string cannot be parsed +// using [url.ParseQuery], a panic() occurs. +// +// Unlike [RegisterMatcherResponder], path cannot be prefixed by "=~" +// to say it is a regexp. If it is, a panic occurs. +// +// Registering an already existing responder resets the corresponding +// statistics as returned by [GetCallCountInfo]. +// +// Registering a nil [Responder] removes the existing one and the +// corresponding statistics as returned by [GetCallCountInfo]. It does +// nothing if it does not already exist. The original matcher can be +// passed but also a new [Matcher] with the same name and a nil match +// function as in: +// +// NewMatcher("original matcher name", nil) +// +// If several responders are registered for a same method, path and +// query tuple, but with different matchers, they are ordered +// depending on the following rules: +// - the zero matcher, Matcher{} (or responder set using +// [.RegisterResponderWithQuery]) is always called lastly; +// - other matchers are ordered by their name. If a matcher does not +// have an explicit name ([NewMatcher] called with an empty name and +// [Matcher.WithName] method not called), a name is automatically +// computed so all anonymous matchers are sorted by their creation +// order. An automatically computed name has always the form +// "~HEXANUMBER@PKG.FUNC() FILE:LINE". See [NewMatcher] for details. +// +// If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, +// OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible +// mistake. This panic can be disabled by setting m.DontCheckMethod to +// true prior to this call. +// +// See also [RegisterResponderWithQuery] if a matcher is not needed. +// +// Note that [github.com/maxatome/tdhttpmock] provides powerful helpers +// to create matchers with the help of [github.com/maxatome/go-testdeep]. +func RegisterMatcherResponderWithQuery(method, path string, query any, matcher Matcher, responder Responder) { + DefaultTransport.RegisterMatcherResponderWithQuery(method, path, query, matcher, responder) +} + // RegisterResponderWithQuery it is same as [RegisterResponder], but // doesn't depends on query items order. // @@ -1074,7 +1549,7 @@ func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder // } // httpmock.RegisterResponderWithQueryValues( // "GET", "http://example.com/", expectedQuery, -// httpmock.NewStringResponder("hello world", 200)) +// httpmock.NewStringResponder(200, "hello world")) // // // requests to http://example.com?a=1&a=3&a=8&b=2&b=4 // // and to http://example.com?b=4&a=2&b=2&a=8&a=1 @@ -1093,7 +1568,7 @@ func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder // } // httpmock.RegisterResponderWithQuery( // "GET", "http://example.com/", expectedQuery, -// httpmock.NewStringResponder("hello world", 200)) +// httpmock.NewStringResponder(200, "hello world")) // // // requests to http://example.com?a=1&b=2 and http://example.com?b=2&a=1 now return 'hello world' // } @@ -1107,7 +1582,7 @@ func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder // expectedQuery := "a=3&b=4&b=2&a=1&a=8" // httpmock.RegisterResponderWithQueryValues( // "GET", "http://example.com/", expectedQuery, -// httpmock.NewStringResponder("hello world", 200)) +// httpmock.NewStringResponder(200, "hello world")) // // // requests to http://example.com?a=1&a=3&a=8&b=2&b=4 // // and to http://example.com?b=4&a=2&b=2&a=8&a=1 @@ -1119,7 +1594,7 @@ func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder // mistake. This panic can be disabled by setting // DefaultTransport.DontCheckMethod to true prior to this call. func RegisterResponderWithQuery(method, path string, query any, responder Responder) { - DefaultTransport.RegisterResponderWithQuery(method, path, query, responder) + RegisterMatcherResponderWithQuery(method, path, query, Matcher{}, responder) } // RegisterNoResponder is used to register a responder that is called diff --git a/transport_test.go b/transport_test.go index 4eac350..e2aa6cd 100644 --- a/transport_test.go +++ b/transport_test.go @@ -6,10 +6,12 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" //nolint: staticcheck "net" "net/http" "net/url" "regexp" + "strings" "testing" "time" @@ -92,6 +94,176 @@ func TestMockTransport(t *testing.T) { } } +func TestRegisterMatcherResponder(t *testing.T) { + Activate() + defer DeactivateAndReset() + + RegisterMatcherResponder("POST", "/foo", + NewMatcher( + "00-header-foo=bar", + func(r *http.Request) bool { return r.Header.Get("Foo") == "bar" }, + ), + NewStringResponder(200, "header-foo")) + + RegisterMatcherResponder("POST", "/foo", + NewMatcher( + "01-body-BAR", + func(r *http.Request) bool { + b, err := ioutil.ReadAll(r.Body) + return err == nil && bytes.Contains(b, []byte("BAR")) + }), + NewStringResponder(200, "body-BAR")) + + RegisterMatcherResponder("POST", "/foo", + NewMatcher( + "02-body-FOO", + func(r *http.Request) bool { + b, err := ioutil.ReadAll(r.Body) + return err == nil && bytes.Contains(b, []byte("FOO")) + }), + NewStringResponder(200, "body-FOO")) + + RegisterResponder("POST", "/foo", NewStringResponder(200, "default")) + + RegisterNoResponder(NewNotFoundResponder(nil)) + + testCases := []struct { + name string + body string + fooHeader string + expectedBody string + }{ + { + name: "header", + body: "pipo", + fooHeader: "bar", + expectedBody: "header-foo", + }, + { + name: "header+body=header", + body: "BAR", + fooHeader: "bar", + expectedBody: "header-foo", + }, + { + name: "body BAR", + body: "BAR", + fooHeader: "xxx", + expectedBody: "body-BAR", + }, + { + name: "body FOO", + body: "FOO", + expectedBody: "body-FOO", + }, + { + name: "default", + body: "ANYTHING", + fooHeader: "zzz", + expectedBody: "default", + }, + } + assert := td.Assert(t) + for _, tc := range testCases { + assert.RunAssertRequire(tc.name, func(assert, require *td.T) { + req, err := http.NewRequest( + "POST", + "http://test.com/foo", + strings.NewReader(tc.body), + ) + require.CmpNoError(err) + + req.Header.Set("Content-Type", "text/plain") + if tc.fooHeader != "" { + req.Header.Set("Foo", tc.fooHeader) + } + + resp, err := http.DefaultClient.Do(req) + require.CmpNoError(err) + + assertBody(assert, resp, tc.expectedBody) + }) + } + + // Remove the default responder + RegisterResponder("POST", "/foo", nil) + + assert.Run("not found despite 3", func(assert *td.T) { + _, err := http.Post( + "http://test.com/foo", + "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"]`) + }) + + // Remove 2 matcher responders + RegisterMatcherResponder("POST", "/foo", NewMatcher("01-body-BAR", nil), nil) + RegisterMatcherResponder("POST", "/foo", NewMatcher("02-body-FOO", nil), nil) + + assert.Run("not found despite 1", func(assert *td.T) { + _, err := http.Post( + "http://test.com/foo", + "text/plain", + strings.NewReader("ANYTHING"), + ) + assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite matcher "00-header-foo=bar"`) + }) + + // Add a regexp responder without a Matcher: as the exact match + // didn't succeed because of the "00-header-foo=bar" Matcher, the + // following one must be tried ans also succeed + RegisterResponder("POST", "=~^/foo", NewStringResponder(200, "regexp")) + + assert.RunAssertRequire("default regexp", func(assert, require *td.T) { + resp, err := http.Post( + "http://test.com/foo", + "text/plain", + strings.NewReader("ANYTHING"), + ) + // The exact match responder "00-header-foo=bar" fails because of + // its Matcher, so regexp responders have to be checked and ^/foo + // has to match + require.CmpNoError(err) + assertBody(assert, resp, "regexp") + }) + + // Remove the previous regexp responder + RegisterResponder("POST", "=~^/foo", nil) + + // Add a regexp Matcher responder that should match ZIP body + RegisterMatcherResponder("POST", "=~^/foo", + BodyContainsString("ZIP").WithName("10-body-ZIP"), + NewStringResponder(200, "body-ZIP")) + + assert.RunAssertRequire("regexp matcher OK", func(assert, require *td.T) { + resp, err := http.Post( + "http://test.com/foo", + "text/plain", + strings.NewReader("ZIP"), + ) + // The exact match responder "00-header-foo=bar" fails because of + // its Matcher, so regexp responders have to be checked and ^/foo + // + body ZIP has to match + require.CmpNoError(err) + assertBody(assert, resp, "body-ZIP") + }) + + assert.Run("regexp matcher no match", func(assert *td.T) { + _, err := http.Post( + "http://test.com/foo", + "text/plain", + strings.NewReader("ANYTHING"), + ) + // The exact match responder "00-header-foo=bar" fails because of + // its Matcher, so regexp responders have to be checked BUT none + // match. In this case the returned error has to be the first + // encountered, so the one corresponding to the exact match phase, + // not the regexp one + assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite matcher "00-header-foo=bar"`) + }) +} + // We should be able to find GET handlers when using an http.Request with a // default (zero-value) .Method. func TestMockTransportDefaultMethod(t *testing.T) { @@ -560,6 +732,9 @@ func TestMockTransportCallCountReset(t *testing.T) { RegisterResponder("GET", url, NewStringResponder(200, "body")) RegisterResponder("POST", "=~gitlab", NewStringResponder(200, "body")) + RegisterMatcherResponder("POST", "=~gitlab", + BodyContainsString("pipo").WithName("pipo-in-body"), + NewStringResponder(200, "body")) _, err := http.Get(url) require.CmpNoError(err) @@ -569,15 +744,23 @@ func TestMockTransportCallCountReset(t *testing.T) { _, err = http.Post(url2, "application/json", buff) require.CmpNoError(err) + buff.Reset() + json.NewEncoder(buff).Encode(`{"pipo":"bingo"}`) // nolint: errcheck + _, err = http.Post(url2, "application/json", buff) + require.CmpNoError(err) + _, err = http.Get(url) require.CmpNoError(err) - assert.Cmp(GetTotalCallCount(), 3) + assert.Cmp(GetTotalCallCount(), 2+1+1) assert.Cmp(GetCallCountInfo(), map[string]int{ "GET " + url: 2, // Regexp match generates 2 entries: "POST " + url2: 1, // the matched call "POST =~gitlab": 1, // the regexp responder + // Regexp + matcher match also generates 2 entries: + "POST " + url2 + " ": 1, // matched call + "POST =~gitlab ": 1, // the regexp responder with matcher }) Reset() @@ -600,6 +783,9 @@ func TestMockTransportCallCountZero(t *testing.T) { RegisterResponder("GET", url, NewStringResponder(200, "body")) RegisterResponder("POST", "=~gitlab", NewStringResponder(200, "body")) + RegisterMatcherResponder("POST", "=~gitlab", + BodyContainsString("pipo").WithName("pipo-in-body"), + NewStringResponder(200, "body")) _, err := http.Get(url) require.CmpNoError(err) @@ -609,15 +795,23 @@ func TestMockTransportCallCountZero(t *testing.T) { _, err = http.Post(url2, "application/json", buff) require.CmpNoError(err) + buff.Reset() + json.NewEncoder(buff).Encode(`{"pipo":"bingo"}`) // nolint: errcheck + _, err = http.Post(url2, "application/json", buff) + require.CmpNoError(err) + _, err = http.Get(url) require.CmpNoError(err) - assert.Cmp(GetTotalCallCount(), 3) + assert.Cmp(GetTotalCallCount(), 2+1+1) assert.Cmp(GetCallCountInfo(), map[string]int{ "GET " + url: 2, // Regexp match generates 2 entries: "POST " + url2: 1, // the matched call "POST =~gitlab": 1, // the regexp responder + // Regexp + matcher match also generates 2 entries: + "POST " + url2 + " ": 1, // matched call + "POST =~gitlab ": 1, // the regexp responder with matcher }) ZeroCallCounters() @@ -628,16 +822,21 @@ func TestMockTransportCallCountZero(t *testing.T) { // Regexp match generates 2 entries: "POST " + url2: 0, // the matched call "POST =~gitlab": 0, // the regexp responder + // Regexp + matcher match also generates 2 entries: + "POST " + url2 + " ": 0, // matched call + "POST =~gitlab ": 0, // the regexp responder with matcher }) // Unregister each responder RegisterResponder("GET", url, nil) RegisterResponder("POST", "=~gitlab", nil) + RegisterMatcherResponder("POST", "=~gitlab", NewMatcher("pipo-in-body", nil), nil) assert.Cmp(GetCallCountInfo(), map[string]int{ - // this one remains as it is not directly related to a registered - // responder but a consequence of a regexp match - "POST " + url2: 0, + // these ones remain as they are not directly related to a + // registered responder but a consequence of a regexp match + "POST " + url2: 0, + "POST " + url2 + " ": 0, }) } @@ -780,13 +979,46 @@ func TestRegisterRegexpResponder(t *testing.T) { rx := regexp.MustCompile("ex.mple") - RegisterRegexpResponder("GET", rx, NewStringResponder(200, "first")) + RegisterRegexpResponder("POST", rx, NewStringResponder(200, "first")) // Overwrite responder - RegisterRegexpResponder("GET", rx, NewStringResponder(200, "second")) + RegisterRegexpResponder("POST", rx, NewStringResponder(200, "second")) - resp, err := http.Get(testURL) + resp, err := http.Post(testURL, "text/plain", strings.NewReader("PIPO")) + td.Require(t).CmpNoError(err) + assertBody(t, resp, "second") + + RegisterRegexpMatcherResponder("POST", rx, + BodyContainsString("PIPO").WithName("01-body-PIPO"), + NewStringResponder(200, "matcher-PIPO")) + + RegisterRegexpMatcherResponder("POST", rx, + BodyContainsString("BINGO").WithName("02-body-BINGO"), + NewStringResponder(200, "matcher-BINGO")) + + resp, err = http.Post(testURL, "text/plain", strings.NewReader("PIPO")) + td.Require(t).CmpNoError(err) + assertBody(t, resp, "matcher-PIPO") + + resp, err = http.Post(testURL, "text/plain", strings.NewReader("BINGO")) td.Require(t).CmpNoError(err) + assertBody(t, resp, "matcher-BINGO") + + // Remove 01-body-PIPO matcher + RegisterRegexpMatcherResponder("POST", rx, NewMatcher("01-body-PIPO", nil), nil) + + resp, err = http.Post(testURL, "text/plain", strings.NewReader("PIPO")) + td.Require(t).CmpNoError(err) + assertBody(t, resp, "second") + resp, err = http.Post(testURL, "text/plain", strings.NewReader("BINGO")) + td.Require(t).CmpNoError(err) + assertBody(t, resp, "matcher-BINGO") + + // Remove 02-body-BINGO matcher + RegisterRegexpMatcherResponder("POST", rx, NewMatcher("02-body-BINGO", nil), nil) + + resp, err = http.Post(testURL, "text/plain", strings.NewReader("BINGO")) + td.Require(t).CmpNoError(err) assertBody(t, resp, "second") }