Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches:
- "**"
pull_request:

permissions:
contents: read
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@
.vscode/
*.swp
*.swo

testdata/tmp/
75 changes: 73 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions helper.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Expand Down
35 changes: 34 additions & 1 deletion helper_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package httpmatter

import (
"errors"
"fmt"
"io"
"net/http"
"os"
Expand Down Expand Up @@ -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)
}
6 changes: 3 additions & 3 deletions http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions matter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
15 changes: 15 additions & 0 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
15 changes: 15 additions & 0 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
2 changes: 1 addition & 1 deletion template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down