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.
- Mock: Replay(ias) answers each request from the recording it matches, and fails any other without touching the network. A client generated by openapi-codegen takes it through its WithHTTPClient option.
- Record: Replay(ias, RecordMisses(nil)) sends what has no recording, and Recorder.Interactions holds the calls to write with Interactions.WriteFile.
- Test strictly: InOrder and CompareHeaders check every call against its recording in turn, and Recorder.Unused tells what was never called.
Writing to a file always masks credentials first, see Interactions.Mask.
Index ¶
- Constants
- Variables
- func AddInteraction(path string, ia *Interaction) error
- func IsMasked(s string) bool
- func IsText(contentType string) bool
- func RedactsBodyKey(key string) bool
- type Body
- type Interaction
- type Interactions
- type MatchOption
- type Matcher
- type Option
- type Recorder
- type Request
- type Response
Examples ¶
Constants ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
MarshalJSONTo implements json.MarshalerTo.
func (*Body) UnmarshalJSONFrom ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Create creates a corresponding *http.Request.
func (Request) IsScaffold ¶
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 ¶
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.