From 32212b13eb2d74ae00b257fae13bb6f72c875dd4 Mon Sep 17 00:00:00 2001 From: Matt Cotter Date: Tue, 22 Oct 2024 16:41:00 -0500 Subject: [PATCH 1/5] feat: add new command to send test data to Observe --- internal/commands/sendtestdata/postdata.go | 48 +++++++++++++++++ .../commands/sendtestdata/sendtestdata.go | 54 +++++++++++++++++++ main.go | 1 + 3 files changed, 103 insertions(+) create mode 100644 internal/commands/sendtestdata/postdata.go create mode 100644 internal/commands/sendtestdata/sendtestdata.go diff --git a/internal/commands/sendtestdata/postdata.go b/internal/commands/sendtestdata/postdata.go new file mode 100644 index 000000000..7c050d68c --- /dev/null +++ b/internal/commands/sendtestdata/postdata.go @@ -0,0 +1,48 @@ +package sendtestdata + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/spf13/viper" +) + +func PostTestData(data any, URL string, headers map[string]string) (string, error) { + postBody, err := json.Marshal(data) + if err != nil { + return "", err + } + client := &http.Client{} + req, err := http.NewRequest("POST", URL, bytes.NewBuffer(postBody)) + if err != nil { + return "", err + } + headers["Content-Type"] = "application/json" + for key, value := range headers { + req.Header.Add(key, value) + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + bodyString := string(bodyBytes) + if resp.StatusCode != 200 { + return "", fmt.Errorf("sending test data to %s failed with response: %s", URL, bodyString) + } + return bodyString, nil +} + +func PostTestDataToObserve(data any, path string) (string, error) { + collector_url := viper.GetString("observe_url") + endpoint := fmt.Sprintf("%s/v1/http/%s", strings.TrimRight(collector_url, "/"), strings.TrimLeft(path, "/")) + authToken := fmt.Sprintf("Bearer %s", viper.GetString("token")) + return PostTestData(data, endpoint, map[string]string{"Authorization": authToken}) +} diff --git a/internal/commands/sendtestdata/sendtestdata.go b/internal/commands/sendtestdata/sendtestdata.go new file mode 100644 index 000000000..6f7946077 --- /dev/null +++ b/internal/commands/sendtestdata/sendtestdata.go @@ -0,0 +1,54 @@ +/* +Copyright © 2024 NAME HERE +*/ +package sendtestdata + +import ( + "encoding/json" + "fmt" + + "github.com/observeinc/observe-agent/internal/root" + "github.com/spf13/cobra" +) + +const TestDataPath = "/observe-agent/test" + +var defaultTestData = map[string]any{ + "hello": "world", +} + +func NewSendTestDataCmd() *cobra.Command { + return &cobra.Command{ + Use: "send-test-data", + Short: "Sends test data to Observe", + Long: "Sends test data to Observe", + RunE: func(cmd *cobra.Command, args []string) error { + var testData map[string]any + dataFlag, _ := cmd.Flags().GetString("data") + if dataFlag != "" { + err := json.Unmarshal([]byte(dataFlag), &testData) + if err != nil { + return err + } + } else { + testData = defaultTestData + } + respBody, err := PostTestDataToObserve(testData, TestDataPath) + if err != nil { + return err + } + fmt.Printf("Successfully sent test data. Saw response: %s\n", respBody) + return nil + }, + } +} + +func init() { + sendTestDataCmd := NewSendTestDataCmd() + RegisterTestDataFlags(sendTestDataCmd) + root.RootCmd.AddCommand(sendTestDataCmd) +} + +func RegisterTestDataFlags(cmd *cobra.Command) { + cmd.Flags().String("data", "", "specify a given json object to send") +} diff --git a/main.go b/main.go index 2e2c324c5..ee0c64f9a 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( _ "github.com/observeinc/observe-agent/internal/commands/config" _ "github.com/observeinc/observe-agent/internal/commands/diagnose" _ "github.com/observeinc/observe-agent/internal/commands/initconfig" + _ "github.com/observeinc/observe-agent/internal/commands/sendtestdata" _ "github.com/observeinc/observe-agent/internal/commands/start" _ "github.com/observeinc/observe-agent/internal/commands/status" _ "github.com/observeinc/observe-agent/internal/commands/version" From 9a095a096e2e46182802f65c80526d4b712f2295 Mon Sep 17 00:00:00 2001 From: Matt Cotter Date: Thu, 24 Oct 2024 12:16:33 -0500 Subject: [PATCH 2/5] add test --- go.mod | 1 + .../commands/sendtestdata/postdata_test.go | 31 + vendor/github.com/jarcoal/httpmock/.gitignore | 22 + vendor/github.com/jarcoal/httpmock/LICENSE | 20 + vendor/github.com/jarcoal/httpmock/README.md | 257 +++ vendor/github.com/jarcoal/httpmock/any.go | 6 + vendor/github.com/jarcoal/httpmock/doc.go | 83 + vendor/github.com/jarcoal/httpmock/env.go | 13 + vendor/github.com/jarcoal/httpmock/file.go | 63 + .../jarcoal/httpmock/internal/error.go | 41 + .../jarcoal/httpmock/internal/route_key.go | 15 + .../jarcoal/httpmock/internal/stack_tracer.go | 91 + .../jarcoal/httpmock/internal/submatches.go | 22 + vendor/github.com/jarcoal/httpmock/match.go | 519 +++++ .../github.com/jarcoal/httpmock/response.go | 756 +++++++ .../github.com/jarcoal/httpmock/transport.go | 1867 +++++++++++++++++ vendor/modules.txt | 4 + 17 files changed, 3811 insertions(+) create mode 100644 internal/commands/sendtestdata/postdata_test.go create mode 100644 vendor/github.com/jarcoal/httpmock/.gitignore create mode 100644 vendor/github.com/jarcoal/httpmock/LICENSE create mode 100644 vendor/github.com/jarcoal/httpmock/README.md create mode 100644 vendor/github.com/jarcoal/httpmock/any.go create mode 100644 vendor/github.com/jarcoal/httpmock/doc.go create mode 100644 vendor/github.com/jarcoal/httpmock/env.go create mode 100644 vendor/github.com/jarcoal/httpmock/file.go create mode 100644 vendor/github.com/jarcoal/httpmock/internal/error.go create mode 100644 vendor/github.com/jarcoal/httpmock/internal/route_key.go create mode 100644 vendor/github.com/jarcoal/httpmock/internal/stack_tracer.go create mode 100644 vendor/github.com/jarcoal/httpmock/internal/submatches.go create mode 100644 vendor/github.com/jarcoal/httpmock/match.go create mode 100644 vendor/github.com/jarcoal/httpmock/response.go create mode 100644 vendor/github.com/jarcoal/httpmock/transport.go diff --git a/go.mod b/go.mod index 672d8b904..d8b056fac 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/observeinc/observe-agent go 1.22.7 require ( + github.com/jarcoal/httpmock v1.3.1 github.com/observeinc/observe-agent/observecol v0.0.0-00010101000000-000000000000 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.59.1 diff --git a/internal/commands/sendtestdata/postdata_test.go b/internal/commands/sendtestdata/postdata_test.go new file mode 100644 index 000000000..e0e7365cf --- /dev/null +++ b/internal/commands/sendtestdata/postdata_test.go @@ -0,0 +1,31 @@ +package sendtestdata + +import ( + "testing" + + "github.com/jarcoal/httpmock" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestPostTestData(t *testing.T) { + httpmock.Activate() + t.Cleanup(httpmock.DeactivateAndReset) + + expectedResponse := `{"ok":true}` + // Verify that the data is sent to the expected endpoint along with the bearer and json headers. + httpmock.RegisterMatcherResponder("POST", "https://123456.collect.observe-eng.com/v1/http/test", + httpmock.BodyContainsString(`"hello":"world"`).And( + httpmock.HeaderIs("Content-Type", "application/json"), + httpmock.HeaderIs("Authorization", "Bearer test-token"), + ), + httpmock.NewStringResponder(200, expectedResponse), + ) + + viper.Set("observe_url", "https://123456.collect.observe-eng.com/") + viper.Set("token", "test-token") + testData := map[string]string{"hello": "world"} + resp, err := PostTestDataToObserve(testData, "/test") + assert.NoError(t, err) + assert.Equal(t, expectedResponse, resp) +} diff --git a/vendor/github.com/jarcoal/httpmock/.gitignore b/vendor/github.com/jarcoal/httpmock/.gitignore new file mode 100644 index 000000000..00268614f --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/.gitignore @@ -0,0 +1,22 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe diff --git a/vendor/github.com/jarcoal/httpmock/LICENSE b/vendor/github.com/jarcoal/httpmock/LICENSE new file mode 100644 index 000000000..438fbf545 --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Jared Morse + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/jarcoal/httpmock/README.md b/vendor/github.com/jarcoal/httpmock/README.md new file mode 100644 index 000000000..e4ffd304f --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/README.md @@ -0,0 +1,257 @@ +# httpmock [![Build Status](https://github.com/jarcoal/httpmock/workflows/Build/badge.svg?branch=v1)](https://github.com/jarcoal/httpmock/actions?query=workflow%3ABuild) [![Coverage Status](https://coveralls.io/repos/github/jarcoal/httpmock/badge.svg?branch=v1)](https://coveralls.io/github/jarcoal/httpmock?branch=v1) [![GoDoc](https://godoc.org/github.com/jarcoal/httpmock?status.svg)](https://godoc.org/github.com/jarcoal/httpmock) [![Version](https://img.shields.io/github/tag/jarcoal/httpmock.svg)](https://github.com/jarcoal/httpmock/releases) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go/#testing) + +Easy mocking of http responses from external resources. + +## Install + +Currently supports Go 1.13 to 1.21 and is regularly tested against tip. + +`v1` branch has to be used instead of `master`. + +In your go files, simply use: +```go +import "github.com/jarcoal/httpmock" +``` + +Then next `go mod tidy` or `go test` invocation will automatically +populate your `go.mod` with the latest httpmock release, now +[![Version](https://img.shields.io/github/tag/jarcoal/httpmock.svg)](https://github.com/jarcoal/httpmock/releases). + + +## Usage + +### Simple Example: +```go +func TestFetchArticles(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // Exact URL match + httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", + httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) + + // Regexp match (could use httpmock.RegisterRegexpResponder instead) + httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/\d+\z`, + httpmock.NewStringResponder(200, `{"id": 1, "name": "My Great Article"}`)) + + // do stuff that makes a request to articles + ... + + // get count info + httpmock.GetTotalCallCount() + + // get the amount of calls for the registered responder + info := httpmock.GetCallCountInfo() + info["GET https://api.mybiz.com/articles"] // number of GET calls made to https://api.mybiz.com/articles + info["GET https://api.mybiz.com/articles/id/12"] // number of GET calls made to https://api.mybiz.com/articles/id/12 + info[`GET =~^https://api\.mybiz\.com/articles/id/\d+\z`] // number of GET calls made to https://api.mybiz.com/articles/id/ +} +``` + +### Advanced Example: +```go +func TestFetchArticles(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // our database of articles + articles := make([]map[string]interface{}, 0) + + // mock to list out the articles + httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", + func(req *http.Request) (*http.Response, error) { + resp, err := httpmock.NewJsonResponse(200, articles) + if err != nil { + 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`, + func(req *http.Request) (*http.Response, error) { + // Get ID from request + id := httpmock.MustGetSubmatchAsUint(req, 1) // 1=first regexp submatch + return httpmock.NewJsonResponse(200, map[string]interface{}{ + "id": id, + "name": "My Great Article", + }) + }) + + // mock to add a new article + httpmock.RegisterResponder("POST", "https://api.mybiz.com/articles", + func(req *http.Request) (*http.Response, error) { + article := make(map[string]interface{}) + if err := json.NewDecoder(req.Body).Decode(&article); err != nil { + return httpmock.NewStringResponse(400, ""), nil + } + + articles = append(articles, article) + + resp, err := httpmock.NewJsonResponse(200, article) + if err != nil { + 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 +} +``` + +### Algorithm + +When `GET http://example.tld/some/path?b=12&a=foo&a=bar` request is +caught, all standard responders are checked against the following URL +or paths, the first match stops the search: + +1. `http://example.tld/some/path?b=12&a=foo&a=bar` (original URL) +1. `http://example.tld/some/path?a=bar&a=foo&b=12` (sorted query params) +1. `http://example.tld/some/path` (without query params) +1. `/some/path?b=12&a=foo&a=bar` (original URL without scheme and host) +1. `/some/path?a=bar&a=foo&b=12` (same, but sorted query params) +1. `/some/path` (path only) + +If no standard responder matched, the regexp responders are checked, +in the same order, the first match stops the search. + + +### [go-testdeep](https://go-testdeep.zetta.rocks/) + [tdsuite](https://pkg.go.dev/github.com/maxatome/go-testdeep/helpers/tdsuite) example: +```go +// article_test.go + +import ( + "testing" + + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/helpers/tdsuite" + "github.com/maxatome/go-testdeep/td" +) + +type MySuite struct{} + +func (s *MySuite) Setup(t *td.T) error { + // block all HTTP requests + httpmock.Activate() + return nil +} + +func (s *MySuite) PostTest(t *td.T, testName string) error { + // remove any mocks after each test + httpmock.Reset() + return nil +} + +func (s *MySuite) Destroy(t *td.T) error { + httpmock.DeactivateAndReset() + return nil +} + +func TestMySuite(t *testing.T) { + tdsuite.Run(t, &MySuite{}) +} + +func (s *MySuite) TestArticles(assert, require *td.T) { + httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles.json", + httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) + + // do stuff that makes a request to articles.json +} +``` + + +### [Ginkgo](https://onsi.github.io/ginkgo/) example: +```go +// article_suite_test.go + +import ( + // ... + "github.com/jarcoal/httpmock" +) +// ... +var _ = BeforeSuite(func() { + // block all HTTP requests + httpmock.Activate() +}) + +var _ = BeforeEach(func() { + // remove any mocks + httpmock.Reset() +}) + +var _ = AfterSuite(func() { + httpmock.DeactivateAndReset() +}) + + +// article_test.go + +import ( + // ... + "github.com/jarcoal/httpmock" +) + +var _ = Describe("Articles", func() { + It("returns a list of articles", func() { + httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles.json", + httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) + + // do stuff that makes a request to articles.json + }) +}) +``` + +### [Ginkgo](https://onsi.github.io/ginkgo/) + [Resty](https://github.com/go-resty/resty) Example: +```go +// article_suite_test.go + +import ( + // ... + "github.com/jarcoal/httpmock" + "github.com/go-resty/resty" +) +// ... +var _ = BeforeSuite(func() { + // block all HTTP requests + httpmock.ActivateNonDefault(resty.DefaultClient.GetClient()) +}) + +var _ = BeforeEach(func() { + // remove any mocks + httpmock.Reset() +}) + +var _ = AfterSuite(func() { + httpmock.DeactivateAndReset() +}) + + +// article_test.go + +import ( + // ... + "github.com/jarcoal/httpmock" + "github.com/go-resty/resty" +) + +var _ = Describe("Articles", func() { + It("returns a list of articles", func() { + fixture := `{"status":{"message": "Your message", "code": 200}}` + responder := httpmock.NewStringResponder(200, fixture) + fakeUrl := "https://api.mybiz.com/articles.json" + httpmock.RegisterResponder("GET", fakeUrl, responder) + + // fetch the article into struct + articleObject := &models.Article{} + _, err := resty.R().SetResult(articleObject).Get(fakeUrl) + + // do stuff with the article object ... + }) +}) +``` diff --git a/vendor/github.com/jarcoal/httpmock/any.go b/vendor/github.com/jarcoal/httpmock/any.go new file mode 100644 index 000000000..3c8f7cc9f --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/any.go @@ -0,0 +1,6 @@ +//go:build !go1.18 +// +build !go1.18 + +package httpmock + +type any = interface{} diff --git a/vendor/github.com/jarcoal/httpmock/doc.go b/vendor/github.com/jarcoal/httpmock/doc.go new file mode 100644 index 000000000..ea3491cbc --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/doc.go @@ -0,0 +1,83 @@ +/* +Package httpmock provides tools for mocking HTTP responses. + +Simple Example: + + func TestFetchArticles(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // Exact URL match + httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", + httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) + + // Regexp match (could use httpmock.RegisterRegexpResponder instead) + httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/\d+\z`, + httpmock.NewStringResponder(200, `{"id": 1, "name": "My Great Article"}`)) + + // do stuff that makes a request to articles + + // get count info + httpmock.GetTotalCallCount() + + // get the amount of calls for the registered responder + info := httpmock.GetCallCountInfo() + info["GET https://api.mybiz.com/articles"] // number of GET calls made to https://api.mybiz.com/articles + info["GET https://api.mybiz.com/articles/id/12"] // number of GET calls made to https://api.mybiz.com/articles/id/12 + info[`GET =~^https://api\.mybiz\.com/articles/id/\d+\z`] // number of GET calls made to https://api.mybiz.com/articles/id/ + } + +Advanced Example: + + func TestFetchArticles(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // our database of articles + articles := make([]map[string]any, 0) + + // mock to list out the articles + httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", + func(req *http.Request) (*http.Response, error) { + resp, err := httpmock.NewJsonResponse(200, articles) + if err != nil { + 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`, + func(req *http.Request) (*http.Response, error) { + // Get ID from request + id := httpmock.MustGetSubmatchAsUint(req, 1) // 1=first regexp submatch + return httpmock.NewJsonResponse(200, map[string]any{ + "id": id, + "name": "My Great Article", + }) + }, + ) + + // mock to add a new article + httpmock.RegisterResponder("POST", "https://api.mybiz.com/articles", + func(req *http.Request) (*http.Response, error) { + article := make(map[string]any) + if err := json.NewDecoder(req.Body).Decode(&article); err != nil { + return httpmock.NewStringResponse(400, ""), nil + } + + articles = append(articles, article) + + resp, err := httpmock.NewJsonResponse(200, article) + if err != nil { + return httpmock.NewStringResponse(500, ""), nil + } + return resp, nil + }, + ) + + // do stuff that adds and checks articles + } +*/ +package httpmock diff --git a/vendor/github.com/jarcoal/httpmock/env.go b/vendor/github.com/jarcoal/httpmock/env.go new file mode 100644 index 000000000..41224683c --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/env.go @@ -0,0 +1,13 @@ +package httpmock + +import ( + "os" +) + +var envVarName = "GONOMOCKS" + +// Disabled allows to test whether httpmock is enabled or not. It +// depends on GONOMOCKS environment variable. +func Disabled() bool { + return os.Getenv(envVarName) != "" +} diff --git a/vendor/github.com/jarcoal/httpmock/file.go b/vendor/github.com/jarcoal/httpmock/file.go new file mode 100644 index 000000000..c88a236fb --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/file.go @@ -0,0 +1,63 @@ +package httpmock + +import ( + "fmt" + "io/ioutil" //nolint: staticcheck +) + +// File is a file name. The contents of this file is loaded on demand +// by the following methods. +// +// Note that: +// +// file := httpmock.File("file.txt") +// fmt.Printf("file: %s\n", file) +// +// prints the content of file "file.txt" as [File.String] method is used. +// +// To print the file name, and not its content, simply do: +// +// file := httpmock.File("file.txt") +// fmt.Printf("file: %s\n", string(file)) +type File string + +// MarshalJSON implements [encoding/json.Marshaler]. +// +// Useful to be used in conjunction with [NewJsonResponse] or +// [NewJsonResponder] as in: +// +// httpmock.NewJsonResponder(200, httpmock.File("body.json")) +func (f File) MarshalJSON() ([]byte, error) { + return f.bytes() +} + +func (f File) bytes() ([]byte, error) { + return ioutil.ReadFile(string(f)) +} + +// Bytes returns the content of file as a []byte. If an error occurs +// during the opening or reading of the file, it panics. +// +// Useful to be used in conjunction with [NewBytesResponse] or +// [NewBytesResponder] as in: +// +// httpmock.NewBytesResponder(200, httpmock.File("body.raw").Bytes()) +func (f File) Bytes() []byte { + b, err := f.bytes() + if err != nil { + panic(fmt.Sprintf("Cannot read %s: %s", string(f), err)) + } + return b +} + +// String implements [fmt.Stringer] and returns the content of file as +// a string. If an error occurs during the opening or reading of the +// file, it panics. +// +// Useful to be used in conjunction with [NewStringResponse] or +// [NewStringResponder] as in: +// +// httpmock.NewStringResponder(200, httpmock.File("body.txt").String()) +func (f File) String() string { + return string(f.Bytes()) +} diff --git a/vendor/github.com/jarcoal/httpmock/internal/error.go b/vendor/github.com/jarcoal/httpmock/internal/error.go new file mode 100644 index 000000000..3a38046d1 --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/internal/error.go @@ -0,0 +1,41 @@ +package internal + +import ( + "errors" + "fmt" +) + +// NoResponderFound is returned when no responders are found for a +// given HTTP method and URL. +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", "URL" or "matcher" + Orig string // original wrong method/URL, without any matching responder + Suggested string // suggested method/URL with a matching responder +} + +var _ error = (*ErrorNoResponderFoundMistake)(nil) + +// Unwrap implements the interface needed by errors.Unwrap. +func (e *ErrorNoResponderFoundMistake) Unwrap() error { + return NoResponderFound +} + +// 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, + e.Orig, + e.Suggested, + ) +} diff --git a/vendor/github.com/jarcoal/httpmock/internal/route_key.go b/vendor/github.com/jarcoal/httpmock/internal/route_key.go new file mode 100644 index 000000000..dbbad5047 --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/internal/route_key.go @@ -0,0 +1,15 @@ +package internal + +type RouteKey struct { + Method string + URL string +} + +var NoResponder RouteKey + +func (r RouteKey) String() string { + if r == NoResponder { + return "NO_RESPONDER" + } + return r.Method + " " + r.URL +} diff --git a/vendor/github.com/jarcoal/httpmock/internal/stack_tracer.go b/vendor/github.com/jarcoal/httpmock/internal/stack_tracer.go new file mode 100644 index 000000000..7072da008 --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/internal/stack_tracer.go @@ -0,0 +1,91 @@ +package internal + +import ( + "bytes" + "fmt" + "net/http" + "runtime" + "strings" +) + +type StackTracer struct { + CustomFn func(...interface{}) + Err error +} + +// Error implements error interface. +func (s StackTracer) Error() string { + if s.Err == nil { + return "" + } + return s.Err.Error() +} + +// Unwrap implements the interface needed by errors.Unwrap. +func (s StackTracer) Unwrap() error { + return s.Err +} + +// CheckStackTracer checks for specific error returned by +// NewNotFoundResponder function or Trace Responder method. +func CheckStackTracer(req *http.Request, err error) error { + if nf, ok := err.(StackTracer); ok { + if nf.CustomFn != nil { + pc := make([]uintptr, 128) + npc := runtime.Callers(2, pc) + pc = pc[:npc] + + var mesg bytes.Buffer + var netHTTPBegin, netHTTPEnd bool + + // Start recording at first net/http call if any... + for { + frames := runtime.CallersFrames(pc) + + var lastFn string + for { + frame, more := frames.Next() + + if !netHTTPEnd { + if netHTTPBegin { + netHTTPEnd = !strings.HasPrefix(frame.Function, "net/http.") + } else { + netHTTPBegin = strings.HasPrefix(frame.Function, "net/http.") + } + } + + if netHTTPEnd { + if lastFn != "" { + if mesg.Len() == 0 { + if nf.Err != nil { + mesg.WriteString(nf.Err.Error()) + } else { + fmt.Fprintf(&mesg, "%s %s", req.Method, req.URL) + } + mesg.WriteString("\nCalled from ") + } else { + mesg.WriteString("\n ") + } + fmt.Fprintf(&mesg, "%s()\n at %s:%d", lastFn, frame.File, frame.Line) + } + } + lastFn = frame.Function + + if !more { + break + } + } + + // At least one net/http frame found + if mesg.Len() > 0 { + break + } + netHTTPEnd = true // retry without looking at net/http frames + } + + nf.CustomFn(mesg.String()) + } + err = nf.Err + } + return err +} diff --git a/vendor/github.com/jarcoal/httpmock/internal/submatches.go b/vendor/github.com/jarcoal/httpmock/internal/submatches.go new file mode 100644 index 000000000..8d071e07a --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/internal/submatches.go @@ -0,0 +1,22 @@ +package internal + +import ( + "context" + "net/http" +) + +type submatchesKeyType struct{} + +var submatchesKey submatchesKeyType + +func SetSubmatches(req *http.Request, submatches []string) *http.Request { + if len(submatches) > 0 { + return req.WithContext(context.WithValue(req.Context(), submatchesKey, submatches)) + } + return req +} + +func GetSubmatches(req *http.Request) []string { + sm, _ := req.Context().Value(submatchesKey).([]string) + return sm +} diff --git a/vendor/github.com/jarcoal/httpmock/match.go b/vendor/github.com/jarcoal/httpmock/match.go new file mode 100644 index 000000000..ba6ada48c --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/match.go @@ -0,0 +1,519 @@ +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 { + rearmBody(req) + 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 { + rearmBody(req) + 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 { + rearmBody(req) + 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 { + rearmBody(req) + 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 + ">" +} + +func rearmBody(req *http.Request) { + if req != nil { + if body, ok := req.Body.(interface{ rearm() }); ok { + body.rearm() + } + } +} + +type buffer struct { + *bytes.Reader +} + +func (b buffer) Close() error { + return nil +} + +// bodyCopyOnRead mutates body into a buffer on first Read(), except +// if body is nil or http.NoBody. In this case, EOF is returned for +// each Read() and body stays untouched. +type bodyCopyOnRead struct { + body io.ReadCloser +} + +func (b *bodyCopyOnRead) rearm() { + if buf, ok := b.body.(buffer); ok { + buf.Seek(0, io.SeekStart) //nolint:errcheck + } // else b.body contains the original body, so don't touch +} + +func (b *bodyCopyOnRead) copy() { + if _, ok := b.body.(buffer); !ok && b.body != nil && b.body != http.NoBody { + buf, _ := ioutil.ReadAll(b.body) + b.body.Close() + b.body = buffer{bytes.NewReader(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/vendor/github.com/jarcoal/httpmock/response.go b/vendor/github.com/jarcoal/httpmock/response.go new file mode 100644 index 000000000..fd9ecd451 --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/response.go @@ -0,0 +1,756 @@ +package httpmock + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/jarcoal/httpmock/internal" +) + +// fromThenKeyType is used by Then(). +type fromThenKeyType struct{} + +var fromThenKey = fromThenKeyType{} + +type suggestedInfo struct { + kind string + suggested string +} + +// suggestedMethodKeyType is used by NewNotFoundResponder(). +type suggestedKeyType struct{} + +var suggestedKey = suggestedKeyType{} + +// Responder is a callback that receives an [*http.Request] and returns +// a mocked response. +type Responder func(*http.Request) (*http.Response, error) + +func (r Responder) times(name string, n int, fn ...func(...any)) Responder { + count := 0 + return func(req *http.Request) (*http.Response, error) { + count++ + if count > n { + err := internal.StackTracer{ + Err: fmt.Errorf("Responder not found for %s %s (coz %s and already called %d times)", req.Method, req.URL, name, count), + } + if len(fn) > 0 { + err.CustomFn = fn[0] + } + return nil, err + } + return r(req) + } +} + +// Times returns a [Responder] callable n times before returning an +// error. If the [Responder] is called more than n times and fn is +// passed and non-nil, it acts as the fn parameter of +// [NewNotFoundResponder], allowing to dump the stack trace to +// localize the origin of the call. +// +// import ( +// "testing" +// "github.com/jarcoal/httpmock" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// // This responder is callable 3 times, then an error is returned and +// // the stacktrace of the call logged using t.Log() +// httpmock.RegisterResponder("GET", "/foo/bar", +// httpmock.NewStringResponder(200, "{}").Times(3, t.Log), +// ) +func (r Responder) Times(n int, fn ...func(...any)) Responder { + return r.times("Times", n, fn...) +} + +// Once returns a new [Responder] callable once before returning an +// error. If the [Responder] is called 2 or more times and fn is passed +// and non-nil, it acts as the fn parameter of [NewNotFoundResponder], +// allowing to dump the stack trace to localize the origin of the +// call. +// +// import ( +// "testing" +// "github.com/jarcoal/httpmock" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// // This responder is callable only once, then an error is returned and +// // the stacktrace of the call logged using t.Log() +// httpmock.RegisterResponder("GET", "/foo/bar", +// httpmock.NewStringResponder(200, "{}").Once(t.Log), +// ) +func (r Responder) Once(fn ...func(...any)) Responder { + return r.times("Once", 1, fn...) +} + +// Trace returns a new [Responder] that allows to easily trace the calls +// of the original [Responder] using fn. It can be used in conjunction +// with the testing package as in the example below with the help of +// [*testing.T.Log] method: +// +// import ( +// "testing" +// "github.com/jarcoal/httpmock" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// httpmock.RegisterResponder("GET", "/foo/bar", +// httpmock.NewStringResponder(200, "{}").Trace(t.Log), +// ) +func (r Responder) Trace(fn func(...any)) Responder { + return func(req *http.Request) (*http.Response, error) { + resp, err := r(req) + return resp, internal.StackTracer{ + CustomFn: fn, + Err: err, + } + } +} + +// Delay returns a new [Responder] that calls the original r Responder +// after a delay of d. +// +// import ( +// "testing" +// "time" +// "github.com/jarcoal/httpmock" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// httpmock.RegisterResponder("GET", "/foo/bar", +// httpmock.NewStringResponder(200, "{}").Delay(100*time.Millisecond), +// ) +func (r Responder) Delay(d time.Duration) Responder { + return func(req *http.Request) (*http.Response, error) { + time.Sleep(d) + return r(req) + } +} + +var errThenDone = errors.New("ThenDone") + +// similar is simple but a bit tricky. Here we consider two Responder +// are similar if they share the same function, but not necessarily +// the same environment. It is only used by Then below. +func (r Responder) similar(other Responder) bool { + return reflect.ValueOf(r).Pointer() == reflect.ValueOf(other).Pointer() +} + +// Then returns a new [Responder] that calls r on first invocation, then +// next on following ones, except when Then is chained, in this case +// next is called only once: +// +// A := httpmock.NewStringResponder(200, "A") +// B := httpmock.NewStringResponder(200, "B") +// C := httpmock.NewStringResponder(200, "C") +// +// httpmock.RegisterResponder("GET", "/pipo", A.Then(B).Then(C)) +// +// http.Get("http://foo.bar/pipo") // A is called +// http.Get("http://foo.bar/pipo") // B is called +// http.Get("http://foo.bar/pipo") // C is called +// http.Get("http://foo.bar/pipo") // C is called, and so on +// +// A panic occurs if next is the result of another Then call (because +// allowing it could cause inextricable problems at runtime). Then +// calls can be chained, but cannot call each other by +// parameter. Example: +// +// A.Then(B).Then(C) // is OK +// A.Then(B.Then(C)) // panics as A.Then() parameter is another Then() call +// +// See also [ResponderFromMultipleResponses]. +func (r Responder) Then(next Responder) (x Responder) { + var done int + var mu sync.Mutex + x = func(req *http.Request) (*http.Response, error) { + mu.Lock() + defer mu.Unlock() + + ctx := req.Context() + thenCalledUs, _ := ctx.Value(fromThenKey).(bool) + if !thenCalledUs { + req = req.WithContext(context.WithValue(ctx, fromThenKey, true)) + } + + switch done { + case 0: + resp, err := r(req) + if err != errThenDone { + if !x.similar(r) { // r is NOT a Then + done = 1 + } + return resp, err + } + fallthrough + + case 1: + done = 2 // next is NEVER a Then, as it is forbidden by design + return next(req) + } + if thenCalledUs { + return nil, errThenDone + } + return next(req) + } + + if next.similar(x) { + panic("Then() does not accept another Then() Responder as parameter") + } + return +} + +// SetContentLength returns a new [Responder] based on r that ensures +// the returned [*http.Response] ContentLength field and +// Content-Length header are set to the right value. +// +// If r returns an [*http.Response] with a nil Body or equal to +// [http.NoBody], the length is always set to 0. +// +// If r returned response.Body implements: +// +// Len() int +// +// then the length is set to the Body.Len() returned value. All +// httpmock generated bodies implement this method. Beware that +// [strings.Builder], [strings.Reader], [bytes.Buffer] and +// [bytes.Reader] types used with [io.NopCloser] do not implement +// Len() anymore. +// +// Otherwise, r returned response.Body is entirely copied into an +// internal buffer to get its length, then it is closed. The Body of +// the [*http.Response] returned by the [Responder] returned by +// SetContentLength can then be read again to return its content as +// usual. But keep in mind that each time this [Responder] is called, +// r is called first. So this one has to carefully handle its body: it +// is highly recommended to use [NewRespBodyFromString] or +// [NewRespBodyFromBytes] to set the body once (as +// [NewStringResponder] and [NewBytesResponder] do behind the scene), +// or to build the body each time r is called. +// +// The following calls are all correct: +// +// responder = httpmock.NewStringResponder(200, "BODY").SetContentLength() +// responder = httpmock.NewBytesResponder(200, []byte("BODY")).SetContentLength() +// responder = ResponderFromResponse(&http.Response{ +// // build a body once, but httpmock knows how to "rearm" it once read +// Body: NewRespBodyFromString("BODY"), +// StatusCode: 200, +// }).SetContentLength() +// responder = httpmock.Responder(func(req *http.Request) (*http.Response, error) { +// // build a new body for each call +// return &http.Response{ +// StatusCode: 200, +// Body: io.NopCloser(strings.NewReader("BODY")), +// }, nil +// }).SetContentLength() +// +// But the following is not correct: +// +// responder = httpmock.ResponderFromResponse(&http.Response{ +// StatusCode: 200, +// Body: io.NopCloser(strings.NewReader("BODY")), +// }).SetContentLength() +// +// it will only succeed for the first responder call. The following +// calls will deliver responses with an empty body, as it will already +// been read by the first call. +func (r Responder) SetContentLength() Responder { + return func(req *http.Request) (*http.Response, error) { + resp, err := r(req) + if err != nil { + return nil, err + } + nr := *resp + switch nr.Body { + case nil: + nr.Body = http.NoBody + fallthrough + case http.NoBody: + nr.ContentLength = 0 + default: + bl, ok := nr.Body.(interface{ Len() int }) + if !ok { + copyBody := &dummyReadCloser{orig: nr.Body} + bl, nr.Body = copyBody, copyBody + } + nr.ContentLength = int64(bl.Len()) + } + if nr.Header == nil { + nr.Header = http.Header{} + } + nr.Header = nr.Header.Clone() + nr.Header.Set("Content-Length", strconv.FormatInt(nr.ContentLength, 10)) + return &nr, nil + } +} + +// HeaderAdd returns a new [Responder] based on r that ensures the +// returned [*http.Response] includes h header. It adds each h entry +// to the header. It appends to any existing values associated with +// each h key. Each key is case insensitive; it is canonicalized by +// [http.CanonicalHeaderKey]. +// +// See also [Responder.HeaderSet] and [Responder.SetContentLength]. +func (r Responder) HeaderAdd(h http.Header) Responder { + return func(req *http.Request) (*http.Response, error) { + resp, err := r(req) + if err != nil { + return nil, err + } + nr := *resp + if nr.Header == nil { + nr.Header = make(http.Header, len(h)) + } + nr.Header = nr.Header.Clone() + for k, v := range h { + k = http.CanonicalHeaderKey(k) + if v == nil { + if _, ok := nr.Header[k]; !ok { + nr.Header[k] = nil + } + continue + } + nr.Header[k] = append(nr.Header[k], v...) + } + return &nr, nil + } +} + +// HeaderSet returns a new [Responder] based on r that ensures the +// returned [*http.Response] includes h header. It sets the header +// entries associated with each h key. It replaces any existing values +// associated each h key. Each key is case insensitive; it is +// canonicalized by [http.CanonicalHeaderKey]. +// +// See also [Responder.HeaderAdd] and [Responder.SetContentLength]. +func (r Responder) HeaderSet(h http.Header) Responder { + return func(req *http.Request) (*http.Response, error) { + resp, err := r(req) + if err != nil { + return nil, err + } + nr := *resp + if nr.Header == nil { + nr.Header = make(http.Header, len(h)) + } + nr.Header = nr.Header.Clone() + for k, v := range h { + k = http.CanonicalHeaderKey(k) + if v == nil { + nr.Header[k] = nil + continue + } + nr.Header[k] = append([]string(nil), v...) + } + return &nr, nil + } +} + +// ResponderFromResponse wraps an [*http.Response] in a [Responder]. +// +// Be careful, except for responses generated by httpmock +// ([NewStringResponse] and [NewBytesResponse] functions) for which +// there is no problems, it is the caller responsibility to ensure the +// response body can be read several times and concurrently if needed, +// as it is shared among all [Responder] returned responses. +// +// For home-made responses, [NewRespBodyFromString] and +// [NewRespBodyFromBytes] functions can be used to produce response +// bodies that can be read several times and concurrently. +func ResponderFromResponse(resp *http.Response) Responder { + return func(req *http.Request) (*http.Response, error) { + res := *resp + + // Our stuff: generate a new io.ReadCloser instance sharing the same buffer + if body, ok := resp.Body.(*dummyReadCloser); ok { + res.Body = body.copy() + } + + res.Request = req + return &res, nil + } +} + +// ResponderFromMultipleResponses wraps an [*http.Response] list in a +// [Responder]. +// +// Each response will be returned in the order of the provided list. +// If the [Responder] is called more than the size of the provided +// list, an error will be thrown. +// +// Be careful, except for responses generated by httpmock +// ([NewStringResponse] and [NewBytesResponse] functions) for which +// there is no problems, it is the caller responsibility to ensure the +// response body can be read several times and concurrently if needed, +// as it is shared among all [Responder] returned responses. +// +// For home-made responses, [NewRespBodyFromString] and +// [NewRespBodyFromBytes] functions can be used to produce response +// bodies that can be read several times and concurrently. +// +// If all responses have been returned and fn is passed and non-nil, +// it acts as the fn parameter of [NewNotFoundResponder], allowing to +// dump the stack trace to localize the origin of the call. +// +// import ( +// "github.com/jarcoal/httpmock" +// "testing" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// // This responder is callable only once, then an error is returned and +// // the stacktrace of the call logged using t.Log() +// httpmock.RegisterResponder("GET", "/foo/bar", +// httpmock.ResponderFromMultipleResponses( +// []*http.Response{ +// httpmock.NewStringResponse(200, `{"name":"bar"}`), +// httpmock.NewStringResponse(404, `{"mesg":"Not found"}`), +// }, +// t.Log), +// ) +// } +// +// See also [Responder.Then]. +func ResponderFromMultipleResponses(responses []*http.Response, fn ...func(...any)) Responder { + responseIndex := 0 + mutex := sync.Mutex{} + return func(req *http.Request) (*http.Response, error) { + mutex.Lock() + defer mutex.Unlock() + defer func() { responseIndex++ }() + if responseIndex >= len(responses) { + err := internal.StackTracer{ + Err: fmt.Errorf("not enough responses provided: responder called %d time(s) but %d response(s) provided", responseIndex+1, len(responses)), + } + if len(fn) > 0 { + err.CustomFn = fn[0] + } + return nil, err + } + res := *responses[responseIndex] + // Our stuff: generate a new io.ReadCloser instance sharing the same buffer + if body, ok := responses[responseIndex].Body.(*dummyReadCloser); ok { + res.Body = body.copy() + } + + res.Request = req + return &res, nil + } +} + +// NewErrorResponder creates a [Responder] that returns an empty request and the +// given error. This can be used to e.g. imitate more deep http errors for the +// client. +func NewErrorResponder(err error) Responder { + return func(req *http.Request) (*http.Response, error) { + return nil, err + } +} + +// NewNotFoundResponder creates a [Responder] typically used in +// conjunction with [RegisterNoResponder] function and [testing] +// package, to be proactive when a [Responder] is not found. fn is +// called with a unique string parameter containing the name of the +// missing route and the stack trace to localize the origin of the +// call. If fn returns (= if it does not panic), the [Responder] returns +// an error of the form: "Responder not found for GET http://foo.bar/path". +// Note that fn can be nil. +// +// It is useful when writing tests to ensure that all routes have been +// mocked. +// +// Example of use: +// +// import ( +// "testing" +// "github.com/jarcoal/httpmock" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// // Calls testing.Fatal with the name of Responder-less route and +// // the stack trace of the call. +// httpmock.RegisterNoResponder(httpmock.NewNotFoundResponder(t.Fatal)) +// +// Will abort the current test and print something like: +// +// transport_test.go:735: Called from net/http.Get() +// at /go/src/github.com/jarcoal/httpmock/transport_test.go:714 +// github.com/jarcoal/httpmock.TestCheckStackTracer() +// at /go/src/testing/testing.go:865 +// testing.tRunner() +// at /go/src/runtime/asm_amd64.s:1337 +func NewNotFoundResponder(fn func(...any)) Responder { + return func(req *http.Request) (*http.Response, error) { + var extra string + suggested, _ := req.Context().Value(suggestedKey).(*suggestedInfo) + if suggested != nil { + 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, + Err: fmt.Errorf("Responder not found for %s %s%s", req.Method, req.URL, extra), + } + } +} + +// NewStringResponse creates an [*http.Response] with a body based on +// the given string. Also accepts an HTTP status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewStringResponse(200, httpmock.File("body.txt").String()) +func NewStringResponse(status int, body string) *http.Response { + return &http.Response{ + Status: strconv.Itoa(status), + StatusCode: status, + Body: NewRespBodyFromString(body), + Header: http.Header{}, + ContentLength: -1, + } +} + +// NewStringResponder creates a [Responder] from a given body (as a +// string) and status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewStringResponder(200, httpmock.File("body.txt").String()) +func NewStringResponder(status int, body string) Responder { + return ResponderFromResponse(NewStringResponse(status, body)) +} + +// NewBytesResponse creates an [*http.Response] with a body based on the +// given bytes. Also accepts an HTTP status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewBytesResponse(200, httpmock.File("body.raw").Bytes()) +func NewBytesResponse(status int, body []byte) *http.Response { + return &http.Response{ + Status: strconv.Itoa(status), + StatusCode: status, + Body: NewRespBodyFromBytes(body), + Header: http.Header{}, + ContentLength: -1, + } +} + +// NewBytesResponder creates a [Responder] from a given body (as a byte +// slice) and status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewBytesResponder(200, httpmock.File("body.raw").Bytes()) +func NewBytesResponder(status int, body []byte) Responder { + return ResponderFromResponse(NewBytesResponse(status, body)) +} + +// NewJsonResponse creates an [*http.Response] with a body that is a +// JSON encoded representation of the given any. Also accepts +// an HTTP status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewJsonResponse(200, httpmock.File("body.json")) +func NewJsonResponse(status int, body any) (*http.Response, error) { // nolint: revive + encoded, err := json.Marshal(body) + if err != nil { + return nil, err + } + response := NewBytesResponse(status, encoded) + response.Header.Set("Content-Type", "application/json") + return response, nil +} + +// NewJsonResponder creates a [Responder] from a given body (as an +// any that is encoded to JSON) and status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewJsonResponder(200, httpmock.File("body.json")) +func NewJsonResponder(status int, body any) (Responder, error) { // nolint: revive + resp, err := NewJsonResponse(status, body) + if err != nil { + return nil, err + } + return ResponderFromResponse(resp), nil +} + +// NewJsonResponderOrPanic is like [NewJsonResponder] but panics in +// case of error. +// +// It simplifies the call of [RegisterResponder], avoiding the use of a +// temporary variable and an error check, and so can be used as +// [NewStringResponder] or [NewBytesResponder] in such context: +// +// httpmock.RegisterResponder( +// "GET", +// "/test/path", +// httpmock.NewJsonResponderOrPanic(200, &MyBody), +// ) +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewJsonResponderOrPanic(200, httpmock.File("body.json")) +func NewJsonResponderOrPanic(status int, body any) Responder { // nolint: revive + responder, err := NewJsonResponder(status, body) + if err != nil { + panic(err) + } + return responder +} + +// NewXmlResponse creates an [*http.Response] with a body that is an +// XML encoded representation of the given any. Also accepts an HTTP +// status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewXmlResponse(200, httpmock.File("body.xml")) +func NewXmlResponse(status int, body any) (*http.Response, error) { // nolint: revive + var ( + encoded []byte + err error + ) + if f, ok := body.(File); ok { + encoded, err = f.bytes() + } else { + encoded, err = xml.Marshal(body) + } + if err != nil { + return nil, err + } + response := NewBytesResponse(status, encoded) + response.Header.Set("Content-Type", "application/xml") + return response, nil +} + +// NewXmlResponder creates a [Responder] from a given body (as an +// any that is encoded to XML) and status code. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewXmlResponder(200, httpmock.File("body.xml")) +func NewXmlResponder(status int, body any) (Responder, error) { // nolint: revive + resp, err := NewXmlResponse(status, body) + if err != nil { + return nil, err + } + return ResponderFromResponse(resp), nil +} + +// NewXmlResponderOrPanic is like [NewXmlResponder] but panics in case +// of error. +// +// It simplifies the call of [RegisterResponder], avoiding the use of a +// temporary variable and an error check, and so can be used as +// [NewStringResponder] or [NewBytesResponder] in such context: +// +// httpmock.RegisterResponder( +// "GET", +// "/test/path", +// httpmock.NewXmlResponderOrPanic(200, &MyBody), +// ) +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewXmlResponderOrPanic(200, httpmock.File("body.xml")) +func NewXmlResponderOrPanic(status int, body any) Responder { // nolint: revive + responder, err := NewXmlResponder(status, body) + if err != nil { + panic(err) + } + return responder +} + +// NewRespBodyFromString creates an [io.ReadCloser] from a string that +// is suitable for use as an HTTP response body. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewRespBodyFromString(httpmock.File("body.txt").String()) +func NewRespBodyFromString(body string) io.ReadCloser { + return &dummyReadCloser{orig: body} +} + +// NewRespBodyFromBytes creates an [io.ReadCloser] from a byte slice +// that is suitable for use as an HTTP response body. +// +// To pass the content of an existing file as body use [File] as in: +// +// httpmock.NewRespBodyFromBytes(httpmock.File("body.txt").Bytes()) +func NewRespBodyFromBytes(body []byte) io.ReadCloser { + return &dummyReadCloser{orig: body} +} + +type lenReadSeeker interface { + io.ReadSeeker + Len() int +} + +type dummyReadCloser struct { + orig any // string or []byte + body lenReadSeeker // instanciated on demand from orig +} + +// copy returns a new instance resetting d.body to nil. +func (d *dummyReadCloser) copy() *dummyReadCloser { + return &dummyReadCloser{orig: d.orig} +} + +// setup ensures d.body is correctly initialized. +func (d *dummyReadCloser) setup() { + if d.body == nil { + switch body := d.orig.(type) { + case string: + d.body = strings.NewReader(body) + case []byte: + d.body = bytes.NewReader(body) + case io.ReadCloser: + var buf bytes.Buffer + io.Copy(&buf, body) //nolint: errcheck + body.Close() + d.body = bytes.NewReader(buf.Bytes()) + } + } +} + +func (d *dummyReadCloser) Read(p []byte) (n int, err error) { + d.setup() + return d.body.Read(p) +} + +func (d *dummyReadCloser) Close() error { + d.setup() + d.body.Seek(0, io.SeekEnd) // nolint: errcheck + return nil +} + +func (d *dummyReadCloser) Len() int { + d.setup() + return d.body.Len() +} diff --git a/vendor/github.com/jarcoal/httpmock/transport.go b/vendor/github.com/jarcoal/httpmock/transport.go new file mode 100644 index 000000000..64b8535c9 --- /dev/null +++ b/vendor/github.com/jarcoal/httpmock/transport.go @@ -0,0 +1,1867 @@ +package httpmock + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + + "github.com/jarcoal/httpmock/internal" +) + +const regexpPrefix = "=~" + +// NoResponderFound is returned when no responders are found for a +// given HTTP method and URL. +var NoResponderFound = internal.NoResponderFound + +var stdMethods = map[string]bool{ + "CONNECT": true, // Section 9.9 + "DELETE": true, // Section 9.7 + "GET": true, // Section 9.3 + "HEAD": true, // Section 9.4 + "OPTIONS": true, // Section 9.2 + "POST": true, // Section 9.5 + "PUT": true, // Section 9.6 + "TRACE": true, // Section 9.8 +} + +// methodProbablyWrong returns true if method has probably wrong case. +func methodProbablyWrong(method string) bool { + return !stdMethods[method] && stdMethods[strings.ToUpper(method)] +} + +// ConnectionFailure is a responder that returns a connection failure. +// This is the default responder and is called when no other matching +// responder is found. See [RegisterNoResponder] to override this +// default behavior. +func ConnectionFailure(*http.Request) (*http.Response, error) { + return nil, NoResponderFound +} + +// NewMockTransport creates a new [*MockTransport] with no responders. +func NewMockTransport() *MockTransport { + return &MockTransport{ + responders: make(map[internal.RouteKey]matchResponders), + callCountInfo: make(map[matchRouteKey]int), + } +} + +type regexpResponder struct { + origRx string + method string + rx *regexp.Regexp + responders matchResponders +} + +// MockTransport implements [http.RoundTripper] interface, which +// fulfills single HTTP requests issued by an [http.Client]. This +// implementation doesn't actually make the call, instead deferring to +// the registered list of responders. +type MockTransport struct { + // DontCheckMethod disables standard methods check. By default, if + // a responder is registered using a lower-cased method among CONNECT, + // DELETE, GET, HEAD, OPTIONS, POST, PUT and TRACE, a panic occurs + // as it is probably a mistake. + DontCheckMethod bool + mu sync.RWMutex + responders map[internal.RouteKey]matchResponders + regexpResponders []regexpResponder + noResponder Responder + callCountInfo map[matchRouteKey]int + totalCallCount int +} + +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{ + Method: method, + } + + 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 + found = getResponders(m, key) + if found.responders != nil { + break + } + + // 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) + found = getResponders(m, key) + if found.responders != nil { + break + } + } + + // if we weren't able to find some responders, try without any query params + strippedURL := *url + strippedURL.RawQuery = "" + strippedURL.Fragment = "" + + // go1.6 does not handle URL.ForceQuery, so in case it is set in go>1.6, + // remove the "?" manually if present. + surl := strings.TrimSuffix(strippedURL.String(), "?") + + hasQueryString := urlStr != surl + + // if the URL contains a querystring then we strip off the + // querystring and try again: http://z.tld/path + if hasQueryString { + key.URL = surl + found = getResponders(m, key) + if found.responders != nil { + break + } + } + + // if we weren't able to find some responders for the full URL, try with + // the path part only + pathAlone := url.RawPath + if pathAlone == "" { + pathAlone = url.Path + } + + // First with unsorted querystring: /path?q... + if hasQueryString { + key.URL = pathAlone + strings.TrimPrefix(urlStr, surl) // concat after-path part + found = getResponders(m, key) + if found.responders != nil { + break + } + + // Then with sorted querystring: /path?sorted_q... + key.URL = pathAlone + "?" + sortedQuery(url.Query()) + if url.Fragment != "" { + key.URL += "#" + url.Fragment + } + found = getResponders(m, key) + if found.responders != nil { + break + } + } + + // Then using path alone: /path + key.URL = pathAlone + 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 +// [*http.Client] you are using will call it for you. +func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + method := req.Method + if method == "" { + // http.Request.Method is documented to default to GET: + method = http.MethodGet + } + + 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 + } + 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), + } + } + } + + // No Matcher found for exact match, retry for regexp match + if findIdx < len(findForKey)-1 { + fromFindIdx = findIdx + 1 + continue + } + break + } + + // 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++ + m.mu.Unlock() + break + } + + 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 + } + m.mu.Unlock() + } + + if responder == nil { + if suggested != nil { + return nil, suggested + } + return ConnectionFailure(req) + } + 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. +// The responder registered with [MockTransport.RegisterNoResponder] +// is not taken into account. +func (m *MockTransport) NumResponders() int { + m.mu.RLock() + defer m.mu.RUnlock() + return m.numResponders() +} + +// Responders returns the list of currently registered responders. +// Each responder is listed as a string containing "METHOD URL". +// Non-regexp responders are listed first in alphabetical order +// (sorted by URL then METHOD), then regexp responders in the order +// they have been registered. +// +// The responder registered with [MockTransport.RegisterNoResponder] +// is not listed. +func (m *MockTransport) Responders() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + rks := make([]internal.RouteKey, 0, len(m.responders)) + for rk := range m.responders { + rks = append(rks, rk) + } + sort.Slice(rks, func(i, j int) bool { + if rks[i].URL == rks[j].URL { + return rks[i].Method < rks[j].Method + } + return rks[i].URL < rks[j].URL + }) + + rs := make([]string, 0, m.numResponders()) + for _, rk := range rks { + for _, mr := range m.responders[rk] { + rs = append(rs, matchRouteKey{ + RouteKey: rk, + name: mr.matcher.name, + }.String()) + } + } + for _, rr := range m.regexpResponders { + 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 +} + +func runCancelable(responder Responder, req *http.Request) (*http.Response, error) { + ctx := req.Context() + if req.Cancel == nil && ctx.Done() == nil { // nolint: staticcheck + resp, err := responder(req) + return resp, internal.CheckStackTracer(req, err) + } + + // Set up a goroutine that translates a close(req.Cancel) into a + // "request canceled" error, and another one that runs the + // responder. Then race them: first to the result channel wins. + + type result struct { + response *http.Response + err error + } + resultch := make(chan result, 1) + done := make(chan struct{}, 1) + + go func() { + select { + case <-req.Cancel: // nolint: staticcheck + resultch <- result{ + response: nil, + err: errors.New("request canceled"), + } + case <-ctx.Done(): + resultch <- result{ + response: nil, + err: ctx.Err(), + } + case <-done: + } + }() + + go func() { + defer func() { + if err := recover(); err != nil { + resultch <- result{ + response: nil, + err: fmt.Errorf("panic in responder: got %q", err), + } + } + }() + + response, err := responder(req) + resultch <- result{ + response: response, + err: err, + } + }() + + r := <-resultch + + // if a cancel() issued from context.WithCancel() or a + // close(req.Cancel) are never coming, we'll need to unblock the + // first goroutine. + done <- struct{}{} + + return r.response, internal.CheckStackTracer(req, r.err) +} + +// 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 respondersFound{ + responders: m.responders[key], + respKey: key, + } + } + + for k, resp := range m.responders { + if key.URL == k.URL { + return respondersFound{ + responders: resp, + respKey: k, + } + } + } + return respondersFound{} +} + +// respondersForKeyUsingRegexp returns the first responder matching a +// given key using regexps. +func (m *MockTransport) regexpRespondersForKey(key internal.RouteKey) respondersFound { + m.mu.RLock() + defer m.mu.RUnlock() + for _, regInfo := range m.regexpResponders { + if key.Method == "" || regInfo.method == key.Method { + if sm := regInfo.rx.FindStringSubmatch(key.URL); sm != nil { + if len(sm) == 1 { + sm = nil + } else { + sm = sm[1:] + } + return respondersFound{ + responders: regInfo.responders, + respKey: internal.RouteKey{ + Method: regInfo.method, + URL: regInfo.origRx, + }, + submatches: sm, + } + } + } + } + return respondersFound{} +} + +func isRegexpURL(url string) bool { + return strings.HasPrefix(url, regexpPrefix) +} + +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), + method, + )) + } +} + +// 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 +// [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. 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 [MockTransport.RegisterRegexpMatcherResponder] to directly pass a +// [*regexp.Regexp]. +// +// 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. +// +// 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) { + rr := regexpResponder{ + origRx: url, + method: method, + rx: regexp.MustCompile(url[2:]), + responders: matchResponders{mr}, + } + m.registerRegexpResponder(rr) + return + } + + key := internal.RouteKey{ + Method: method, + URL: url, + } + + m.mu.Lock() + if responder == nil { + 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] = 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 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 { + rr.responders = rr.responders.add(mr) + m.regexpResponders[i] = rr + } + break found + } + } + if mr.responder != nil { + m.regexpResponders = append(m.regexpResponders, rxResp) + } + break // nolint: staticcheck + } + + mrk := matchRouteKey{ + RouteKey: internal.RouteKey{ + Method: rxResp.method, + URL: rxResp.origRx, + }, + name: mr.matcher.name, + } + if mr.responder == nil { + delete(m.callCountInfo, mrk) + } else { + 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. +// +// 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) +// replaces its responder, but does not change its position, and +// 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.MockTransportGetCallCountInfo]. It does nothing if +// it does not already exist. +// +// A "=~" prefix is added to the stringified regexp in the statistics +// returned by [MockTransport.GetCallCountInfo]. +// +// See [MockTransport.RegisterResponder] 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.RegisterRegexpMatcherResponder] to also match on +// request header and/or body. +func (m *MockTransport) RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder Responder) { + m.RegisterRegexpMatcherResponder(method, urlRegexp, Matcher{}, responder) +} + +// RegisterMatcherResponderWithQuery is same as +// [MockTransport.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 [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]. +// +// 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) +// +// 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. +// +// 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`) + } + + var mapQuery url.Values + switch q := query.(type) { + case url.Values: + mapQuery = q + + case map[string]string: + mapQuery = make(url.Values, len(q)) + for key, e := range q { + mapQuery[key] = []string{e} + } + + case string: + var err error + mapQuery, err = url.ParseQuery(q) + if err != nil { + panic("RegisterResponderWithQuery bad query string: " + err.Error()) + } + + default: + if query != nil { + panic(fmt.Sprintf("RegisterResponderWithQuery bad query type %T. Only url.Values, map[string]string and string are allowed", query)) + } + } + + if queryString := sortedQuery(mapQuery); queryString != "" { + path += "?" + queryString + } + 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 { + if len(m) == 0 { + return "" + } + + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var b bytes.Buffer + var values []string // nolint: prealloc + + for _, k := range keys { + // Do not alter the passed url.Values + values = append(values, m[k]...) + sort.Strings(values) + + k = url.QueryEscape(k) + + for _, v := range values { + if b.Len() > 0 { + b.WriteByte('&') + } + fmt.Fprintf(&b, "%v=%v", k, url.QueryEscape(v)) + } + + values = values[:0] + } + + return b.String() +} + +// RegisterNoResponder is used to register a responder that is called +// if no other responders are found. The default is [ConnectionFailure] +// that returns a connection error. +// +// Use it in conjunction with [NewNotFoundResponder] to ensure that all +// routes have been mocked: +// +// func TestMyApp(t *testing.T) { +// ... +// // Calls testing.Fatal with the name of Responder-less route and +// // the stack trace of the call. +// mock.RegisterNoResponder(httpmock.NewNotFoundResponder(t.Fatal)) +// +// will abort the current test and print something like: +// +// transport_test.go:735: Called from net/http.Get() +// at /go/src/github.com/jarcoal/httpmock/transport_test.go:714 +// github.com/jarcoal/httpmock.TestCheckStackTracer() +// at /go/src/testing/testing.go:865 +// testing.tRunner() +// at /go/src/runtime/asm_amd64.s:1337 +// +// If responder is passed as nil, the default behavior +// ([ConnectionFailure]) is re-enabled. +// +// In some cases you may not want all URLs to be mocked, in which case +// you can do this: +// +// func TestFetchArticles(t *testing.T) { +// ... +// mock.RegisterNoResponder(httpmock.InitialTransport.RoundTrip) +// +// // any requests that don't have a registered URL will be fetched normally +// } +func (m *MockTransport) RegisterNoResponder(responder Responder) { + m.mu.Lock() + m.noResponder = responder + m.mu.Unlock() +} + +// Reset removes all registered responders (including the no +// responder) from the [MockTransport]. It zeroes call counters too. +func (m *MockTransport) Reset() { + m.mu.Lock() + m.responders = make(map[internal.RouteKey]matchResponders) + m.regexpResponders = nil + m.noResponder = nil + m.callCountInfo = make(map[matchRouteKey]int) + m.totalCallCount = 0 + m.mu.Unlock() +} + +// ZeroCallCounters zeroes call counters without touching registered responders. +func (m *MockTransport) ZeroCallCounters() { + m.mu.Lock() + for k := range m.callCountInfo { + m.callCountInfo[k] = 0 + } + m.totalCallCount = 0 + m.mu.Unlock() +} + +// GetCallCountInfo gets the info on all the calls m has caught +// since it was activated or reset. The info is returned as a map of +// the calling keys with the number of calls made to them as their +// value. The key is the method, a space, and the URL all concatenated +// together. +// +// As a special case, regexp responders generate 2 entries for each +// call. One for the call caught and the other for the rule that +// matched. For example: +// +// RegisterResponder("GET", `=~z\.com\z`, NewStringResponder(200, "body")) +// http.Get("http://z.com") +// +// will generate the following result: +// +// map[string]int{ +// `GET http://z.com`: 1, +// `GET =~z\.com\z`: 1, +// } +func (m *MockTransport) GetCallCountInfo() map[string]int { + m.mu.RLock() + res := make(map[string]int, len(m.callCountInfo)) + for k, v := range m.callCountInfo { + res[k.String()] = v + } + m.mu.RUnlock() + return res +} + +// GetTotalCallCount gets the total number of calls m has taken +// since it was activated or reset. +func (m *MockTransport) GetTotalCallCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return m.totalCallCount +} + +// DefaultTransport is the default mock transport used by [Activate], +// [Deactivate], [Reset], [DeactivateAndReset], [RegisterResponder], +// [RegisterRegexpResponder], [RegisterResponderWithQuery] and +// [RegisterNoResponder]. +var DefaultTransport = NewMockTransport() + +// InitialTransport is a cache of the original transport used so we +// can put it back when [Deactivate] is called. +var InitialTransport = http.DefaultTransport + +// oldClients is used to handle custom http clients (i.e clients other +// than http.DefaultClient). +var oldClients = map[*http.Client]http.RoundTripper{} + +// oldClientsLock protects oldClients from concurrent writes. +var oldClientsLock sync.Mutex + +// Activate starts the mock environment. This should be called before +// your tests run. Under the hood this replaces the [http.Client.Transport] +// field of [http.DefaultClient] with [DefaultTransport]. +// +// To enable mocks for a test, simply activate at the beginning of a test: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// // all http requests using http.DefaultTransport will now be intercepted +// } +// +// If you want all of your tests in a package to be mocked, just call +// [Activate] from init(): +// +// func init() { +// httpmock.Activate() +// } +// +// or using a TestMain function: +// +// func TestMain(m *testing.M) { +// httpmock.Activate() +// os.Exit(m.Run()) +// } +func Activate() { + if Disabled() { + return + } + + // make sure that if Activate is called multiple times it doesn't + // overwrite the InitialTransport with a mock transport. + if http.DefaultTransport != DefaultTransport { + InitialTransport = http.DefaultTransport + } + + http.DefaultTransport = DefaultTransport +} + +// ActivateNonDefault starts the mock environment with a non-default +// [*http.Client]. This emulates the [Activate] function, but allows for +// custom clients that do not use [http.DefaultTransport]. +// +// To enable mocks for a test using a custom client, activate at the +// beginning of a test: +// +// client := &http.Client{Transport: &http.Transport{TLSHandshakeTimeout: 60 * time.Second}} +// httpmock.ActivateNonDefault(client) +func ActivateNonDefault(client *http.Client) { + if Disabled() { + return + } + + // save the custom client & it's RoundTripper + oldClientsLock.Lock() + defer oldClientsLock.Unlock() + if _, ok := oldClients[client]; !ok { + oldClients[client] = client.Transport + } + client.Transport = DefaultTransport +} + +// GetCallCountInfo gets the info on all the calls httpmock has caught +// since it was activated or reset. The info is returned as a map of +// the calling keys with the number of calls made to them as their +// value. The key is the method, a space, and the URL all concatenated +// together. +// +// As a special case, regexp responders generate 2 entries for each +// call. One for the call caught and the other for the rule that +// matched. For example: +// +// RegisterResponder("GET", `=~z\.com\z`, NewStringResponder(200, "body")) +// http.Get("http://z.com") +// +// will generate the following result: +// +// map[string]int{ +// `GET http://z.com`: 1, +// `GET =~z\.com\z`: 1, +// } +func GetCallCountInfo() map[string]int { + return DefaultTransport.GetCallCountInfo() +} + +// GetTotalCallCount gets the total number of calls httpmock has taken +// since it was activated or reset. +func GetTotalCallCount() int { + return DefaultTransport.GetTotalCallCount() +} + +// Deactivate shuts down the mock environment. Any HTTP calls made +// after this will use a live transport. +// +// Usually you'll call it in a defer right after activating the mock +// environment: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// defer httpmock.Deactivate() +// +// // when this test ends, the mock environment will close +// } +// +// Since go 1.14 you can also use [*testing.T.Cleanup] method as in: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// t.Cleanup(httpmock.Deactivate) +// +// // when this test ends, the mock environment will close +// } +// +// useful in test helpers to save your callers from calling defer themselves. +func Deactivate() { + if Disabled() { + return + } + http.DefaultTransport = InitialTransport + + // reset the custom clients to use their original RoundTripper + oldClientsLock.Lock() + defer oldClientsLock.Unlock() + for oldClient, oldTransport := range oldClients { + oldClient.Transport = oldTransport + delete(oldClients, oldClient) + } +} + +// Reset removes any registered mocks and returns the mock +// environment to its initial state. It zeroes call counters too. +func Reset() { + DefaultTransport.Reset() +} + +// ZeroCallCounters zeroes call counters without touching registered responders. +func ZeroCallCounters() { + DefaultTransport.ZeroCallCounters() +} + +// DeactivateAndReset is just a convenience method for calling +// [Deactivate] and then [Reset]. +// +// Happy deferring! +func DeactivateAndReset() { + Deactivate() + 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). +// +// 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. +// +// See [RegisterRegexpResponder] to directly pass a *regexp.Regexp. +// +// Example: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// defer httpmock.DeactivateAndReset() +// +// httpmock.RegisterResponder("GET", "http://example.com/", +// httpmock.NewStringResponder(200, "hello world")) +// +// httpmock.RegisterResponder("GET", "/path/only", +// httpmock.NewStringResponder(200, "any host hello world")) +// +// httpmock.RegisterResponder("GET", `=~^/item/id/\d+\z`, +// 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" +// // requests to any host with path matching ^/item/id/\d+\z regular expression return "any item get" +// } +// +// 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 +// [DefaultTransport].DontCheckMethod to true prior to this call. +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. +// +// 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) +// replaces its responder, but does not change its position, and +// 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. +// +// A "=~" prefix is added to the stringified regexp in the statistics +// returned by [GetCallCountInfo]. +// +// See [RegisterResponder] 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 +// DefaultTransport.DontCheckMethod to true prior to this call. +func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder 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. +// +// query 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 [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 [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. +// +// Example using a [url.Values]: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// defer httpmock.DeactivateAndReset() +// +// expectedQuery := net.Values{ +// "a": []string{"3", "1", "8"}, +// "b": []string{"4", "2"}, +// } +// httpmock.RegisterResponderWithQueryValues( +// "GET", "http://example.com/", expectedQuery, +// 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 +// // now return 'hello world' +// } +// +// or using a map[string]string: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// defer httpmock.DeactivateAndReset() +// +// expectedQuery := map[string]string{ +// "a": "1", +// "b": "2" +// } +// httpmock.RegisterResponderWithQuery( +// "GET", "http://example.com/", expectedQuery, +// 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' +// } +// +// or using a query string: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// defer httpmock.DeactivateAndReset() +// +// expectedQuery := "a=3&b=4&b=2&a=1&a=8" +// httpmock.RegisterResponderWithQueryValues( +// "GET", "http://example.com/", expectedQuery, +// 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 +// // now return 'hello world' +// } +// +// 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 +// DefaultTransport.DontCheckMethod to true prior to this call. +func RegisterResponderWithQuery(method, path string, query any, responder Responder) { + RegisterMatcherResponderWithQuery(method, path, query, Matcher{}, responder) +} + +// RegisterNoResponder is used to register a responder that is called +// if no other responders are found. The default is [ConnectionFailure] +// that returns a connection error. +// +// Use it in conjunction with [NewNotFoundResponder] to ensure that all +// routes have been mocked: +// +// import ( +// "testing" +// "github.com/jarcoal/httpmock" +// ) +// ... +// func TestMyApp(t *testing.T) { +// ... +// // Calls testing.Fatal with the name of Responder-less route and +// // the stack trace of the call. +// httpmock.RegisterNoResponder(httpmock.NewNotFoundResponder(t.Fatal)) +// +// will abort the current test and print something like: +// +// transport_test.go:735: Called from net/http.Get() +// at /go/src/github.com/jarcoal/httpmock/transport_test.go:714 +// github.com/jarcoal/httpmock.TestCheckStackTracer() +// at /go/src/testing/testing.go:865 +// testing.tRunner() +// at /go/src/runtime/asm_amd64.s:1337 +// +// If responder is passed as nil, the default behavior +// ([ConnectionFailure]) is re-enabled. +// +// In some cases you may not want all URLs to be mocked, in which case +// you can do this: +// +// func TestFetchArticles(t *testing.T) { +// ... +// httpmock.RegisterNoResponder(httpmock.InitialTransport.RoundTrip) +// +// // any requests that don't have a registered URL will be fetched normally +// } +func RegisterNoResponder(responder Responder) { + DefaultTransport.RegisterNoResponder(responder) +} + +// ErrSubmatchNotFound is the error returned by GetSubmatch* functions +// when the given submatch index cannot be found. +var ErrSubmatchNotFound = errors.New("submatch not found") + +// GetSubmatch has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as a string. Example: +// +// RegisterResponder("GET", `=~^/item/name/([^/]+)\z`, +// func(req *http.Request) (*http.Response, error) { +// name, err := GetSubmatch(req, 1) // 1=first regexp submatch +// if err != nil { +// return nil, err +// } +// return NewJsonResponse(200, map[string]any{ +// "id": 123, +// "name": name, +// }) +// }) +// +// It panics if n < 1. See [MustGetSubmatch] to avoid testing the +// returned error. +func GetSubmatch(req *http.Request, n int) (string, error) { + if n <= 0 { + panic(fmt.Sprintf("getting submatches starts at 1, not %d", n)) + } + n-- + + submatches := internal.GetSubmatches(req) + if n >= len(submatches) { + return "", ErrSubmatchNotFound + } + return submatches[n], nil +} + +// GetSubmatchAsInt has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as an int64. Example: +// +// RegisterResponder("GET", `=~^/item/id/(\d+)\z`, +// func(req *http.Request) (*http.Response, error) { +// id, err := GetSubmatchAsInt(req, 1) // 1=first regexp submatch +// if err != nil { +// return nil, err +// } +// return NewJsonResponse(200, map[string]any{ +// "id": id, +// "name": "The beautiful name", +// }) +// }) +// +// It panics if n < 1. See [MustGetSubmatchAsInt] to avoid testing the +// returned error. +func GetSubmatchAsInt(req *http.Request, n int) (int64, error) { + sm, err := GetSubmatch(req, n) + if err != nil { + return 0, err + } + return strconv.ParseInt(sm, 10, 64) +} + +// GetSubmatchAsUint has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as a uint64. Example: +// +// RegisterResponder("GET", `=~^/item/id/(\d+)\z`, +// func(req *http.Request) (*http.Response, error) { +// id, err := GetSubmatchAsUint(req, 1) // 1=first regexp submatch +// if err != nil { +// return nil, err +// } +// return NewJsonResponse(200, map[string]any{ +// "id": id, +// "name": "The beautiful name", +// }) +// }) +// +// It panics if n < 1. See [MustGetSubmatchAsUint] to avoid testing the +// returned error. +func GetSubmatchAsUint(req *http.Request, n int) (uint64, error) { + sm, err := GetSubmatch(req, n) + if err != nil { + return 0, err + } + return strconv.ParseUint(sm, 10, 64) +} + +// GetSubmatchAsFloat has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as a float64. Example: +// +// RegisterResponder("PATCH", `=~^/item/id/\d+\?height=(\d+(?:\.\d*)?)\z`, +// func(req *http.Request) (*http.Response, error) { +// height, err := GetSubmatchAsFloat(req, 1) // 1=first regexp submatch +// if err != nil { +// return nil, err +// } +// return NewJsonResponse(200, map[string]any{ +// "id": id, +// "name": "The beautiful name", +// "height": height, +// }) +// }) +// +// It panics if n < 1. See [MustGetSubmatchAsFloat] to avoid testing the +// returned error. +func GetSubmatchAsFloat(req *http.Request, n int) (float64, error) { + sm, err := GetSubmatch(req, n) + if err != nil { + return 0, err + } + return strconv.ParseFloat(sm, 64) +} + +// MustGetSubmatch works as [GetSubmatch] except that it panics in +// case of error (submatch not found). It has to be used in Responders +// installed by [RegisterRegexpResponder] or [RegisterResponder] + +// "=~" URL prefix (as well as [MockTransport.RegisterRegexpResponder] +// or [MockTransport.RegisterResponder]). It allows to retrieve the +// n-th submatch of the matching regexp, as a string. Example: +// +// RegisterResponder("GET", `=~^/item/name/([^/]+)\z`, +// func(req *http.Request) (*http.Response, error) { +// name := MustGetSubmatch(req, 1) // 1=first regexp submatch +// return NewJsonResponse(200, map[string]any{ +// "id": 123, +// "name": name, +// }) +// }) +// +// It panics if n < 1. +func MustGetSubmatch(req *http.Request, n int) string { + s, err := GetSubmatch(req, n) + if err != nil { + panic("GetSubmatch failed: " + err.Error()) + } + return s +} + +// MustGetSubmatchAsInt works as [GetSubmatchAsInt] except that it +// panics in case of error (submatch not found or invalid int64 +// format). It has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as an int64. Example: +// +// RegisterResponder("GET", `=~^/item/id/(\d+)\z`, +// func(req *http.Request) (*http.Response, error) { +// id := MustGetSubmatchAsInt(req, 1) // 1=first regexp submatch +// return NewJsonResponse(200, map[string]any{ +// "id": id, +// "name": "The beautiful name", +// }) +// }) +// +// It panics if n < 1. +func MustGetSubmatchAsInt(req *http.Request, n int) int64 { + i, err := GetSubmatchAsInt(req, n) + if err != nil { + panic("GetSubmatchAsInt failed: " + err.Error()) + } + return i +} + +// MustGetSubmatchAsUint works as [GetSubmatchAsUint] except that it +// panics in case of error (submatch not found or invalid uint64 +// format). It has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as a uint64. Example: +// +// RegisterResponder("GET", `=~^/item/id/(\d+)\z`, +// func(req *http.Request) (*http.Response, error) { +// id, err := MustGetSubmatchAsUint(req, 1) // 1=first regexp submatch +// return NewJsonResponse(200, map[string]any{ +// "id": id, +// "name": "The beautiful name", +// }) +// }) +// +// It panics if n < 1. +func MustGetSubmatchAsUint(req *http.Request, n int) uint64 { + u, err := GetSubmatchAsUint(req, n) + if err != nil { + panic("GetSubmatchAsUint failed: " + err.Error()) + } + return u +} + +// MustGetSubmatchAsFloat works as [GetSubmatchAsFloat] except that it +// panics in case of error (submatch not found or invalid float64 +// format). It has to be used in Responders installed by +// [RegisterRegexpResponder] or [RegisterResponder] + "=~" URL prefix +// (as well as [MockTransport.RegisterRegexpResponder] or +// [MockTransport.RegisterResponder]). It allows to retrieve the n-th +// submatch of the matching regexp, as a float64. Example: +// +// RegisterResponder("PATCH", `=~^/item/id/\d+\?height=(\d+(?:\.\d*)?)\z`, +// func(req *http.Request) (*http.Response, error) { +// height := MustGetSubmatchAsFloat(req, 1) // 1=first regexp submatch +// return NewJsonResponse(200, map[string]any{ +// "id": id, +// "name": "The beautiful name", +// "height": height, +// }) +// }) +// +// It panics if n < 1. +func MustGetSubmatchAsFloat(req *http.Request, n int) float64 { + f, err := GetSubmatchAsFloat(req, n) + if err != nil { + panic("GetSubmatchAsFloat failed: " + err.Error()) + } + return f +} diff --git a/vendor/modules.txt b/vendor/modules.txt index ee9426c44..c7d590a60 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -539,6 +539,10 @@ github.com/jaegertracing/jaeger/model github.com/jaegertracing/jaeger/model/converter/thrift/zipkin github.com/jaegertracing/jaeger/thrift-gen/jaeger github.com/jaegertracing/jaeger/thrift-gen/zipkincore +# github.com/jarcoal/httpmock v1.3.1 +## explicit; go 1.18 +github.com/jarcoal/httpmock +github.com/jarcoal/httpmock/internal # github.com/jcmturner/aescts/v2 v2.0.0 ## explicit; go 1.13 github.com/jcmturner/aescts/v2 From 1a3c9a2bd0c0339bbd10753f6fc0f8bc11acc2de Mon Sep 17 00:00:00 2001 From: Matt Cotter Date: Thu, 24 Oct 2024 12:22:20 -0500 Subject: [PATCH 3/5] add test for helper as well --- .../commands/sendtestdata/postdata_test.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/commands/sendtestdata/postdata_test.go b/internal/commands/sendtestdata/postdata_test.go index e0e7365cf..3408c98e2 100644 --- a/internal/commands/sendtestdata/postdata_test.go +++ b/internal/commands/sendtestdata/postdata_test.go @@ -12,6 +12,28 @@ func TestPostTestData(t *testing.T) { httpmock.Activate() t.Cleanup(httpmock.DeactivateAndReset) + testURL := "https://example.com/test" + expectedResponse := `{"ok":true}` + // Verify that the data is sent to the expected endpoint along with the bearer and json headers. + httpmock.RegisterMatcherResponder("POST", testURL, + httpmock.BodyContainsString(`"hello":"world"`).And( + httpmock.HeaderIs("Content-Type", "application/json"), + httpmock.HeaderIs("SomeHeader", "some value"), + ), + httpmock.NewStringResponder(200, expectedResponse), + ) + + testData := map[string]string{"hello": "world"} + testHeaders := map[string]string{"SomeHeader": "some value"} + resp, err := PostTestData(testData, testURL, testHeaders) + assert.NoError(t, err) + assert.Equal(t, expectedResponse, resp) +} + +func TestPostTestDataToObserve(t *testing.T) { + httpmock.Activate() + t.Cleanup(httpmock.DeactivateAndReset) + expectedResponse := `{"ok":true}` // Verify that the data is sent to the expected endpoint along with the bearer and json headers. httpmock.RegisterMatcherResponder("POST", "https://123456.collect.observe-eng.com/v1/http/test", From 7190956cdeee3e065d38949ef214a4472cc796ea Mon Sep 17 00:00:00 2001 From: Matt Cotter Date: Thu, 24 Oct 2024 15:22:47 -0500 Subject: [PATCH 4/5] pass viper to function for cleaner testing --- internal/commands/sendtestdata/postdata.go | 8 ++++---- internal/commands/sendtestdata/postdata_test.go | 7 ++++--- internal/commands/sendtestdata/sendtestdata.go | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/commands/sendtestdata/postdata.go b/internal/commands/sendtestdata/postdata.go index 7c050d68c..cfa780df3 100644 --- a/internal/commands/sendtestdata/postdata.go +++ b/internal/commands/sendtestdata/postdata.go @@ -40,9 +40,9 @@ func PostTestData(data any, URL string, headers map[string]string) (string, erro return bodyString, nil } -func PostTestDataToObserve(data any, path string) (string, error) { - collector_url := viper.GetString("observe_url") - endpoint := fmt.Sprintf("%s/v1/http/%s", strings.TrimRight(collector_url, "/"), strings.TrimLeft(path, "/")) - authToken := fmt.Sprintf("Bearer %s", viper.GetString("token")) +func PostTestDataToObserve(data any, path string, v *viper.Viper) (string, error) { + collector_url := v.GetString("observe_url") + endpoint := fmt.Sprintf("%s/v1/http%s", strings.TrimRight(collector_url, "/"), path) + authToken := fmt.Sprintf("Bearer %s", v.GetString("token")) return PostTestData(data, endpoint, map[string]string{"Authorization": authToken}) } diff --git a/internal/commands/sendtestdata/postdata_test.go b/internal/commands/sendtestdata/postdata_test.go index 3408c98e2..4a64bcd0e 100644 --- a/internal/commands/sendtestdata/postdata_test.go +++ b/internal/commands/sendtestdata/postdata_test.go @@ -44,10 +44,11 @@ func TestPostTestDataToObserve(t *testing.T) { httpmock.NewStringResponder(200, expectedResponse), ) - viper.Set("observe_url", "https://123456.collect.observe-eng.com/") - viper.Set("token", "test-token") + v := viper.New() + v.Set("observe_url", "https://123456.collect.observe-eng.com/") + v.Set("token", "test-token") testData := map[string]string{"hello": "world"} - resp, err := PostTestDataToObserve(testData, "/test") + resp, err := PostTestDataToObserve(testData, "/test", v) assert.NoError(t, err) assert.Equal(t, expectedResponse, resp) } diff --git a/internal/commands/sendtestdata/sendtestdata.go b/internal/commands/sendtestdata/sendtestdata.go index 6f7946077..1689147f5 100644 --- a/internal/commands/sendtestdata/sendtestdata.go +++ b/internal/commands/sendtestdata/sendtestdata.go @@ -9,6 +9,7 @@ import ( "github.com/observeinc/observe-agent/internal/root" "github.com/spf13/cobra" + "github.com/spf13/viper" ) const TestDataPath = "/observe-agent/test" @@ -33,7 +34,7 @@ func NewSendTestDataCmd() *cobra.Command { } else { testData = defaultTestData } - respBody, err := PostTestDataToObserve(testData, TestDataPath) + respBody, err := PostTestDataToObserve(testData, TestDataPath, viper.GetViper()) if err != nil { return err } From 7a5c1ffcd621feae0ab08f306144635f01028a73 Mon Sep 17 00:00:00 2001 From: Matt Cotter Date: Mon, 28 Oct 2024 16:26:25 -0500 Subject: [PATCH 5/5] rename variables for clarity --- internal/commands/sendtestdata/postdata.go | 4 ++-- internal/commands/sendtestdata/postdata_test.go | 4 ++-- internal/commands/sendtestdata/sendtestdata.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/commands/sendtestdata/postdata.go b/internal/commands/sendtestdata/postdata.go index cfa780df3..250c63b9d 100644 --- a/internal/commands/sendtestdata/postdata.go +++ b/internal/commands/sendtestdata/postdata.go @@ -40,9 +40,9 @@ func PostTestData(data any, URL string, headers map[string]string) (string, erro return bodyString, nil } -func PostTestDataToObserve(data any, path string, v *viper.Viper) (string, error) { +func PostDataToObserve(data any, extraPath string, v *viper.Viper) (string, error) { collector_url := v.GetString("observe_url") - endpoint := fmt.Sprintf("%s/v1/http%s", strings.TrimRight(collector_url, "/"), path) + endpoint := fmt.Sprintf("%s/v1/http%s", strings.TrimRight(collector_url, "/"), extraPath) authToken := fmt.Sprintf("Bearer %s", v.GetString("token")) return PostTestData(data, endpoint, map[string]string{"Authorization": authToken}) } diff --git a/internal/commands/sendtestdata/postdata_test.go b/internal/commands/sendtestdata/postdata_test.go index 4a64bcd0e..cceb4f3eb 100644 --- a/internal/commands/sendtestdata/postdata_test.go +++ b/internal/commands/sendtestdata/postdata_test.go @@ -30,7 +30,7 @@ func TestPostTestData(t *testing.T) { assert.Equal(t, expectedResponse, resp) } -func TestPostTestDataToObserve(t *testing.T) { +func TestPostDataToObserve(t *testing.T) { httpmock.Activate() t.Cleanup(httpmock.DeactivateAndReset) @@ -48,7 +48,7 @@ func TestPostTestDataToObserve(t *testing.T) { v.Set("observe_url", "https://123456.collect.observe-eng.com/") v.Set("token", "test-token") testData := map[string]string{"hello": "world"} - resp, err := PostTestDataToObserve(testData, "/test", v) + resp, err := PostDataToObserve(testData, "/test", v) assert.NoError(t, err) assert.Equal(t, expectedResponse, resp) } diff --git a/internal/commands/sendtestdata/sendtestdata.go b/internal/commands/sendtestdata/sendtestdata.go index 1689147f5..de1199c96 100644 --- a/internal/commands/sendtestdata/sendtestdata.go +++ b/internal/commands/sendtestdata/sendtestdata.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/viper" ) -const TestDataPath = "/observe-agent/test" +const TestDataExtraPath = "/observe-agent/test" var defaultTestData = map[string]any{ "hello": "world", @@ -34,7 +34,7 @@ func NewSendTestDataCmd() *cobra.Command { } else { testData = defaultTestData } - respBody, err := PostTestDataToObserve(testData, TestDataPath, viper.GetViper()) + respBody, err := PostDataToObserve(testData, TestDataExtraPath, viper.GetViper()) if err != nil { return err }