cassette

package module
v0.0.0-...-3bee9a1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: Apache-2.0 Imports: 22 Imported by: 2

README

Go Reference Code Coverage License

Mock any Go HTTP client with the calls it really made.

Package cassette records HTTP calls as interactions, stored as JSON, and replays them. One http.RoundTripper, cassette.Replay, does both: it mocks a client in tests and records its real traffic, so there is no need to write one of your own.

Introduction

A cassette is an interactions.json file: a list of requests, each with the response it got. Hand it to cassette.Replay and any client that takes an *http.Client gets those responses, without touching the network.

The package depends on nothing but the standard library and jsonutil, so a library can depend on it, and offer it to its users, at no cost.

Features

  • Mock a client in tests with cassette.Replay(ias): each request is answered from the recording it matches, by method, URL with its query in any order, and body, compared as JSON. Any other request fails with cassette.ErrNoInteraction, without touching the network.
  • Record with cassette.Replay(ias, cassette.RecordMisses(nil)): a request without a recording is sent and recorded, and Recorder.Interactions holds every call, ready for Interactions.WriteFile.
  • Test strictly with cassette.InOrder() and cassette.CompareHeaders(...): every call must match the next recording, headers included, and Recorder.Unused tells which recordings were never called.
  • Masking: writing a file always masks credentials first, in headers, bodies and query values, as Interactions.Mask does.
  • Once per call: AddInteraction writes a call to a file only if it is not in it yet, so a failure that recurs is recorded once.
  • Large bodies: a body that is not text is never read or written, only marked bodyOmitted, so a download streams to its caller as it is. A JSON string longer than 2,048 bytes, such as an image in base64, is cut to that.
  • Stable files: query parameters are sorted by name, so a recording does not change with the order a client happens to send them in.

Usage

Mock a client in a test:

ias, err := cassette.InteractionsReadFile("testdata/interactions.json")
if err != nil {
	t.Fatal(err)
}

client := &http.Client{Transport: cassette.Replay(ias)}

A client generated by openapi-codegen takes it as WithHTTPClient(&http.Client{Transport: cassette.Replay(ias)}).

Record what has no recording yet, and save it:

rec := cassette.Replay(ias, cassette.RecordMisses(nil))
client := &http.Client{Transport: rec}

// … make the calls …

if err := rec.Interactions().WriteFile("testdata/interactions.json"); err != nil {
	return err
}

Check every call against its recording, in order and with its headers:

rec := cassette.Replay(ias, cassette.InOrder(), cassette.WithMatcher(
	cassette.MatchRequest(cassette.CompareHeaders("User-Agent")),
))

t.Cleanup(func() {
	if unused := rec.Unused(); len(unused) > 0 {
		t.Errorf("%d recordings were never replayed", len(unused))
	}
})

The package examples run each of these.

Where it comes from

cassette started inside openapi-enrich, which turns recorded traffic into an OpenAPI specification, and openapi-codegen generates clients whose tests replay it. It moved into a module of its own so that those clients, and anyone else, can record and replay without depending on either.

Additional Information

Contributing

Contributions are welcome — please open an issue or a pull request on GitHub.

License

This project is licensed under the Apache 2.0 License.

Documentation

Overview

Package cassette records HTTP calls as Interactions, stored as JSON, and replays them.

To mock a client in tests, or to record its calls, use Replay: it is the http.RoundTripper for both, and there is no need to write one of your own.

Writing to a file always masks credentials first, see Interactions.Mask.

Index

Examples

Constants

View Source
const MaxStringLen = 2048

MaxStringLen is the most bytes a JSON string in a recorded body keeps: a longer one, such as a whole image in base64, is cut to it and ends in "…". It is long enough for every URL seen so far, presigned ones included.

Variables

View Source
var ErrNoInteraction = errors.New("no recorded interaction")

ErrNoInteraction is returned, wrapped, for a request no recorded interaction answers.

Functions

func AddInteraction

func AddInteraction(path string, ia *Interaction) error

AddInteraction adds ia to the interactions in the file at path, masked as Interactions.Mask does and with its bodies trimmed as Interactions.TrimBodies does with MaxStringLen, writing the file right away; ia is left as it is. A call the file holds already, the same request answered with the same status and body, is not added again: a client retrying a call, or polling one, records it once.

func IsMasked

func IsMasked(s string) bool

IsMasked reports whether s is a placeholder produced by masking.

Such a value carries no information a reader does not already have: the inferred format says "uuid" far more usefully than an example of "00000000-0000-0000-0000-000000000000" does. Callers building documentation from a masked recording can use this to leave the example out entirely.

func IsText

func IsText(contentType string) bool

IsText reports whether a body of the media type contentType is text, and so worth recording: JSON, XML, a form or text/*. Any other, such as a zip, a PDF, an image or a video, is recorded only as omitted. A body without a media type is treated as text.

func RedactsBodyKey

func RedactsBodyKey(key string) bool

RedactsBodyKey reports whether Interactions.Mask redacts values held by key.

A masked string announces itself — the zero UUID and the asterisk run are recognisable by IsMasked — but a masked number does not: a redacted count becomes 0, which is indistinguishable from a genuine 0. The key is the only remaining signal, so callers documenting a masked recording can ask about it directly rather than guessing from the value.

Types

type Body

type Body []byte

Body is a recorded body: written as the JSON it is, or as a string if it is no JSON.

func (Body) IsZero

func (b Body) IsZero() bool

IsZero reports whether there is no body, which is left out when written: an empty JSON value, such as null or {}, is a body all the same.

func (Body) MarshalJSONTo

func (b Body) MarshalJSONTo(enc *jsontext.Encoder) error

MarshalJSONTo implements json.MarshalerTo.

func (*Body) UnmarshalJSONFrom

func (b *Body) UnmarshalJSONFrom(dec *jsontext.Decoder) error

UnmarshalJSONFrom implements json.UnmarshalerFrom.

type Interaction

type Interaction struct {
	Request  Request  `json:"request"`
	Response Response `json:"response,omitzero"`
}

Interaction represents a single observed HTTP request/response pair.

type Interactions

type Interactions []*Interaction

Interactions represents a collection of interactions.

func InteractionsReadFile

func InteractionsReadFile(path string) (Interactions, error)

InteractionsReadFile reads the interactions in the JSON file at path.

func (Interactions) MarshalWrite

func (ias Interactions) MarshalWrite(w io.Writer) error

MarshalWrite writes the interactions as JSON to w, as they are: unlike Interactions.WriteFile, it does not mask them.

func (Interactions) Mask

func (ias Interactions) Mask()

Mask redacts credentials in place: in headers, in bodies and in query values.

func (Interactions) TrimBodies

func (ias Interactions) TrimBodies(maxLen int)

TrimBodies omits every recorded body that is not text, see IsText, and cuts every JSON string in the others longer than maxLen bytes, see MaxStringLen.

func (Interactions) TrimResponseHeaders

func (ias Interactions) TrimResponseHeaders()

TrimResponseHeaders removes response headers we don't need in place.

func (Interactions) WriteFile

func (ias Interactions) WriteFile(path string) error

WriteFile writes the interactions as JSON to the file at path, masked as Interactions.Mask does; ias are left as they are.

type MatchOption

type MatchOption func(*matcher)

MatchOption configures MatchRequest.

func CompareHeaders

func CompareHeaders(ignore ...string) MatchOption

CompareHeaders makes MatchRequest compare headers too, all but those named, exactly. Authorization is compared by its scheme alone, since a recording masks the credential after it, and a Content-Type recorded without a body is left out: it says nothing of a request that has none.

func OptionalAuthorization

func OptionalAuthorization(optional func(*http.Request) bool) MatchOption

OptionalAuthorization makes MatchRequest match a recording without Authorization to a request that has one where optional reports true for it: a call whose credentials are optional may be sent with them though it was recorded without.

type Matcher

type Matcher func(req *http.Request, rec Request) error

Matcher reports why the request req does not match the recorded request rec, or nil if it does.

func MatchRequest

func MatchRequest(opts ...MatchOption) Matcher

MatchRequest returns the Matcher a Recorder uses unless told otherwise: a request matches a recording with the same method, the same URL, its query parameters in any order, and the same body, compared as JSON where both are, so that the order of members does not matter. Headers are not compared, unless CompareHeaders says so.

type Option

type Option func(*Recorder)

Option configures a Recorder.

func InOrder

func InOrder() Option

InOrder makes a Recorder answer with its recordings in their order, each once: a request that does not match the next one fails, saying why.

Example

Test strictly, as openapi-codegen's generated tests do: each call must be the next recording, headers included but the User-Agent, and every recording must be called.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/url"

	"github.com/MarkRosemaker/cassette"
)

// interactions stand in for those read with cassette.InteractionsReadFile("api/interactions.json").
var interactions = cassette.Interactions{{
	Request: cassette.Request{
		Method: http.MethodGet,
		URL:    url.URL{Scheme: "https", Host: "api.example.com", Path: "/users/me"},
	},
	Response: cassette.Response{
		StatusCode: http.StatusOK,
		Headers:    http.Header{"Content-Type": {"application/json"}},
		Body:       cassette.Body(`{"name":"Alice"}`),
	},
}}

func main() {
	rec := cassette.Replay(interactions, cassette.InOrder(),
		cassette.WithMatcher(cassette.MatchRequest(cassette.CompareHeaders("User-Agent"))))
	client := &http.Client{Transport: rec}

	_, err := get(client, "https://api.example.com/users/42")
	fmt.Println(err)
	fmt.Println(len(rec.Unused()), "never called")

}

// get sends a GET request for u with client.
func get(client *http.Client, u string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}
Output:
Get "https://api.example.com/users/42": interaction #0: got URL https://api.example.com/users/42, want https://api.example.com/users/me
1 never called

func RecordMisses

func RecordMisses(rt http.RoundTripper) Option

RecordMisses makes a Recorder send a request it has no recording for through rt, http.DefaultTransport if nil, and record it, instead of failing. With no recordings to start from, it records every request.

func WithMatcher

func WithMatcher(m Matcher) Option

WithMatcher makes a Recorder match requests to recordings with m instead of MatchRequest().

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder is an http.RoundTripper that answers requests from recorded interactions, and, with RecordMisses, sends and records those it has none for. Make one with Replay. It is safe for concurrent use.

func Replay

func Replay(ias Interactions, opts ...Option) *Recorder

Replay returns a Recorder that answers each request from the first of ias it matches (see MatchRequest) that has not answered yet, or, once all have, the last of them again. A request none of ias matches fails with an error wrapping ErrNoInteraction; nothing reaches the network. Interactions without a response, scaffolds, are left out.

Example

Mock a client in tests: requests are answered from the recordings, and nothing reaches the network. A client generated by openapi-codegen takes it as WithHTTPClient(&http.Client{Transport: cassette.Replay(ias)}).

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/url"

	"github.com/MarkRosemaker/cassette"
)

// interactions stand in for those read with cassette.InteractionsReadFile("api/interactions.json").
var interactions = cassette.Interactions{{
	Request: cassette.Request{
		Method: http.MethodGet,
		URL:    url.URL{Scheme: "https", Host: "api.example.com", Path: "/users/me"},
	},
	Response: cassette.Response{
		StatusCode: http.StatusOK,
		Headers:    http.Header{"Content-Type": {"application/json"}},
		Body:       cassette.Body(`{"name":"Alice"}`),
	},
}}

func main() {
	client := &http.Client{Transport: cassette.Replay(interactions)}

	rsp, err := get(client, "https://api.example.com/users/me")
	if err != nil {
		panic(err)
	}
	defer rsp.Body.Close()

	body, _ := io.ReadAll(rsp.Body)
	fmt.Println(rsp.StatusCode, string(body))

	_, err = get(client, "https://api.example.com/users/42")
	fmt.Println(err)

}

// get sends a GET request for u with client.
func get(client *http.Client, u string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}
Output:
200 {"name":"Alice"}
Get "https://api.example.com/users/42": GET https://api.example.com/users/42: no recorded interaction
Example (Record)

Record calls: what has no recording is sent, here to a stand-in for the API, and recorded beside what was replayed. Write them with rec.Interactions().WriteFile("api/interactions.json"), which masks credentials first.

api := roundTripFunc(func(*http.Request) (*http.Response, error) {
	return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
})

rec := cassette.Replay(interactions, cassette.RecordMisses(api))
client := &http.Client{Transport: rec}

for _, u := range []string{"https://api.example.com/users/me", "https://api.example.com/ping"} {
	rsp, err := get(client, u)
	if err != nil {
		panic(err)
	}

	rsp.Body.Close()
}

for _, ia := range rec.Interactions() {
	fmt.Println(ia.Request.Method, &ia.Request.URL, ia.Response.StatusCode)
}
Output:
GET https://api.example.com/users/me 200
GET https://api.example.com/ping 204

func (*Recorder) Interactions

func (r *Recorder) Interactions() Interactions

Interactions are the interactions the recorder answered with or recorded, in the order of the calls.

func (*Recorder) RoundTrip

func (r *Recorder) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*Recorder) Unused

func (r *Recorder) Unused() Interactions

Unused are the recordings that have not answered any request.

type Request

type Request struct {
	Method string `json:"method"`
	// URL is the URL requested, its query parameters sorted by name. A scaffold, a request yet to
	// be made, has none.
	URL     url.URL     `json:"url"`
	Headers http.Header `json:"header,omitempty"`
	Body    Body        `json:"body,omitzero"`
	// BodyOmitted is set for a body that was not text, see [IsText], which is not recorded.
	BodyOmitted bool `json:"bodyOmitted,omitzero"`
}

Request represents an observed HTTP request.

func NewRequest

func NewRequest(req *http.Request) (Request, error)

NewRequest creates a new Request out of an *http.Request. If the request has a body of text, it is drained and restored; any other is left as it is, and recorded as omitted.

func (Request) Create

func (r Request) Create(ctx context.Context) (*http.Request, error)

Create creates a corresponding *http.Request.

func (Request) IsScaffold

func (r Request) IsScaffold() bool

IsScaffold reports whether r is a request yet to be made: one without a URL.

type Response

type Response struct {
	StatusCode int         `json:"statusCode"`
	Headers    http.Header `json:"header,omitempty"`
	Body       Body        `json:"body,omitzero"`
	// BodyOmitted is set for a body that was not text, see [IsText], which is not recorded.
	BodyOmitted bool `json:"bodyOmitted,omitzero"`
}

Response represents an observed HTTP response.

func NewResponse

func NewResponse(resp *http.Response) (Response, error)

NewResponse creates a new Response out of an *http.Response. If the response has a body of text, it is drained and restored; any other, such as a zip, is left to stream as it is, and recorded as omitted.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL