Skip to content

Commit

Permalink
Address several TODOs - some compatibility breaking changes (#65)
Browse files Browse the repository at this point in the history
* Address several TODOs

* Changes to use of predicates with mutators

* Updates tests and pass current request to replaying mutator
  • Loading branch information
seborama authored Aug 8, 2022
1 parent 4160004 commit a5c2dc1
Show file tree
Hide file tree
Showing 25 changed files with 556 additions and 486 deletions.
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ deps:
cover:
go test -timeout 20s -cover ./... -coverprofile coverage.out -coverpkg ./...
go tool cover -func coverage.out -o coverage.out
cat coverage.out

@echo -e "\n"
@cat coverage.out

test: deps cover
# to run a single test inside a stretchr suite (e.g.):
Expand Down
102 changes: 81 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ We use a "relaxed" request matcher because `example.com` injects an "`Age`" head
## Install

```bash
go get github.com/seborama/govcr/v6@latest
go get github.com/seborama/govcr/v7@latest
```

For all available releases, please check the [releases](https://github.com/seborama/govcr/releases) tab on github.

And your source code would use this import:

```go
import "github.com/seborama/govcr/v6"
import "github.com/seborama/govcr/v7"
```

For versions of **govcr** before v5 (which don't use go.mod), use a dependency manager to lock the version you wish to use (perhaps v4)!
Expand All @@ -62,7 +62,7 @@ go get gopkg.in/seborama/govcr.v4

**ControlPanel**: the creation of a VCR instantiates a ControlPanel for interacting with the VCR and conceal its internals.

## Documentation
## Concepts

**govcr** is a wrapper around the Go `http.Client`. It can record live HTTP traffic to files (called "**cassettes**") and later replay HTTP requests ("**tracks**") from them instead of live HTTP calls.

Expand Down Expand Up @@ -93,7 +93,7 @@ Settings are populated via `With*` options:
- `WithCassette` loads the specified cassette.\
Note that it is also possible to call `LoadCassette` from the vcr instance.
- See `vcrsettings.go` for more options such as `WithRequestMatcher`, `WithTrackRecordingMutators`, `WithTrackReplayingMutators`, ...
- TODO in v5: `WithLogging` enables logging to help understand what govcr is doing internally.
- TODO: `WithLogging` enables logging to help understand what govcr is doing internally.

## Match a request to a cassette track

Expand All @@ -117,14 +117,20 @@ Nonetheless, **govcr** supports mutating tracks, either at **recording time** or

In either case, this is achieved with track `Mutators`.

A `Mutator` can be combined with one or more `On` conditions. All `On` conditions attached to a mutator must be true for the mutator to apply. The predicate 'Any' provides an alternative and will only require one of its conditions to be true. `Any` can be combined with an `On` conditional mutator to achieve a logical `Or` within the `On` condition.
A `Mutator` can be combined with one or more `On` conditions. All `On` conditions attached to a mutator must be true for the mutator to apply - in other word, they are logically "and-ed".

To help construct more complex yet readable predicates easily, **govcr** provides these pre-defined functions for use with `On`:
- `Any` achieves a logical "**or**" of the provided predicates.
- `All` achieves a logical "**and**" of the provided predicates.
- `Not` achieves a logical "**not**" of the provided predicates.

Example:

```go
myMutator.
On(Any(...)).
On(...)
On(Any(...)). // proceeds if any of the "`...`" predicates is true
On(Not(Any(...))) // proceeds if none of the "`...`" predicates is true (i.e. all predicates are false)
On(Not(All(...))). // proceeds if not every of the "`...`" predicates is true (i.e. at least one predicate is false)
```

A **track recording mutator** can change both the request and the response that will be persisted to the cassette.
Expand All @@ -133,6 +139,8 @@ A **track replaying mutator** transforms the track after it was matched and retr

While a track replaying mutator could change the request, it serves no purpose since the request has already been made and matched to a track by the time the replaying mutator is invoked. The reason for supplying the request in the replaying mutator is for information. In some situations, the request details are needed to transform the response.

The **track replaying mutator** additionally receives an informational copy of the current HTTP request in the track's `Response` under the `Request` field i.e. `Track.Response.Request`. This is useful for tailoring track replays with current request information. See TestExample3 for illustration.

Refer to the tests for examples (search for `WithTrackRecordingMutators` and `WithTrackReplayingMutators`).

## Cookbook
Expand Down Expand Up @@ -224,22 +232,34 @@ vcr.AddReplayingMutators(track.ResponseDeleteTLS())

**govcr** support operation modes:

- Live only: never replay from the cassette.
- Read only: normal behaviour except that recording to cassette is disabled.
- Offline: playback from cassette only, return a transport error if no track matches.
- **Normal HTTP mode**: replay from the cassette if a track matches otherwise place a live call.
- **Live only**: never replay from the cassette.
- **Offline**: playback from cassette only, return a transport error if no track matches.
- **Read only**: normal behaviour except that recording to cassette is disabled.

#### Live only
#### Normal HTTP mode

```go
vcr := govcr.NewVCR(
govcr.WithCassette(exampleCassetteName2),
// Normal mode is default, no special option required :)
)
// or equally:
vcr.SetNormalMode()
```

#### Live only HTTP mode

```go
vcr := govcr.NewVCR(
govcr.WithCassette(exampleCassetteName2),
govcr.WithLiveOnlyMode(),
)
// or equally:
vcr.SetLiveOnlyMode(true) // `false` to disable option
vcr.SetLiveOnlyMode()
```

#### Read only
#### Read only cassette mode

```go
vcr := govcr.NewVCR(
Expand All @@ -250,15 +270,15 @@ vcr := govcr.NewVCR(
vcr.SetReadOnlyMode(true) // `false` to disable option
```

#### Offline
#### Offline HTTP mode

```go
vcr := govcr.NewVCR(
govcr.WithCassette(exampleCassetteName2),
govcr.WithOfflineMode(),
)
// or equally:
vcr.SetOfflineMode(true) // `false` to disable option
vcr.SetOfflineMode()
```

### Recipe: VCR with a custom RequestFilter
Expand All @@ -279,22 +299,21 @@ vcr.SetRequestMatcher(NewBlankRequestMatcher(
httpRequest.Header.Del("X-Custom-Timestamp")
trackRequest.Header.Del("X-Custom-Timestamp")

return DefaultHeaderMatcher(httpRequest, trackRequest)
return govcr.DefaultHeaderMatcher(httpRequest, trackRequest)
},
),
))
```

### Recipe: VCR with a recoding Track Mutator
### Recipe: VCR with a recording Track Mutator

**TODO: THIS EXAMPLE FOR v4 NOT v5**

This example shows how to handle situations where a transaction Id in the header needs to be present in the response.
This example shows how to handle a situation where a request-specific transaction ID in the header needs to be present in the response.

This could be as part of a contract validation between server and client.

Note: This is useful when some of the data in the **request** Header / Body needs to be transformed
before it can be evaluated for comparison for playback.
Note: This is useful when some of the data in the **request** Header / Body needs to be transformed before it can be evaluated for comparison for playback.

```go
package main
Expand All @@ -306,7 +325,7 @@ import (

"net/http"

"github.com/seborama/govcr/v6"
"github.com/seborama/govcr/v7"
)

const example5CassetteName = "MyCassette5"
Expand Down Expand Up @@ -358,6 +377,47 @@ func Example5() {

### Recipe: VCR with a replaying Track Mutator

In this scenario, the API requires a "`X-Transaction-Id`" header to be present. Since the header changes per-request, as needed, replaying a track poses two concerns:
- the request won't match the previously recorded track because the value in "`X-Transaction-Id`" has changed since the track was recorded
- the response track contains the original values of "`X-Transaction-Id`" which is also a mis-match for the new request.

One of different solutions to address both concerns consists in:
- providing a custom request matcher that ignores "`X-Transaction-Id`"
- using the help of a replaying track mutator to inject the correct value for "`X-Transaction-Id`" from the current HTTP request.

How you specifically tackle this in practice really depends on how the API you are using behaves.

```go
// See TestExample3 for complete working example.
func TestExample3(t *testing.T) {
// Instantiate VCR.
vcr := govcr.NewVCR(
govcr.WithCassette(exampleCassetteName3),
govcr.WithRequestMatcher(
govcr.NewBlankRequestMatcher(
govcr.WithRequestMatcherFunc(
func(httpRequest, trackRequest *track.Request) bool {
// Remove the header from comparison.
// Note: this removal is only scoped to the request matcher, it does not affect the original HTTP request
httpRequest.Header.Del("X-Transaction-Id")
trackRequest.Header.Del("X-Transaction-Id")

return govcr.DefaultHeaderMatcher(httpRequest, trackRequest)
},
),
),
),
govcr.WithTrackReplayingMutators(
// Note: although we deleted the headers in the request matcher, this was limited to the scope of
// the request matcher. The replaying mutator's scope is past request matching.
track.ResponseDeleteHeaderKeys("X-Transaction-Id"), // do not append to existing values
track.ResponseTransferHTTPHeaderKeys("X-Transaction-Id"),
),
)
```
### More
**TODO: add example that includes the use of `.On*` predicates**
## Stats
Expand Down
13 changes: 6 additions & 7 deletions cassette/cassette.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import (
"github.com/google/uuid"
"github.com/pkg/errors"

"github.com/seborama/govcr/v6/cassette/track"
"github.com/seborama/govcr/v6/compression"
"github.com/seborama/govcr/v6/stats"
"github.com/seborama/govcr/v7/cassette/track"
"github.com/seborama/govcr/v7/compression"
"github.com/seborama/govcr/v7/stats"
)

// Cassette contains a set of tracks.
Expand Down Expand Up @@ -113,6 +113,7 @@ func (k7 *Cassette) AddTrack(trk *track.Track) {
}

// IsLongPlay returns true if the cassette content is compressed.
// This is simply based on the extension of the cassette filename.
func (k7 *Cassette) IsLongPlay() bool {
return strings.HasSuffix(k7.name, ".gz")
}
Expand Down Expand Up @@ -142,8 +143,7 @@ func (k7 *Cassette) save() error {
}

// GzipFilter compresses the cassette data in gzip format if the cassette
// name ends with '.gz', otherwise data is left as is (i.e. de-compressed)
// TODO: above comment is wrong: testing IsLongPlay!
// name ends with '.gz', otherwise data is left as is (i.e. de-compressed).
func (k7 *Cassette) GzipFilter(data bytes.Buffer) ([]byte, error) {
if k7.IsLongPlay() {
return compression.Compress(data.Bytes())
Expand All @@ -152,8 +152,7 @@ func (k7 *Cassette) GzipFilter(data bytes.Buffer) ([]byte, error) {
}

// GunzipFilter de-compresses the cassette data in gzip format if the cassette
// name ends with '.gz', otherwise data is left as is (i.e. de-compressed)
// TODO: above comment is wrong: testing IsLongPlay!
// name ends with '.gz', otherwise data is left as is (i.e. de-compressed).
func (k7 *Cassette) GunzipFilter(data []byte) ([]byte, error) {
if k7.IsLongPlay() {
return compression.Decompress(data)
Expand Down
4 changes: 2 additions & 2 deletions cassette/cassette_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/seborama/govcr/v6/cassette"
"github.com/seborama/govcr/v6/cassette/track"
"github.com/seborama/govcr/v7/cassette"
"github.com/seborama/govcr/v7/cassette/track"
)

func Test_cassette_GzipFilter(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cassette/cassette_wb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cassette
import (
"testing"

"github.com/seborama/govcr/v6/stats"
"github.com/seborama/govcr/v7/stats"
"github.com/stretchr/testify/assert"
)

Expand Down
51 changes: 27 additions & 24 deletions cassette/track/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ func (r *Request) Clone() *Request {
body := make([]byte, len(r.Body))
copy(body, r.Body)

transferEncoding := make([]string, len(r.TransferEncoding))
copy(transferEncoding, r.TransferEncoding)

newR := &Request{
Method: r.Method,
URL: cloneURL(r.URL),
Expand All @@ -62,7 +59,7 @@ func (r *Request) Clone() *Request {
Header: r.Header.Clone(),
Body: body,
ContentLength: r.ContentLength,
TransferEncoding: transferEncoding,
TransferEncoding: cloneStringSlice(r.TransferEncoding),
Close: r.Close,
Host: r.Host,
Form: cloneMapOfSlices(r.Form),
Expand Down Expand Up @@ -150,25 +147,26 @@ func ToRequest(httpRequest *http.Request) *Request {
bodyClone := cloneHTTPRequestBody(httpRequest)
headerClone := httpRequest.Header.Clone()
trailerClone := httpRequest.Trailer.Clone()
tsfEncodingClone := cloneStringSlice(httpRequest.TransferEncoding)

return &Request{
Method: httpRequest.Method,
URL: cloneURL(httpRequest.URL),
Proto: httpRequest.Proto,
ProtoMajor: httpRequest.ProtoMajor,
ProtoMinor: httpRequest.ProtoMinor,
Header: headerClone,
Body: bodyClone,
ContentLength: httpRequest.ContentLength,
// TODO: TransferEncoding: []string{},
Close: httpRequest.Close,
Host: httpRequest.Host,
// TODO: Form: map[string][]string{},
// TODO: PostForm: map[string][]string{},
// TODO: MultipartForm: &multipart.Form{},
Trailer: trailerClone,
RemoteAddr: httpRequest.RemoteAddr,
RequestURI: httpRequest.RequestURI,
Method: httpRequest.Method,
URL: cloneURL(httpRequest.URL),
Proto: httpRequest.Proto,
ProtoMajor: httpRequest.ProtoMajor,
ProtoMinor: httpRequest.ProtoMinor,
Header: headerClone,
Body: bodyClone,
ContentLength: httpRequest.ContentLength,
TransferEncoding: tsfEncodingClone,
Close: httpRequest.Close,
Host: httpRequest.Host,
Form: cloneMapOfSlices(httpRequest.Form),
PostForm: cloneMapOfSlices(httpRequest.PostForm),
MultipartForm: cloneMultipartForm(httpRequest.MultipartForm),
Trailer: trailerClone,
RemoteAddr: httpRequest.RemoteAddr,
RequestURI: httpRequest.RequestURI,
}
}

Expand All @@ -186,6 +184,13 @@ type Response struct {
TransferEncoding []string
Trailer http.Header
TLS *tls.ConnectionState

// Request is nil when recording a track to the cassette.
// At _replaying_ _time_ _only_ it will be populated with the "current" HTTP request.
// This is useful in scenarios where the request contains a dynamic piece of information
// such as e.g. a transaction ID, a customer number, etc.
// This is solely for informational purpose at replaying time. Mutating it achieves nothing.
Request *Request
}

// ToResponse transcodes an HTTP Response to a track Response.
Expand Down Expand Up @@ -401,9 +406,7 @@ func CloneHTTPRequest(httpRequest *http.Request) *http.Request {
httpRequestClone.TransferEncoding = cloneStringSlice(httpRequest.TransferEncoding)
httpRequestClone.Form = cloneURLValues(httpRequest.Form)
httpRequestClone.PostForm = cloneURLValues(httpRequest.PostForm)

// TODO:
// MultipartForm
httpRequestClone.MultipartForm = cloneMultipartForm(httpRequest.MultipartForm)
httpRequestClone.TLS = cloneTLS(httpRequest.TLS)

var responseClone *http.Response
Expand Down
2 changes: 1 addition & 1 deletion cassette/track/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"net/url"
"testing"

"github.com/seborama/govcr/v6/cassette/track"
"github.com/seborama/govcr/v7/cassette/track"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down
Loading

0 comments on commit a5c2dc1

Please sign in to comment.