diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7fb663..b1794fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: - "**" - pull_request: permissions: contents: read diff --git a/.gitignore b/.gitignore index 7263d58..4b2299b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ .vscode/ *.swp *.swo + +testdata/tmp/ diff --git a/README.md b/README.md index a01b550..536a9a0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # httpmatter +![Build Status](https://github.com/therewardstore/httpmatter/actions/workflows/ci.yml/badge.svg) File-backed HTTP request/response fixtures with a thin wrapper on top of `github.com/jarcoal/httpmock`. @@ -69,12 +70,14 @@ import ( "github.com/therewardstore/httpmatter" ) -func TestSomething(t *testing.T) { +func init() { _ = httpmatter.Init(&httpmatter.Config{ BaseDir: filepath.Join("testdata"), FileExtension: ".http", }) +} +func TestSomething(t *testing.T) { resp, err := httpmatter.Response("basic", "response_with_header") if err != nil { t.Fatal(err) @@ -88,17 +91,85 @@ func TestSomething(t *testing.T) { } ``` +### Load and execute a request fixture + +Load a `.http` fixture, substitute variables, and execute it using a standard `http.Client`. + +```go +func TestRequestFixture(t *testing.T) { + reqMatter, err := httpmatter.Request( + "advanced", + "create_order", + httpmatter.WithVariables(map[string]any{ + "ProductID": 123, + "token": "secret-token", + }), + ) + if err != nil { + t.Fatal(err) + } + + client := &http.Client{} + resp, err := client.Do(reqMatter.Request) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} +``` + +### Capture and save a response fixture + +Capture a real `*http.Response` and save it to a fixture file. This is useful for recording real API responses to use as future mocks. + +```go +func TestRecordResponse(t *testing.T) { + // Initialize the response matter. If the file doesn't exist yet, + // Response() returns ErrReadingFile which we can ignore when recording. + respMatter, err := httpmatter.Response("tmp", "recorded_api_response") + if err != nil && !errors.Is(err, httpmatter.ErrReadingFile()) { + t.Fatal(err) + } + + // Make a real request using standard http.Client + resp, err := http.Get("https://httpbin.org/json") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Capture the response content into the matter + if err := respMatter.Dump(resp); err != nil { + t.Fatal(err) + } + + // Save to testdata/tmp/recorded_api_response.http + if err := respMatter.Save(); err != nil { + t.Fatal(err) + } +} +``` + ### Mock outgoing HTTP calls (global) This library uses `httpmock.Activate()` / `httpmock.DeactivateAndReset()`, which is **global within the current process**. - Avoid `t.Parallel()` in tests that use `(*HTTP).Init()`. ```go -func TestVendorFlow(t *testing.T) { + +func init() { _ = httpmatter.Init(&httpmatter.Config{ BaseDir: filepath.Join("testdata"), + FileExtension: ".http", }) +} +func TestVendorFlow(t *testing.T) { h := httpmatter.NewHTTP(t, "basic"). Add("request_with_prompts_and_vars", "response_with_header"). Respond(nil) diff --git a/config.go b/config.go index d62478a..025105d 100644 --- a/config.go +++ b/config.go @@ -9,9 +9,14 @@ type Config struct { FileExtension string EnvFileName string EnvFileExtension string + DisableLogs bool TemplateConverter func(content string) string } +func (c *Config) copy() Config { + return *c +} + func Init(conf *Config) error { if conf.BaseDir == "" { return fmt.Errorf("base dir is required") diff --git a/helper.go b/helper.go index 51f7f6c..a760833 100644 --- a/helper.go +++ b/helper.go @@ -1,7 +1,7 @@ package httpmatter -// Matterer is a generic interface that can be used to create a matter -type Matterer interface { +// matterer is a generic interface that can be used to create a matter +type matterer interface { WithOptions(opts ...Option) error Read() error Parse() error @@ -20,7 +20,7 @@ func Response(namespace, name string, opts ...Option) (*ResponseMatter, error) { } // makeMatter makes a matter with the given options -func makeMatter(matter Matterer, opts ...Option) error { +func makeMatter(matter matterer, opts ...Option) error { if err := matter.WithOptions(opts...); err != nil { return err } diff --git a/helper_test.go b/helper_test.go index 5f676db..024f42f 100644 --- a/helper_test.go +++ b/helper_test.go @@ -1,6 +1,8 @@ package httpmatter import ( + "errors" + "fmt" "io" "net/http" "os" @@ -99,5 +101,36 @@ func TestMockWithPromptsAndVars(t *testing.T) { must.Equal("*", resp.Header.Get("Access-Control-Allow-Origin")) body, err = io.ReadAll(resp.Body) must.NoError(err) - must.GreaterOrEqual(len(body), 687) + must.GreaterOrEqual(len(body), 686) +} + +func TestResponseOverride(t *testing.T) { + must := require.New(t) + randomName := fmt.Sprintf("file_should_not_exists_%d", time.Now().UnixNano()) + mock, err := Response("tmp", randomName) + must.True(errors.Is(err, ErrReadingFile())) + must.Nil(mock.Response) + must.Equal(randomName, mock.Name) + must.Equal("tmp", mock.Namespace) + // Now make a actual request and get response + client := &http.Client{} + resp, err := client.Get("https://httpbin.org/get?name=JohnDoe&for=" + randomName) + must.NoError(err) + must.Equal(200, resp.StatusCode) + + // Should set the content + must.NoError(mock.Dump(resp)) + // Should save the content to the same file + must.NoError(mock.Save()) + + mock2, err := Response("tmp", randomName) + must.NoError(err) + must.NotNil(mock2.Response) + must.Equal(200, mock2.Response.StatusCode) + must.Equal("application/json", mock2.Response.Header.Get("Content-Type")) + must.Equal("*", mock2.Response.Header.Get("Access-Control-Allow-Origin")) + body, err := mock2.BodyBytes() + must.NoError(err) + must.GreaterOrEqual(len(body), 100) + must.Contains(string(body), randomName) } diff --git a/http.go b/http.go index 44fbd53..515f517 100644 --- a/http.go +++ b/http.go @@ -18,13 +18,13 @@ type trip struct { } type HTTP struct { - t *testing.T + t testing.TB namespaces []string trip *trip trips []*trip } -func NewHTTP(t *testing.T, namespaces ...string) *HTTP { +func NewHTTP(t testing.TB, namespaces ...string) *HTTP { return &HTTP{ t: t, namespaces: namespaces, @@ -53,7 +53,7 @@ func (h *HTTP) Init() { } for key, group := range groups { - h.t.Log("Registering responder for ", key, "with", len(group), "trips") + h.t.Logf("Registering responder for %s with %d trips", key, len(group)) httpmock.RegisterResponder( // Because group always have same Method and URL, // we can use the first trip to get the method and url diff --git a/matter.go b/matter.go index 33eebd8..c1fb87c 100644 --- a/matter.go +++ b/matter.go @@ -2,13 +2,15 @@ package httpmatter import ( "bufio" + "os" + "path/filepath" "strings" "testing" ) // Matter is a generic matter that can be used to store content and error type Matter struct { - config *Config + config Config front string content string Namespace string @@ -19,7 +21,7 @@ type Matter struct { func NewMatter(namespace, name string) *Matter { return &Matter{ - config: config, + config: config.copy(), Namespace: namespace, Name: name, Vars: make(map[string]any), @@ -48,7 +50,7 @@ func (m *Matter) Read() error { // first read the .dot env file m.readDotEnv() m.ifTB(func(tb testing.TB) { - tb.Log("Reading file", m.filePath(), "for", m.Namespace, m.Name) + tb.Logf("Reading file %s for %s/%s", m.filePath(), m.Namespace, m.Name) }) front, content, err := readFile(m.filePath()) if err != nil { @@ -112,3 +114,12 @@ func (m *Matter) ifTB(fn func(tb testing.TB)) { } fn(m.tb) } + +func (m *Matter) Save() error { + filePath := m.filePath() + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(filePath, []byte(m.front+m.content), 0644) +} diff --git a/request.go b/request.go index f7b1376..90e02fb 100644 --- a/request.go +++ b/request.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "net/http" + "net/http/httputil" ) // RequestMatter is a matter that can be used to store request content and error @@ -49,3 +50,17 @@ func (rm *RequestMatter) BodyBytes() ([]byte, error) { rm.Body = io.NopCloser(bytes.NewReader(body)) return body, nil } + +func (rm *RequestMatter) Dump(req *http.Request) error { + b, err := httputil.DumpRequest(req, true) + if err != nil { + return err + } + rm.content = string(b) + rm.Request = req + return nil +} + +func (rm *RequestMatter) Save() error { + return rm.Matter.Save() +} diff --git a/response.go b/response.go index bbdda75..0ddc0ee 100644 --- a/response.go +++ b/response.go @@ -3,6 +3,7 @@ package httpmatter import ( "io" "net/http" + "net/http/httputil" ) // ResponseMatter is a matter that can be used to store response content and error @@ -44,3 +45,17 @@ func (rm *ResponseMatter) BodyBytes() ([]byte, error) { } return body, nil } + +func (rm *ResponseMatter) Dump(resp *http.Response) error { + b, err := httputil.DumpResponse(resp, true) + if err != nil { + return err + } + rm.content = string(b) + rm.Response = resp + return nil +} + +func (rm *ResponseMatter) Save() error { + return rm.Matter.Save() +} diff --git a/template_test.go b/template_test.go index 6d9f2f6..b869c83 100644 --- a/template_test.go +++ b/template_test.go @@ -26,7 +26,7 @@ func TestExecuteTemplate(t *testing.T) { not a {{ index .Vars "number" }}, not a {{ index .Vars "date" }}` matter := &Matter{ - config: &Config{}, + config: Config{}, Vars: map[string]any{ "who": "John", "thing": "table",