diff --git a/Makefile b/Makefile index e850184..ece072d 100644 --- a/Makefile +++ b/Makefile @@ -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.): diff --git a/README.md b/README.md index 57aebce..5ae89f0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ 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. @@ -40,7 +40,7 @@ For all available releases, please check the [releases](https://github.com/sebor 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)! @@ -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. @@ -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 @@ -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. @@ -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 @@ -224,11 +232,23 @@ 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( @@ -236,10 +256,10 @@ vcr := govcr.NewVCR( govcr.WithLiveOnlyMode(), ) // or equally: -vcr.SetLiveOnlyMode(true) // `false` to disable option +vcr.SetLiveOnlyMode() ``` -#### Read only +#### Read only cassette mode ```go vcr := govcr.NewVCR( @@ -250,7 +270,7 @@ vcr := govcr.NewVCR( vcr.SetReadOnlyMode(true) // `false` to disable option ``` -#### Offline +#### Offline HTTP mode ```go vcr := govcr.NewVCR( @@ -258,7 +278,7 @@ vcr := govcr.NewVCR( govcr.WithOfflineMode(), ) // or equally: -vcr.SetOfflineMode(true) // `false` to disable option +vcr.SetOfflineMode() ``` ### Recipe: VCR with a custom RequestFilter @@ -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 @@ -306,7 +325,7 @@ import ( "net/http" - "github.com/seborama/govcr/v6" + "github.com/seborama/govcr/v7" ) const example5CassetteName = "MyCassette5" @@ -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 diff --git a/cassette/cassette.go b/cassette/cassette.go index 99751dd..15335bf 100644 --- a/cassette/cassette.go +++ b/cassette/cassette.go @@ -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. @@ -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") } @@ -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()) @@ -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) diff --git a/cassette/cassette_test.go b/cassette/cassette_test.go index e3a8fcd..1ca452c 100644 --- a/cassette/cassette_test.go +++ b/cassette/cassette_test.go @@ -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) { diff --git a/cassette/cassette_wb_test.go b/cassette/cassette_wb_test.go index e6a5112..1d79406 100644 --- a/cassette/cassette_wb_test.go +++ b/cassette/cassette_wb_test.go @@ -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" ) diff --git a/cassette/track/http.go b/cassette/track/http.go index 19fb138..69afb8a 100644 --- a/cassette/track/http.go +++ b/cassette/track/http.go @@ -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), @@ -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), @@ -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, } } @@ -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. @@ -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 diff --git a/cassette/track/http_test.go b/cassette/track/http_test.go index efd414f..045ab6a 100644 --- a/cassette/track/http_test.go +++ b/cassette/track/http_test.go @@ -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" ) diff --git a/cassette/track/mutator.go b/cassette/track/mutator.go index e5cf72e..9fad01f 100644 --- a/cassette/track/mutator.go +++ b/cassette/track/mutator.go @@ -5,11 +5,63 @@ import ( "regexp" ) +// It is used to construct conditional mutators. +type Predicate func(trk *Track) bool + +// Any accepts one or more predicates and returns a new predicate that will evaluate +// to true when any the supplied predicate is true, otherwise false. +func Any(predicates ...Predicate) Predicate { + return Predicate( + func(trk *Track) bool { + for _, p := range predicates { + if p(trk) { + return true + } + } + + return false + }, + ) +} + +// All accepts one or more predicates and returns a new predicate that will evaluate +// to true when every of the supplied predicate is true, otherwise false. +func All(predicates ...Predicate) Predicate { + return Predicate( + func(trk *Track) bool { + for _, p := range predicates { + if !p(trk) { + return false + } + } + + return true + }, + ) +} + +// Not accepts one predicate and returns its logically contrary evaluation. +// I.e. it returns true when the supplied predicate is false and vice-versa. +func Not(predicate Predicate) Predicate { + return Predicate( + func(trk *Track) bool { + return !predicate(trk) + }, + ) +} + // Mutator is a function signature for a Track mutator. // A Mutator can be used to mutate a track at recording or replaying time. -type Mutator func(*Track) +// +// When recording, Response.Request will be nil since the track already records the Request in +// its own track.Request object. +// +// When replaying (and _only_ just when _replaying_), Response.Request will be populated with +// the _current_ HTTP request. +type Mutator func(trk *Track) // On accepts a mutator only when the predicate is true. +// On will cowardly avoid the case when trk is nil. func (tm Mutator) On(predicate Predicate) Mutator { return func(trk *Track) { if trk != nil && predicate(trk) { @@ -18,37 +70,7 @@ func (tm Mutator) On(predicate Predicate) Mutator { } } -// TODO: Create an OnAll? - -// Any accepts a mutator when any the supplied predicate is true. -// See also the alias "Or". -// TODO: Rename to OnAny? Then delete Or() >> new major version -// Other Option: Any become a Predicate (to be used with On()) and not a Mutator -func (tm Mutator) Any(predicates ...Predicate) Mutator { - return func(trk *Track) { - if trk == nil { - return - } - - for _, p := range predicates { - if p(trk) { - tm(trk) - return - } - } - } -} - -// Or accepts a mutator when any the supplied predicate is true. -// It is an alias of "Any". -func (tm Mutator) Or(predicates ...Predicate) Mutator { - return tm.Any(predicates...) -} - // Predicate is a function signature that takes a track.Track and returns a boolean. -// It is used to construct conditional mutators. -type Predicate func(trk *Track) bool - // OnErr accepts a mutator only when an (HTTP/net) error occurred. func (tm Mutator) OnErr() Mutator { return tm.On( @@ -150,10 +172,11 @@ func (tm Mutator) OnStatusCode(codes ...int) Mutator { return tm.On(HasAnyStatusCode(codes...)) } -// RequestAddHeaderValue adds or overwrites a header key / value to the request. -func RequestAddHeaderValue(key, value string) Mutator { +// TrackRequestAddHeaderValue adds or overwrites a header key / value to the HTTP request. +func TrackRequestAddHeaderValue(key, value string) Mutator { return func(trk *Track) { if trk != nil { + // TODO: add a debug log on trk.Response.Request != nil as it indicates replaying time rather than recording time. if trk.Request.Header == nil { trk.Request.Header = http.Header{} } @@ -162,10 +185,12 @@ func RequestAddHeaderValue(key, value string) Mutator { } } -// RequestDeleteHeaderKeys deletes one or more header keys from the request. -func RequestDeleteHeaderKeys(keys ...string) Mutator { +// TrackRequestDeleteHeaderKeys deletes one or more header keys from the track request. +// This is useful with a recording track mutator. +func TrackRequestDeleteHeaderKeys(keys ...string) Mutator { return func(trk *Track) { if trk != nil { + // TODO: add a debug log on trk.Response.Request != nil as it indicates replaying time rather than recording time. for _, key := range keys { trk.Request.Header.Del(key) } @@ -196,99 +221,81 @@ func ResponseDeleteHeaderKeys(keys ...string) Mutator { } } -// RequestTransferHeaderKeys transfers one or more headers from the response to the request. -func RequestTransferHeaderKeys(keys ...string) Mutator { +// ResponseTransferHTTPHeaderKeys transfers one or more headers from the "current" Response.Request to the track response. +// This is _only_ useful with a replaying track mutator. +func ResponseTransferHTTPHeaderKeys(keys ...string) Mutator { return func(trk *Track) { if trk == nil { return } - for _, key := range keys { - // only transfer headers that actually exist - if trk.Response.Header.Values(key) != nil && trk.Response.Header.Get(key) != "" { - // this test must be inside the loop so we only add a blank header when we know - // we're going to populate it, otherwise retain the "nil" value untouched. - if trk.Request.Header == nil { - trk.Request.Header = http.Header{} - } - - trk.Request.Header.Add(key, trk.Response.Header.Get(key)) - } + if trk.Response == nil { + // TODO: add debug logging that this mutator was likely called at recording time or that it was called + // on replaying a track that does not have a response (presumably a transport error occurred). + return } - } -} -// ResponseTransferHeaderKeys transfers one or more headers from the request to the response. -func ResponseTransferHeaderKeys(keys ...string) Mutator { - return func(trk *Track) { - if trk == nil { + if trk.Response.Request == nil { + // TODO: add debug logging that this mutator was likely called at recording time and it is not correct usage. return } for _, key := range keys { // only transfer headers that actually exist - if trk.Request.Header.Values(key) != nil && trk.Request.Header.Get(key) != "" { + if trk.Response.Request.Header.Values(key) != nil && trk.Response.Request.Header.Get(key) != "" { // this test must be inside the loop so we only add a blank header when we know // we're going to populate it, otherwise retain the "nil" value untouched. if trk.Response.Header == nil { trk.Response.Header = http.Header{} } - trk.Response.Header.Add(key, trk.Request.Header.Get(key)) + trk.Response.Header.Add(key, trk.Response.Request.Header.Get(key)) } } } } -// RequestTransferTrailerKeys transfers one or more trailers from the response to the request. -func RequestTransferTrailerKeys(keys ...string) Mutator { +// ResponseTransferHTTPTrailerKeys transfers one or more trailers from the HTTP request to the track response. +// This is _only_ useful with a replaying track mutator. +func ResponseTransferHTTPTrailerKeys(keys ...string) Mutator { return func(trk *Track) { if trk == nil { return } - for _, key := range keys { - // only transfer trailers that actually exist - if trk.Response.Trailer.Values(key) != nil && trk.Response.Trailer.Get(key) != "" { - // this test must be inside the loop so we only add a blank trailer when we know - // we're going to populate it, otherwise retain the "nil" value untouched. - if trk.Request.Trailer == nil { - trk.Request.Trailer = http.Header{} - } - - trk.Request.Trailer.Add(key, trk.Response.Trailer.Get(key)) - } + if trk.Response == nil { + // TODO: add debug logging that this mutator was likely called at recording time or that it was called + // on replaying a track that does not have a response (presumably a transport error occurred). + return } - } -} -// ResponseTransferTrailerKeys transfers one or more trailers from the request to the response. -func ResponseTransferTrailerKeys(keys ...string) Mutator { - return func(trk *Track) { - if trk == nil { + if trk.Response.Request == nil { + // TODO: add debug logging that this mutator was likely called at recording time and it is not correct usage. return } for _, key := range keys { // only transfer trailers that actually exist - if trk.Request.Trailer.Values(key) != nil && trk.Request.Trailer.Get(key) != "" { + if trk.Response.Request.Trailer.Values(key) != nil && trk.Response.Request.Trailer.Get(key) != "" { // this test must be inside the loop so we only add a blank trailer when we know // we're going to populate it, otherwise retain the "nil" value untouched. if trk.Response.Trailer == nil { trk.Response.Trailer = http.Header{} } - trk.Response.Trailer.Add(key, trk.Request.Trailer.Get(key)) + trk.Response.Trailer.Add(key, trk.Response.Request.Trailer.Get(key)) } } } } -// RequestChangeBody allows to change the body of the request. +// TrackRequestChangeBody allows to change the body of the request. // Supply a function that does input to output transformation. -func RequestChangeBody(fn func(b []byte) []byte) Mutator { +// This is useful with a recording track mutator. +func TrackRequestChangeBody(fn func(b []byte) []byte) Mutator { return func(trk *Track) { if trk != nil { + // TODO: add a debug log on trk.Response.Request != nil as it indicates replaying time rather than recording time. trk.Request.Body = fn(trk.Request.Body) } } @@ -322,6 +329,9 @@ func (tms Mutators) Add(mutators ...Mutator) Mutators { } // Mutate applies all mutators in this Mutators collection to the specified Track. +// Reminder that trk.Response.Request is nil at recording time and only populated +// at replaying time. +// See Mutator and Track.Response.Request for further details. func (tms Mutators) Mutate(trk *Track) { for _, tm := range tms { tm(trk) diff --git a/cassette/track/mutator_test.go b/cassette/track/mutator_test.go index 992c228..054e286 100644 --- a/cassette/track/mutator_test.go +++ b/cassette/track/mutator_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/seborama/govcr/v6/cassette/track" + "github.com/seborama/govcr/v7/cassette/track" ) func Test_Mutator_On(t *testing.T) { @@ -55,15 +55,35 @@ func Test_Mutator_On(t *testing.T) { require.Equal(t, 0, mutatorCallCounter) } -func Test_Mutator_Or(t *testing.T) { - mutatorCallCounter := 0 +func Test_Mutator_Any(t *testing.T) { + pTrue := track.Predicate( + func(trk *track.Track) bool { + return true + }, + ) - unitMutator := track.Mutator( - func(tk *track.Track) { - mutatorCallCounter++ + pFalse := track.Predicate( + func(trk *track.Track) bool { + return false }, ) + trk := track.NewTrack(nil, nil, nil) + + result := track.Any(pFalse, pTrue)(nil) + require.True(t, result) + + result = track.Any(pFalse, pTrue)(trk) + require.True(t, result) + + result = track.Any(pFalse, pFalse)(trk) + require.False(t, result) + + result = track.Any(pTrue, pTrue)(trk) + require.True(t, result) +} + +func Test_Mutator_All(t *testing.T) { pTrue := track.Predicate( func(trk *track.Track) bool { return true @@ -76,29 +96,63 @@ func Test_Mutator_Or(t *testing.T) { }, ) - trk := track.NewTrack( - &track.Request{}, - &track.Response{ - StatusCode: 172, - }, - nil, - ) + trk := track.NewTrack(nil, nil, nil) - mutatorCallCounter = 0 - unitMutator.Or(pFalse, pTrue)(nil) - require.Equal(t, 0, mutatorCallCounter) + result := track.All(pFalse, pTrue)(nil) + require.False(t, result) - mutatorCallCounter = 0 - unitMutator.Or(pFalse, pTrue)(trk) - require.Equal(t, 1, mutatorCallCounter) + result = track.All(pFalse, pTrue)(trk) + require.False(t, result) - mutatorCallCounter = 0 - unitMutator.Or(pFalse, pFalse)(trk) - require.Equal(t, 0, mutatorCallCounter) + result = track.All(pFalse, pFalse)(trk) + require.False(t, result) - mutatorCallCounter = 0 - unitMutator.Or(pTrue, pTrue)(trk) - require.Equal(t, 1, mutatorCallCounter) + result = track.All(pTrue, pTrue)(trk) + require.True(t, result) +} + +func Test_Mutator_Not(t *testing.T) { + pTrue := track.Predicate( + func(trk *track.Track) bool { + return true + }, + ) + + pFalse := track.Predicate( + func(trk *track.Track) bool { + return false + }, + ) + + result := track.Not(pFalse)(nil) + require.True(t, result) + result = track.Not(pTrue)(nil) + require.False(t, result) + + trk := track.NewTrack(nil, nil, nil) + + result = track.Not(pFalse)(trk) + require.True(t, result) + result = track.Not(pTrue)(trk) + require.False(t, result) + + result = track.Not(track.Any(pFalse, pTrue))(trk) + require.False(t, result) + result = track.Not(track.Any(pTrue, pFalse))(trk) + require.False(t, result) + result = track.Not(track.Any(pFalse, pFalse))(trk) + require.True(t, result) + result = track.Not(track.Any(pTrue, pTrue))(trk) + require.False(t, result) + + result = track.Not(track.All(pFalse, pTrue))(trk) + require.True(t, result) + result = track.Not(track.All(pTrue, pFalse))(trk) + require.True(t, result) + result = track.Not(track.All(pFalse, pFalse))(trk) + require.True(t, result) + result = track.Not(track.All(pTrue, pTrue))(trk) + require.False(t, result) } func Test_Mutator_HasErr(t *testing.T) { @@ -357,7 +411,7 @@ func Test_Mutator_OnStatusCode(t *testing.T) { } func Test_Mutator_RequestAddHeaderValue(t *testing.T) { - unitMutator := track.RequestAddHeaderValue("key-1", "value-1") + unitMutator := track.TrackRequestAddHeaderValue("key-1", "value-1") h := http.Header{} h.Set("key-a", "value-b") @@ -375,7 +429,7 @@ func Test_Mutator_RequestAddHeaderValue(t *testing.T) { } func Test_Mutator_RequestAddHeaderValue_NilHeader(t *testing.T) { - unitMutator := track.RequestAddHeaderValue("key-1", "value-1") + unitMutator := track.TrackRequestAddHeaderValue("key-1", "value-1") trk := track.NewTrack( &track.Request{}, @@ -390,7 +444,7 @@ func Test_Mutator_RequestAddHeaderValue_NilHeader(t *testing.T) { } func Test_Mutator_RequestDeleteHeaderKeys(t *testing.T) { - unitMutator := track.RequestDeleteHeaderKeys("other", "key-a") + unitMutator := track.TrackRequestDeleteHeaderKeys("other", "key-a") h := http.Header{} h.Set("key-a", "value-b") @@ -408,7 +462,7 @@ func Test_Mutator_RequestDeleteHeaderKeys(t *testing.T) { } func Test_Mutator_RequestDeleteHeaderKeys_NilHeader(t *testing.T) { - unitMutator := track.RequestDeleteHeaderKeys("other", "key-a") + unitMutator := track.TrackRequestDeleteHeaderKeys("other", "key-a") trk := track.NewTrack( &track.Request{}, @@ -489,7 +543,7 @@ func Test_Mutator_ResponseDeleteHeaderKeys_NilHeader(t *testing.T) { } func Test_Mutator_RequestChangeBody(t *testing.T) { - unitMutator := track.RequestChangeBody( + unitMutator := track.TrackRequestChangeBody( func(b []byte) []byte { return []byte("changed") }, @@ -592,249 +646,57 @@ func Test_Mutator_ResponseDeleteTLS(t *testing.T) { assert.Nil(t, trk.Response) } -func TestRequestTransferHeaderKeys_NilTrack(t *testing.T) { - var trk *track.Track - track.RequestTransferHeaderKeys("unit-key-1", "unit-value-1")(trk) - assert.Nil(t, trk) -} - -func TestRequestTransferTrailerKeys_NilTrack(t *testing.T) { - var trk *track.Track - track.RequestTransferTrailerKeys("unit-key-1", "unit-value-1")(trk) - assert.Nil(t, trk) -} - func TestResponseTransferHeaderKeys_NilTrack(t *testing.T) { var trk *track.Track - track.ResponseTransferHeaderKeys("unit-key-1", "unit-value-1")(trk) + track.ResponseTransferHTTPHeaderKeys("unit-key-1", "unit-key-2")(trk) assert.Nil(t, trk) } func TestResponseTransferTrailerKeys_NilTrack(t *testing.T) { var trk *track.Track - track.ResponseTransferTrailerKeys("unit-key-1", "unit-value-1")(trk) + track.ResponseTransferHTTPTrailerKeys("unit-key-1", "unit-key-2")(trk) assert.Nil(t, trk) } -func Test_Mutator_RequestTransferHeaderKeys(t *testing.T) { +func Test_Mutator_ResponseTransferHTTPHeaderKeys(t *testing.T) { tt := map[string]struct { - reqHeader http.Header + respReqHeader http.Header respHeader http.Header - wantReqHeader http.Header wantRespHeader http.Header }{ - "nil request and nil response headers": { - reqHeader: nil, + "nil request header and nil response header": { + respReqHeader: nil, respHeader: nil, - wantReqHeader: nil, wantRespHeader: nil, }, - "nil request and blank response header": { - reqHeader: nil, + "nil request header and blank response header": { + respReqHeader: nil, respHeader: http.Header{}, - wantReqHeader: nil, wantRespHeader: http.Header{}, }, - "blank request and nil response header": { - reqHeader: http.Header{}, + "blank request header and nil response header": { + respReqHeader: http.Header{}, respHeader: nil, - wantReqHeader: http.Header{}, wantRespHeader: nil, }, - "blank request and blank response header": { - reqHeader: http.Header{}, + "blank request header and blank response header": { + respReqHeader: http.Header{}, respHeader: http.Header{}, - wantReqHeader: http.Header{}, wantRespHeader: http.Header{}, }, - "nil request and eligible response header": { - reqHeader: nil, - respHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantRespHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - "blank request and eligible response header": { - reqHeader: http.Header{}, - respHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantRespHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - "eligible response header with request containing other data": { - reqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-a", "unit-value-a"); return h }(), - respHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqHeader: func() http.Header { - h := http.Header{} - h.Set("unit-key-a", "unit-value-a") - h.Add("unit-key-1", "unit-value-1") - return h - }(), - wantRespHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - "eligible response header with request already containing the transfer data": { - reqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - respHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqHeader: func() http.Header { - h := http.Header{} - h.Set("unit-key-1", "unit-value-1") - h.Add("unit-key-1", "unit-value-1") - return h - }(), - wantRespHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - } - - for name, tc := range tt { - name := name - tc := tc - - t.Run(name, func(t *testing.T) { - trk := track.NewTrack( - &track.Request{Header: tc.reqHeader}, - &track.Response{Header: tc.respHeader}, - nil, - ) - - track.RequestTransferHeaderKeys("unit-key-1", "unit-value-1")(trk) - - assert.Equal(t, tc.wantReqHeader, trk.Request.Header) - assert.Equal(t, tc.wantRespHeader, trk.Response.Header) - }) - } -} - -func Test_Mutator_RequestTransferTrailerKeys(t *testing.T) { - tt := map[string]struct { - reqTrailer http.Header - respTrailer http.Header - wantReqTrailer http.Header - wantRespTrailer http.Header - }{ - "nil request and nil response trailers": { - reqTrailer: nil, - respTrailer: nil, - wantReqTrailer: nil, - wantRespTrailer: nil, - }, - "nil request and blank response trailer": { - reqTrailer: nil, - respTrailer: http.Header{}, - wantReqTrailer: nil, - wantRespTrailer: http.Header{}, - }, - "blank request and nil response trailer": { - reqTrailer: http.Header{}, - respTrailer: nil, - wantReqTrailer: http.Header{}, - wantRespTrailer: nil, - }, - "blank request and blank response trailer": { - reqTrailer: http.Header{}, - respTrailer: http.Header{}, - wantReqTrailer: http.Header{}, - wantRespTrailer: http.Header{}, - }, - "nil request and eligible response trailer": { - reqTrailer: nil, - respTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantRespTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - "blank request and eligible response trailer": { - reqTrailer: http.Header{}, - respTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantRespTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - "eligible response trailer with request containing other data": { - reqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-a", "unit-value-a"); return h }(), - respTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqTrailer: func() http.Header { - h := http.Header{} - h.Set("unit-key-a", "unit-value-a") - h.Add("unit-key-1", "unit-value-1") - return h - }(), - wantRespTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - "eligible response trailer with request already containing the transfer data": { - reqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - respTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqTrailer: func() http.Header { - h := http.Header{} - h.Set("unit-key-1", "unit-value-1") - h.Add("unit-key-1", "unit-value-1") - return h - }(), - wantRespTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - }, - } - - for name, tc := range tt { - name := name - tc := tc - - t.Run(name, func(t *testing.T) { - trk := track.NewTrack( - &track.Request{Trailer: tc.reqTrailer}, - &track.Response{Trailer: tc.respTrailer}, - nil, - ) - - track.RequestTransferTrailerKeys("unit-key-1", "unit-value-1")(trk) - - assert.Equal(t, tc.wantReqTrailer, trk.Request.Trailer) - assert.Equal(t, tc.wantRespTrailer, trk.Response.Trailer) - }) - } -} - -func Test_Mutator_ResponseTransferHeaderKeys(t *testing.T) { - tt := map[string]struct { - reqHeader http.Header - respHeader http.Header - wantReqHeader http.Header - wantRespHeader http.Header - }{ - "nil request and nil response headers": { - reqHeader: nil, + "nil response header and eligible request header": { + respReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respHeader: nil, - wantReqHeader: nil, - wantRespHeader: nil, - }, - "nil request and blank response header": { - reqHeader: nil, - respHeader: http.Header{}, - wantReqHeader: nil, - wantRespHeader: http.Header{}, - }, - "blank request and nil response header": { - reqHeader: http.Header{}, - respHeader: nil, - wantReqHeader: http.Header{}, - wantRespHeader: nil, - }, - "blank request and blank response header": { - reqHeader: http.Header{}, - respHeader: http.Header{}, - wantReqHeader: http.Header{}, - wantRespHeader: http.Header{}, - }, - "nil response and eligible request header": { - reqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - respHeader: nil, - wantReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), }, - "blank response and eligible request header": { - reqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "blank response header and eligible request header": { + respReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respHeader: http.Header{}, - wantReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), }, - "eligible request header with response containing other data": { - reqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "eligible request header with response header containing other data": { + respReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-a", "unit-value-a"); return h }(), - wantReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespHeader: func() http.Header { h := http.Header{} h.Set("unit-key-a", "unit-value-a") @@ -842,10 +704,9 @@ func Test_Mutator_ResponseTransferHeaderKeys(t *testing.T) { return h }(), }, - "eligible request header with response already containing the transfer data": { - reqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "eligible request header with response header already containing the transfer data": { + respReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqHeader: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespHeader: func() http.Header { h := http.Header{} h.Set("unit-key-1", "unit-value-1") @@ -861,66 +722,57 @@ func Test_Mutator_ResponseTransferHeaderKeys(t *testing.T) { t.Run(name, func(t *testing.T) { trk := track.NewTrack( - &track.Request{Header: tc.reqHeader}, - &track.Response{Header: tc.respHeader}, + nil, + &track.Response{Header: tc.respHeader, Request: &track.Request{Header: tc.respReqHeader}}, nil, ) - track.ResponseTransferHeaderKeys("unit-key-1", "unit-value-1")(trk) + track.ResponseTransferHTTPHeaderKeys("unit-key-1", "unit-key-2")(trk) - assert.Equal(t, tc.wantReqHeader, trk.Request.Header) assert.Equal(t, tc.wantRespHeader, trk.Response.Header) }) } } -func Test_Mutator_ResponseTransferTrailerKeys(t *testing.T) { +func Test_Mutator_ResponseTransferHTTPTrailerKeys(t *testing.T) { tt := map[string]struct { - reqTrailer http.Header + respReqTrailer http.Header respTrailer http.Header - wantReqTrailer http.Header wantRespTrailer http.Header }{ - "nil request and nil response trailers": { - reqTrailer: nil, + "nil request trailer and nil response trailer": { + respReqTrailer: nil, respTrailer: nil, - wantReqTrailer: nil, wantRespTrailer: nil, }, - "nil request and blank response trailer": { - reqTrailer: nil, + "nil request trailer and blank response trailer": { + respReqTrailer: nil, respTrailer: http.Header{}, - wantReqTrailer: nil, wantRespTrailer: http.Header{}, }, - "blank request and nil response trailer": { - reqTrailer: http.Header{}, + "blank request trailer and nil response trailer": { + respReqTrailer: http.Header{}, respTrailer: nil, - wantReqTrailer: http.Header{}, wantRespTrailer: nil, }, - "blank request and blank response trailer": { - reqTrailer: http.Header{}, + "blank request trailer and blank response trailer": { + respReqTrailer: http.Header{}, respTrailer: http.Header{}, - wantReqTrailer: http.Header{}, wantRespTrailer: http.Header{}, }, - "nil response and eligible request trailer": { - reqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "nil response trailer and eligible request trailer": { + respReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respTrailer: nil, - wantReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), }, - "blank response and eligible request trailer": { - reqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "blank response trailer and eligible request trailer": { + respReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respTrailer: http.Header{}, - wantReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), }, - "eligible request trailer with response containing other data": { - reqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "eligible request trailer with response trailer containing other data": { + respReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-a", "unit-value-a"); return h }(), - wantReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespTrailer: func() http.Header { h := http.Header{} h.Set("unit-key-a", "unit-value-a") @@ -928,10 +780,9 @@ func Test_Mutator_ResponseTransferTrailerKeys(t *testing.T) { return h }(), }, - "eligible request trailer with response already containing the transfer data": { - reqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), + "eligible request trailer with response trailer already containing the transfer data": { + respReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), respTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), - wantReqTrailer: func() http.Header { h := http.Header{}; h.Set("unit-key-1", "unit-value-1"); return h }(), wantRespTrailer: func() http.Header { h := http.Header{} h.Set("unit-key-1", "unit-value-1") @@ -947,14 +798,13 @@ func Test_Mutator_ResponseTransferTrailerKeys(t *testing.T) { t.Run(name, func(t *testing.T) { trk := track.NewTrack( - &track.Request{Trailer: tc.reqTrailer}, - &track.Response{Trailer: tc.respTrailer}, + nil, + &track.Response{Trailer: tc.respTrailer, Request: &track.Request{Trailer: tc.respReqTrailer}}, nil, ) - track.ResponseTransferTrailerKeys("unit-key-1", "unit-value-1")(trk) + track.ResponseTransferHTTPTrailerKeys("unit-key-1", "unit-key-2")(trk) - assert.Equal(t, tc.wantReqTrailer, trk.Request.Trailer) assert.Equal(t, tc.wantRespTrailer, trk.Response.Trailer) }) } diff --git a/cassette/track/track.go b/cassette/track/track.go index b830bd1..db82d53 100644 --- a/cassette/track/track.go +++ b/cassette/track/track.go @@ -9,7 +9,7 @@ import ( "github.com/pkg/errors" - trkerr "github.com/seborama/govcr/v6/cassette/track/errors" + trkerr "github.com/seborama/govcr/v7/cassette/track/errors" ) // Track is a recording (HTTP Request + Response) in a cassette. diff --git a/concurrency_test.go b/concurrency_test.go index 936e58c..d57634e 100644 --- a/concurrency_test.go +++ b/concurrency_test.go @@ -15,8 +15,8 @@ import ( "github.com/pkg/errors" "github.com/stretchr/testify/require" - "github.com/seborama/govcr/v6" - "github.com/seborama/govcr/v6/stats" + "github.com/seborama/govcr/v7" + "github.com/seborama/govcr/v7/stats" ) func TestConcurrencySafety(t *testing.T) { diff --git a/controlpanel.go b/controlpanel.go index 2767d30..2f72737 100644 --- a/controlpanel.go +++ b/controlpanel.go @@ -3,8 +3,8 @@ package govcr import ( "net/http" - "github.com/seborama/govcr/v6/cassette/track" - "github.com/seborama/govcr/v6/stats" + "github.com/seborama/govcr/v7/cassette/track" + "github.com/seborama/govcr/v7/stats" ) // ControlPanel holds the parts of a VCR that can be interacted with. @@ -34,14 +34,19 @@ func (controlPanel *ControlPanel) SetReadOnlyMode(state bool) { controlPanel.vcrTransport().SetReadOnlyMode(state) } -// SetOfflineMode sets the VCR to offline mode (true) or to normal live/replay (false). -func (controlPanel *ControlPanel) SetOfflineMode(state bool) { - controlPanel.vcrTransport().SetOfflineMode(state) +// SetNormalMode sets the VCR to normal HTTP mode. +func (controlPanel *ControlPanel) SetNormalMode() { + controlPanel.vcrTransport().SetNormalMode() } -// SetLiveOnlyMode sets the VCR to live-only mode (true) or to normal live/replay (false). -func (controlPanel *ControlPanel) SetLiveOnlyMode(state bool) { - controlPanel.vcrTransport().SetLiveOnlyMode(state) +// SetOfflineMode sets the VCR to offline mode. +func (controlPanel *ControlPanel) SetOfflineMode() { + controlPanel.vcrTransport().SetOfflineMode() +} + +// SetLiveOnlyMode sets the VCR to live-only mode. +func (controlPanel *ControlPanel) SetLiveOnlyMode() { + controlPanel.vcrTransport().SetLiveOnlyMode() } // AddRecordingMutators adds a set of recording Track Mutator's to the VCR. diff --git a/examples/Example1_test.go b/examples/Example1_test.go index bcc7b16..030f0b5 100644 --- a/examples/Example1_test.go +++ b/examples/Example1_test.go @@ -4,15 +4,15 @@ import ( "os" "testing" - "github.com/seborama/govcr/v6" - "github.com/seborama/govcr/v6/stats" + "github.com/seborama/govcr/v7" + "github.com/seborama/govcr/v7/stats" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const exampleCassetteName1 = "temp-fixtures/TestExample1.cassette.json" -// TestExample1 is an example use of govcr. +// TestExample1 is a simple example use of govcr. func TestExample1(t *testing.T) { _ = os.Remove(exampleCassetteName1) diff --git a/examples/Example2_test.go b/examples/Example2_test.go index c03ec78..5d7b51d 100644 --- a/examples/Example2_test.go +++ b/examples/Example2_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/seborama/govcr/v6" + "github.com/seborama/govcr/v7" ) const exampleCassetteName2 = "temp-fixtures/TestExample2.cassette.json" @@ -20,7 +20,7 @@ func (app myApp) Get(url string) { app.httpClient.Get(url) } -// TestExample2 is an example use of govcr. +// TestExample2 is an example use of govcr with a custom HTTP client. func TestExample2(t *testing.T) { // Create a custom http.Transport for our app. tr := http.DefaultTransport.(*http.Transport) diff --git a/examples/Example3_test.go b/examples/Example3_test.go new file mode 100644 index 0000000..8004e7d --- /dev/null +++ b/examples/Example3_test.go @@ -0,0 +1,111 @@ +package examples_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/seborama/govcr/v7" + "github.com/seborama/govcr/v7/cassette/track" + "github.com/stretchr/testify/require" +) + +const exampleCassetteName3 = "temp-fixtures/TestExample3.cassette.json" + +// TestExample3 is an example use of govcr in a situation where a request-specific transaction ID is exchanged +// between the server and the client. +// There exist multiple ways to achieve this. This is only one possibility. +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"), + ), + ) + + defer func() { + // Display govcr Stats + t.Logf("%+v\n", vcr.Stats()) + }() + + // Start mock server + serverURL := mockServer() + + // Run request, we will receive Status Created. + txID := uuid.NewString() + + req, err := http.NewRequest(http.MethodGet, serverURL+"/create", nil) + require.NoError(t, err) + req.Header.Set("X-Transaction-Id", txID) + + resp, err := vcr.HTTPClient().Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + require.Equal(t, txID, resp.Header.Get("X-Transaction-Id")) + + // Repeat the request, this time we'll get Status Conflict. + req, err = http.NewRequest(http.MethodGet, serverURL+"/get", nil) + require.NoError(t, err) + req.Header.Set("X-Transaction-Id", txID) + require.Equal(t, txID, resp.Header.Get("X-Transaction-Id")) + + resp, err = vcr.HTTPClient().Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +// +// Note: code past this point is purely to support the example +// There is no value in reading this from a govcr point-of-view. +// + +func mockServer() string { + txns := map[string]struct{}{} + + // Create a basic test server. + // The server accepts a query param of "txid" or it will return HTTP Bad Request. + // When the provided ID is new, the server will return HTTP Created. + // When the provided ID is recognised, the server will return HTTP Conflict. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + txID := r.Header.Get("X-Transaction-Id") + if txID == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, "missing 'txid' parameter") + return + } + + if _, ok := txns[txID]; ok { + w.Header().Set("X-Transaction-Id", txID) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "txid exists: %s\n", txID) + return + } + + txns[txID] = struct{}{} + w.Header().Set("X-Transaction-Id", txID) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, "created new txid: %s\n", txID) + })) + + return ts.URL +} diff --git a/go.mod b/go.mod index 7593d51..a8335e6 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/seborama/govcr/v6 +module github.com/seborama/govcr/v7 go 1.17 diff --git a/govcr.go b/govcr.go index 5ec0ec9..a312ad3 100644 --- a/govcr.go +++ b/govcr.go @@ -32,9 +32,8 @@ func NewVCR(settings ...Setting) *ControlPanel { requestMatcher: vcrSettings.requestMatcher, trackRecordingMutators: vcrSettings.trackRecordingMutators, trackReplayingMutators: vcrSettings.trackReplayingMutators, - liveOnly: vcrSettings.liveOnly, + httpMode: vcrSettings.httpMode, readOnly: vcrSettings.readOnly, - offlineMode: vcrSettings.offlineMode, }, cassette: vcrSettings.cassette, transport: vcrSettings.client.Transport, diff --git a/govcr_test.go b/govcr_test.go index 85b8033..f211b47 100644 --- a/govcr_test.go +++ b/govcr_test.go @@ -14,8 +14,8 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "github.com/seborama/govcr/v6" - "github.com/seborama/govcr/v6/stats" + "github.com/seborama/govcr/v7" + "github.com/seborama/govcr/v7/stats" ) func TestNewVCR(t *testing.T) { @@ -163,7 +163,7 @@ func (suite *GoVCRTestSuite) TestVCR_ReadOnlyMode() { } func (suite *GoVCRTestSuite) TestVCR_LiveOnlyMode() { - suite.vcr.SetLiveOnlyMode(true) + suite.vcr.SetLiveOnlyMode() suite.vcr.SetRequestMatcher(govcr.NewBlankRequestMatcher()) // ensure always matching // 1st execution of set of calls @@ -199,7 +199,7 @@ func (suite *GoVCRTestSuite) TestVCR_OfflineMode() { suite.vcr.SetRequestMatcher(govcr.NewBlankRequestMatcher()) // ensure always matching // 1st execution of set of calls - populate cassette - suite.vcr.SetOfflineMode(false) // get data in the cassette + suite.vcr.SetNormalMode() // get data in the cassette err := suite.vcr.LoadCassette(suite.cassetteName) suite.Require().NoError(err) @@ -215,7 +215,7 @@ func (suite *GoVCRTestSuite) TestVCR_OfflineMode() { suite.vcr.EjectCassette() // 2nd execution of set of calls -- offline only - suite.vcr.SetOfflineMode(true) + suite.vcr.SetOfflineMode() err = suite.vcr.LoadCassette(suite.cassetteName) suite.Require().NoError(err) diff --git a/govcr_wb_test.go b/govcr_wb_test.go index 67cfc3d..3612d66 100644 --- a/govcr_wb_test.go +++ b/govcr_wb_test.go @@ -12,8 +12,8 @@ import ( "github.com/stretchr/testify/suite" - "github.com/seborama/govcr/v6/cassette/track" - "github.com/seborama/govcr/v6/stats" + "github.com/seborama/govcr/v7/cassette/track" + "github.com/seborama/govcr/v7/stats" ) type GoVCRWBTestSuite struct { @@ -36,6 +36,7 @@ func (suite *GoVCRWBTestSuite) SetupTest() { suite.testServer = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Trailer", "trailer_1") w.Header().Set("header_1", "header_1_value") + w.Header().Del("Date") w.WriteHeader(http.StatusOK) counter++ iQuery := r.URL.Query().Get("i") @@ -75,7 +76,7 @@ func (suite *GoVCRWBTestSuite) TestRoundTrip_RequestMatcherDoesNotMutateState() suite.vcr.ClearRecordingMutators() // mutators by definition cannot change the live request / response, only the track suite.vcr.ClearReplayingMutators() // mutators by definition cannot change the live request / response, only the track - suite.vcr.SetLiveOnlyMode(true) // ensure we record one track so we can have a request matcher execution later (no track on cassette = no request matching) + suite.vcr.SetLiveOnlyMode() // ensure we record one track so we can have a request matcher execution later (no track on cassette = no request matching) requestMatcherCount := 0 @@ -136,7 +137,7 @@ func (suite *GoVCRWBTestSuite) TestRoundTrip_RequestMatcherDoesNotMutateState() suite.vcr.EjectCassette() // reset cassette state so to allow track replay (newly recorded tracks are marked at replayed) err = suite.vcr.LoadCassette(suite.cassetteName) suite.Require().NoError(err) - suite.vcr.SetLiveOnlyMode(false) // ensure we attempt request matching + suite.vcr.SetNormalMode() // ensure we attempt request matching req, err = http.NewRequest(http.MethodGet, suite.testServer.URL, nil) suite.Require().NoError(err) @@ -167,8 +168,7 @@ func (suite *GoVCRWBTestSuite) TestRoundTrip_RequestMatcherDoesNotMutateState() suite.vcr.EjectCassette() // reset cassette state so to allow track replay (newly recorded tracks are marked at replayed) err = suite.vcr.LoadCassette(suite.cassetteName) suite.Require().NoError(err) - suite.vcr.SetLiveOnlyMode(false) - suite.vcr.SetOfflineMode(true) + suite.vcr.SetOfflineMode() requestMatcherCount = 0 suite.vcr.SetRequestMatcher(NewBlankRequestMatcher( diff --git a/matchers.go b/matchers.go index 0fcda3b..55785e4 100644 --- a/matchers.go +++ b/matchers.go @@ -4,7 +4,7 @@ import ( "net/http" "net/url" - "github.com/seborama/govcr/v6/cassette/track" + "github.com/seborama/govcr/v7/cassette/track" ) // RequestMatcherFunc is a function that performs request comparison. diff --git a/matchers_test.go b/matchers_test.go index e7681b0..c9aec72 100644 --- a/matchers_test.go +++ b/matchers_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/assert" - "github.com/seborama/govcr/v6" - "github.com/seborama/govcr/v6/cassette/track" + "github.com/seborama/govcr/v7" + "github.com/seborama/govcr/v7/cassette/track" ) func Test_DefaultHeaderMatcher(t *testing.T) { diff --git a/pcb.go b/pcb.go index 5b24ce1..c2b72b7 100644 --- a/pcb.go +++ b/pcb.go @@ -3,8 +3,23 @@ package govcr import ( "net/http" - "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" +) + +// HTTPMode defines govcr's mode for HTTP requests. +// See specific modes for further details. +type HTTPMode int + +const ( + // HTTPModeNormal replays from cassette if a match exists or execute live request. + HTTPModeNormal HTTPMode = iota + + // HTTPModeLiveOnly executes live calls for all requests, ignores cassette. + HTTPModeLiveOnly + + // HTTPModeOffline, plays back from cassette or if no match, return a transport error. + HTTPModeOffline ) // PrintedCircuitBoard is a structure that holds some facilities that are passed to @@ -20,22 +35,15 @@ type PrintedCircuitBoard struct { // However, the Request data can be referenced as part of mutating the Response. trackReplayingMutators track.Mutators - // Make live calls only, do not replay from cassette even if a track would exist. - // Perhaps more useful when used in combination with 'readOnly' to by-pass govcr entirely. - // TODO: note it probably does not make sense to have Offline true and LiveOnly true - liveOnly bool + // httpMode govcr's mode for HTTP request - see httpMode for details. + httpMode HTTPMode // Replay tracks from cassette, if present, or make live calls but do not records new tracks. readOnly bool - - // Replay tracks from cassette, if present, but do not make live calls. - // govcr will return a transport error if no track was found. - // TODO: note it probably does not make sense to have Offline true and LiveOnly true - offlineMode bool } func (pcb *PrintedCircuitBoard) seekTrack(k7 *cassette.Cassette, httpRequest *http.Request) (*track.Track, error) { - if pcb.liveOnly { + if pcb.httpMode == HTTPModeLiveOnly { return nil, nil } @@ -44,7 +52,8 @@ func (pcb *PrintedCircuitBoard) seekTrack(k7 *cassette.Cassette, httpRequest *ht numberOfTracksInCassette := k7.NumberOfTracks() for trackNumber := int32(0); trackNumber < numberOfTracksInCassette; trackNumber++ { if pcb.trackMatches(k7, trackNumber, request) { - return pcb.replayTrack(k7, trackNumber) + currentReq := track.ToRequest(httpRequest) + return pcb.replayTrack(k7, trackNumber, currentReq) } } @@ -61,8 +70,21 @@ func (pcb *PrintedCircuitBoard) trackMatches(k7 *cassette.Cassette, trackNumber return !trk.IsReplayed() && pcb.requestMatcher.Match(httpRequestClone, trackReqClone) } -func (pcb *PrintedCircuitBoard) replayTrack(k7 *cassette.Cassette, trackNumber int32) (*track.Track, error) { - return k7.ReplayTrack(trackNumber) +func (pcb *PrintedCircuitBoard) replayTrack(k7 *cassette.Cassette, trackNumber int32, httpRequest *track.Request) (*track.Track, error) { + trk, err := k7.ReplayTrack(trackNumber) + if err != nil { + return nil, err + } + + // protect the original objects against mutation by the matcher + httpRequestClone := httpRequest.Clone() + + // inject current request into Response.Request + if trk.Response != nil { + trk.Response.Request = httpRequestClone + } + + return trk, nil } func (pcb *PrintedCircuitBoard) mutateTrackRecording(t *track.Track) { @@ -83,14 +105,19 @@ func (pcb *PrintedCircuitBoard) SetReadOnlyMode(state bool) { pcb.readOnly = state } -// SetOfflineMode sets the VCR to offline mode (true) or to normal live/replay (false). -func (pcb *PrintedCircuitBoard) SetOfflineMode(state bool) { - pcb.offlineMode = state +// SetNormalMode sets the VCR to normal HTTP mode. +func (pcb *PrintedCircuitBoard) SetNormalMode() { + pcb.httpMode = HTTPModeNormal +} + +// SetOfflineMode sets the VCR to offline mode. +func (pcb *PrintedCircuitBoard) SetOfflineMode() { + pcb.httpMode = HTTPModeOffline } -// SetLiveOnlyMode sets the VCR to live-only mode (true) or to normal live/replay (false). -func (pcb *PrintedCircuitBoard) SetLiveOnlyMode(state bool) { - pcb.liveOnly = state +// SetLiveOnlyMode sets the VCR to live-only mode. +func (pcb *PrintedCircuitBoard) SetLiveOnlyMode() { + pcb.httpMode = HTTPModeLiveOnly } // AddRecordingMutators adds a collection of recording TrackMutator's. diff --git a/pcb_wb_test.go b/pcb_wb_test.go index bed9ec6..cd330cc 100644 --- a/pcb_wb_test.go +++ b/pcb_wb_test.go @@ -9,8 +9,8 @@ import ( "net/textproto" "testing" - "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" "github.com/stretchr/testify/require" ) diff --git a/vcrsettings.go b/vcrsettings.go index 36dffc0..f82fced 100644 --- a/vcrsettings.go +++ b/vcrsettings.go @@ -3,8 +3,8 @@ package govcr import ( "net/http" - "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" ) // Setting defines an optional functional parameter as received by NewVCR(). @@ -60,7 +60,7 @@ func WithTrackReplayingMutators(trackReplayingMutators ...track.Mutator) Setting // Perhaps more useful when used in combination with 'readOnly' to by-pass govcr entirely. func WithLiveOnlyMode() Setting { return func(vcrSettings *VCRSettings) { - vcrSettings.liveOnly = true + vcrSettings.httpMode = HTTPModeLiveOnly } } @@ -77,7 +77,7 @@ func WithReadOnlyMode() Setting { // govcr will return a transport error if no track was found. func WithOfflineMode() Setting { return func(vcrSettings *VCRSettings) { - vcrSettings.offlineMode = true + vcrSettings.httpMode = HTTPModeOffline } } @@ -88,7 +88,6 @@ type VCRSettings struct { requestMatcher RequestMatcher trackRecordingMutators track.Mutators trackReplayingMutators track.Mutators - liveOnly bool + httpMode HTTPMode readOnly bool - offlineMode bool } diff --git a/vcrtransport.go b/vcrtransport.go index b9aa806..eaa2952 100644 --- a/vcrtransport.go +++ b/vcrtransport.go @@ -6,9 +6,9 @@ import ( "github.com/pkg/errors" - "github.com/seborama/govcr/v6/cassette" - "github.com/seborama/govcr/v6/cassette/track" - "github.com/seborama/govcr/v6/stats" + "github.com/seborama/govcr/v7/cassette" + "github.com/seborama/govcr/v7/cassette/track" + "github.com/seborama/govcr/v7/stats" ) // vcrTransport is the heart of VCR. It implements @@ -41,7 +41,7 @@ func (t *vcrTransport) RoundTrip(httpRequest *http.Request) (*http.Response, err } } - if t.pcb.offlineMode { + if t.pcb.httpMode == HTTPModeOffline { return nil, errors.New("no track matched on cassette and offline mode is active") } @@ -95,14 +95,19 @@ func (t *vcrTransport) SetReadOnlyMode(state bool) { t.pcb.SetReadOnlyMode(state) } -// SetOfflineMode sets the VCR to offline mode (true) or to normal live/replay (false). -func (t *vcrTransport) SetOfflineMode(state bool) { - t.pcb.SetOfflineMode(state) +// SetNormalMode sets the VCR to normal HTTP mode. +func (t *vcrTransport) SetNormalMode() { + t.pcb.SetNormalMode() } -// SetLiveOnlyMode sets the VCR to live-only mode (true) or to normal live/replay (false). -func (t *vcrTransport) SetLiveOnlyMode(state bool) { - t.pcb.SetLiveOnlyMode(state) +// SetOfflineMode sets the VCR to offline mode. +func (t *vcrTransport) SetOfflineMode() { + t.pcb.SetOfflineMode() +} + +// SetLiveOnlyMode sets the VCR to live-only mode. +func (t *vcrTransport) SetLiveOnlyMode() { + t.pcb.SetLiveOnlyMode() } // AddRecordingMutators adds a set of recording Track Mutator's to the VCR.