diff --git a/README.md b/README.md index c31eac8..92d6071 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,12 @@ This simply redirects all **govcr** logging to the OS's standard Null device (e. - http / https supported and any other protocol implemented by the supplied `http.Client`'s `http.RoundTripper`. -- Hook to define HTTP headers that should be excluded from the HTTP request when attemtping to retrieve a **track** for playback. +- Hook to define HTTP headers that should be ignored from the HTTP request when attemtping to retrieve a **track** for playback. This is useful to deal with non-static HTTP headers (for example, containing a timestamp). -- Hook to parse the Body of an HTTP request to deal with non-static data. The purpose is similar to the hook for headers described above. +- Hook to transform the Header / Body of an HTTP request to deal with non-static data. The purpose is similar to the hook for headers described above but with the ability to modify the data. + +- Hook to transform the Header / Body of the HTTP response to deal with non-static data. This is similar to the request hook however, the header / body of the request are also supplied (read-only) to help match data in the response with data in the request (such as a transaction Id). - Ability to switch off automatic recordings. This allows to play back existing records or make @@ -218,25 +220,6 @@ This example shows how to handle situations where a header in the request needs For this example, logging is switched on. This is achieved with `Logging: true` in `VCRConfig` when calling `NewVCR`. -Note 1: `RequestBodyFilterFunc` achieves a similar purpose with the Body of the **request**. - This is useful when some of the data in the **request** Body needs to be transformed before it - can be evaluated for comparison matching for playback. - -Note 2: `ResponseBodyFilterFunc` achieves a similar purpose with the Body of the **response**. - This is useful when some of the data in the **response** Body needs to be transformed before it - is supplied for playback. Example: the request and response exchange a fingerprint via a body - element that must match. - -Note 3: `ResponseHeaderFilterFunc` achieves a similar purpose with the Header of the **response**. - This is useful when some of the data in the **response** Header needs to be transformed before it - is supplied for playback. Example: the request and response exchange a fingerprint via a header - that must match. - -Note 4: ResponseBodyFilterFunc & ResponseHeaderFilterFunc only apply to the recorded response. - They are **not** executed against the live response! - -TO DO: add an example of either ResponseHeaderFilterFunc or ResponseBodyFilterFunc. - ```go package main @@ -281,6 +264,78 @@ func Example4() { } ``` +### Example 5 - Custom VCR with a ExcludeHeaderFunc and ResponseFilterFunc + +This example shows how to handle situations where a 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: `RequestFilterFunc` achieves a similar purpose with the **request** Header / Body. + 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 + +import ( + "fmt" + "strings" + "time" + + "net/http" + + "github.com/seborama/govcr" +) + +const example5CassetteName = "MyCassette5" + +// Example5 is an example use of govcr. +// Supposing a fictional application where the request contains a custom header +// 'X-Transaction-Id' which must be matched in the response from the server. +// When replaying, the request will have a different Transaction Id than that which was recorded. +// Hence the protocol (of this fictional example) is broken. +// To circumvent that, we inject the new request's X-Transaction-Id into the recorded response. +// Without the ResponseFilterFunc, the X-Transaction-Id in the header would not match that +// of the recorded response and our fictional application would reject the response on validation! +func Example5() { + vcr := govcr.NewVCR(example5CassetteName, + &govcr.VCRConfig{ + ExcludeHeaderFunc: func(key string) bool { + // ignore the X-Transaction-Id since it changes per-request + return strings.ToLower(key) == "x-transaction-id" + }, + ResponseFilterFunc: func(respHeader http.Header, respBody string, reqHeader http.Header) (*http.Header, *string) { + // overwrite X-Transaction-Id in the Response with that from the Request + respHeader.Set("X-Transaction-Id", reqHeader.Get("X-Transaction-Id")) + + return &respHeader, &respBody + }, + Logging: true, + }) + + // create a request with our custom header + req, err := http.NewRequest("POST", "http://example.com/foo5", nil) + if err != nil { + fmt.Println(err) + } + req.Header.Add("X-Transaction-Id", time.Now().String()) + + // run the request + resp, err := vcr.Client.Do(req) + if err != nil { + fmt.Println(err) + } + + // verify outcome + if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") { + fmt.Println("Header transaction Id verification failed - this would be the live request!") + } else { + fmt.Println("Header transaction Id verification passed - this would be the replayed track!") + } + + fmt.Printf("%+v\n", vcr.Stats()) +} +``` + ### Stats VCR provides some statistics. @@ -344,11 +399,24 @@ Complete ====================================================== Running Example4... 1st run ======================================================= -2016/07/17 00:08:01 INFO - Cassette 'MyCassette4' - Executing request to live server for POST http://example.com/foo -2016/07/17 00:08:02 INFO - Cassette 'MyCassette4' - Recording new track for POST http://example.com/foo +2016/09/12 22:22:20 INFO - Cassette 'MyCassette4' - Executing request to live server for POST http://example.com/foo +2016/09/12 22:22:20 INFO - Cassette 'MyCassette4' - Recording new track for POST http://example.com/foo +{TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} +2nd run ======================================================= +2016/09/12 22:22:20 INFO - Cassette 'MyCassette4' - Found a matching track for POST http://example.com/foo +{TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} +Complete ====================================================== + + +Running Example5... +1st run ======================================================= +2016/09/12 22:22:20 INFO - Cassette 'MyCassette5' - Executing request to live server for POST http://example.com/foo5 +2016/09/12 22:22:20 INFO - Cassette 'MyCassette5' - Recording new track for POST http://example.com/foo5 +Header transaction Id verification failed - this would be the live request! {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 2nd run ======================================================= -2016/07/17 00:08:02 INFO - Cassette 'MyCassette4' - Found a matching track for POST http://example.com/foo +2016/09/12 22:22:20 INFO - Cassette 'MyCassette5' - Found a matching track for POST http://example.com/foo5 +Header transaction Id verification passed - this would be the replayed track! {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} Complete ====================================================== ``` diff --git a/examples/example5.go b/examples/example5.go new file mode 100644 index 0000000..8b0548a --- /dev/null +++ b/examples/example5.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "net/http" + + "github.com/seborama/govcr" +) + +const example5CassetteName = "MyCassette5" + +// Example5 is an example use of govcr. +// Supposing a fictional application where the request contains a custom header +// 'X-Transaction-Id' which must be matched in the response from the server. +// When replaying, the request will have a different Transaction Id than that which was recorded. +// Hence the protocol (of this fictional example) is broken. +// To circumvent that, we inject the new request's X-Transaction-Id into the recorded response. +// Without the ResponseFilterFunc, the X-Transaction-Id in the header would not match that +// of the recorded response and our fictional application would reject the response on validation! +func Example5() { + vcr := govcr.NewVCR(example5CassetteName, + &govcr.VCRConfig{ + ExcludeHeaderFunc: func(key string) bool { + // ignore the X-Transaction-Id since it changes per-request + return strings.ToLower(key) == "x-transaction-id" + }, + ResponseFilterFunc: func(respHeader http.Header, respBody string, reqHeader http.Header) (*http.Header, *string) { + // overwrite X-Transaction-Id in the Response with that from the Request + respHeader.Set("X-Transaction-Id", reqHeader.Get("X-Transaction-Id")) + + return &respHeader, &respBody + }, + Logging: true, + }) + + // create a request with our custom header + req, err := http.NewRequest("POST", "http://example.com/foo5", nil) + if err != nil { + fmt.Println(err) + } + req.Header.Add("X-Transaction-Id", time.Now().String()) + + // run the request + resp, err := vcr.Client.Do(req) + if err != nil { + fmt.Println(err) + } + + // verify outcome + if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") { + fmt.Println("Header transaction Id verification failed - this would be the live request!") + } else { + fmt.Println("Header transaction Id verification passed - this would be the replayed track!") + } + + fmt.Printf("%+v\n", vcr.Stats()) +} diff --git a/examples/main.go b/examples/main.go index 11d5ceb..3608e2e 100644 --- a/examples/main.go +++ b/examples/main.go @@ -17,4 +17,5 @@ func main() { runExample("Example2", example2CassetteName, Example2) runExample("Example3", example3CassetteName, Example3) runExample("Example4", example4CassetteName, Example4) + runExample("Example5", example5CassetteName, Example5) } diff --git a/govcr.go b/govcr.go index 621613d..a35b41a 100644 --- a/govcr.go +++ b/govcr.go @@ -26,17 +26,13 @@ const defaultCassettePath = "./govcr-fixtures/" // VCRConfig holds a set of options for the VCR. type VCRConfig struct { - Client *http.Client - ExcludeHeaderFunc ExcludeHeaderFunc - RequestBodyFilterFunc BodyFilterFunc - - // ResponseHeaderFilterFunc can be used to modify the header of the response. - // This is useful when a fingerprint is exchanged and expected to match between request and response. - ResponseHeaderFilterFunc HeaderFilterFunc + Client *http.Client + ExcludeHeaderFunc ExcludeHeaderFunc + RequestFilterFunc RequestFilterFunc - // ResponseBodyFilterFunc can be used to modify the body of the response. + // ResponseFilterFunc can be used to modify the header of the response. // This is useful when a fingerprint is exchanged and expected to match between request and response. - ResponseBodyFilterFunc BodyFilterFunc + ResponseFilterFunc ResponseFilterFunc DisableRecording bool Logging bool @@ -46,14 +42,13 @@ type VCRConfig struct { // PCB stands for Printed Circuit Board. It is a structure that holds some // facilities that are passed to the VCR machine to modify its internals. type pcb struct { - Transport http.RoundTripper - ExcludeHeaderFunc ExcludeHeaderFunc - RequestBodyFilterFunc BodyFilterFunc - ResponseHeaderFilterFunc HeaderFilterFunc - ResponseBodyFilterFunc BodyFilterFunc - Logger *log.Logger - DisableRecording bool - CassettePath string + Transport http.RoundTripper + ExcludeHeaderFunc ExcludeHeaderFunc + RequestFilterFunc RequestFilterFunc + ResponseFilterFunc ResponseFilterFunc + Logger *log.Logger + DisableRecording bool + CassettePath string } const trackNotFound = -1 @@ -84,11 +79,16 @@ func (pcbr *pcb) trackMatches(cassette *cassette, trackNumber int, req *http.Req track := cassette.Tracks[trackNumber] + // apply filter function to track header / body + filteredTrackHeader, filteredTrackBody := pcbr.RequestFilterFunc(track.Request.Header, track.Request.Body) + // apply filter function to request header / body + filteredReqHeader, filteredReqBody := pcbr.RequestFilterFunc(req.Header, bodyData) + return !track.replayed && track.Request.Method == req.Method && track.Request.URL.String() == req.URL.String() && - pcbr.headerResembles(track.Request.Header, req.Header) && - pcbr.bodyResembles(track.Request.Body, bodyData) + pcbr.headerResembles(*filteredTrackHeader, *filteredReqHeader) && + pcbr.bodyResembles(*filteredTrackBody, *filteredReqBody) } // headerResembles compares HTTP headers for equivalence. @@ -107,22 +107,19 @@ func (pcbr *pcb) headerResembles(header1 http.Header, header2 http.Header) bool // bodyResembles compares HTTP bodies for equivalence. func (pcbr *pcb) bodyResembles(body1 string, body2 string) bool { - return *pcbr.RequestBodyFilterFunc(body1) == *pcbr.RequestBodyFilterFunc(body2) + return body1 == body2 } -func (pcbr *pcb) filterHeader(resp *http.Response, reqHdr http.Header) *http.Response { - resp.Header = *pcbr.ResponseHeaderFilterFunc(resp.Header, reqHdr) - return resp -} - -func (pcbr *pcb) filterBody(resp *http.Response) *http.Response { +func (pcbr *pcb) filterResponse(resp *http.Response, reqHdr http.Header) *http.Response { body, err := readResponseBody(resp) if err != nil { pcbr.Logger.Printf("ERROR - Unable to filter response body so leaving it untouched: %s\n", err.Error()) return resp } - resp.Body = toReadCloser(*pcbr.ResponseBodyFilterFunc(body)) + newHeader, newBody := pcbr.ResponseFilterFunc(resp.Header, body, reqHdr) + resp.Header = *newHeader + resp.Body = toReadCloser(*newBody) return resp } @@ -175,21 +172,15 @@ func NewVCR(cassetteName string, vcrConfig *VCRConfig) *VCRControlPanel { } } - if vcrConfig.RequestBodyFilterFunc == nil { - vcrConfig.RequestBodyFilterFunc = func(body string) *string { - return &body + if vcrConfig.RequestFilterFunc == nil { + vcrConfig.RequestFilterFunc = func(header http.Header, body string) (*http.Header, *string) { + return &header, &body } } - if vcrConfig.ResponseHeaderFilterFunc == nil { - vcrConfig.ResponseHeaderFilterFunc = func(respHdr http.Header, reqHdr http.Header) *http.Header { - return &respHdr - } - } - - if vcrConfig.ResponseBodyFilterFunc == nil { - vcrConfig.ResponseBodyFilterFunc = func(body string) *string { - return &body + if vcrConfig.ResponseFilterFunc == nil { + vcrConfig.ResponseFilterFunc = func(respHdr http.Header, body string, reqHdr http.Header) (*http.Header, *string) { + return &respHdr, &body } } @@ -202,14 +193,13 @@ func NewVCR(cassetteName string, vcrConfig *VCRConfig) *VCRControlPanel { // create PCB pcbr := &pcb{ // TODO: create appropriate test! - DisableRecording: vcrConfig.DisableRecording, - Transport: vcrConfig.Client.Transport, - ExcludeHeaderFunc: vcrConfig.ExcludeHeaderFunc, - RequestBodyFilterFunc: vcrConfig.RequestBodyFilterFunc, - ResponseHeaderFilterFunc: vcrConfig.ResponseHeaderFilterFunc, - ResponseBodyFilterFunc: vcrConfig.ResponseBodyFilterFunc, - Logger: logger, - CassettePath: vcrConfig.CassettePath, + DisableRecording: vcrConfig.DisableRecording, + Transport: vcrConfig.Client.Transport, + ExcludeHeaderFunc: vcrConfig.ExcludeHeaderFunc, + RequestFilterFunc: vcrConfig.RequestFilterFunc, + ResponseFilterFunc: vcrConfig.ResponseFilterFunc, + Logger: logger, + CassettePath: vcrConfig.CassettePath, } // create VCR's HTTP client @@ -238,34 +228,57 @@ func NewVCR(cassetteName string, vcrConfig *VCRConfig) *VCRControlPanel { // For instance, if your application sends requests with a timestamp held in a custom header, // you likely want to exclude it from the comparison to ensure that the request headers are // considered a match with those saved on the cassette's track. -type ExcludeHeaderFunc func(key string) bool - -// BodyFilterFunc is a hook function that is used to filter the Body. // -// Typically this can be used to remove / amend undesirable body elements from the request. +// Parameters: +// - parameter 1 - Name of header key in the Request // -// For instance, if your application sends requests with a timestamp held in a part of the body, -// you likely want to remove it or force a static timestamp via BodyFilterFunc to -// ensure that the request body matches those saved on the cassette's track. -type BodyFilterFunc func(string) *string +// Return value: +// true - retain header key for comparison +// false - ignore header key for comparison +type ExcludeHeaderFunc func(key string) bool -// HeaderFilterFunc is a hook function that is used to filter the Header. +// RequestFilterFunc is a hook function that is used to filter the Request Header / Body. +// +// Typically this can be used to remove / amend undesirable header / body elements from the request. // -// It works like BodyFilterFunc but applies to the header. +// For instance, if your application sends requests with a timestamp held in a part of +// the header / body, you likely want to remove it or force a static timestamp via +// RequestFilterFunc to ensure that the request body matches those saved on the cassette's track. // // It is important to note that this differs from ExcludeHeaderFunc in that the former does not // modify the header (it only returns a bool) whereas this function can be used to modify the header. -type HeaderFilterFunc func(http.Header, http.Header) *http.Header +// +// Parameters: +// - parameter 1 - Copy of http.Header of the Request +// - parameter 2 - Copy of string of the Request's Body +// +// Return values: +// - value 1 - Request's amended header +// - value 1 - Request's amended body +type RequestFilterFunc func(http.Header, string) (*http.Header, *string) + +// ResponseFilterFunc is a hook function that is used to filter the Response Header / Body. +// +// It works similarly to RequestFilterFunc but applies to the Response and also receives a +// copy of the Request's header (if you need to pick info from it to override the response). +// +// Parameters: +// - parameter 1 - Copy of http.Header of the Response +// - parameter 2 - Copy of string of the Response's Body +// - parameter 3 - Copy of http.Header of the Request +// +// Return values: +// - value 1 - Response's amended header +// - value 1 - Response's amended body +type ResponseFilterFunc func(http.Header, string, http.Header) (*http.Header, *string) // vcrTransport is the heart of VCR. It provides // an http.RoundTripper that wraps over the default // one provided by Go's http package or a custom one // if specified when calling NewVCR. type vcrTransport struct { - PCB *pcb - Cassette *cassette - ExcludeHeaderFunc ExcludeHeaderFunc - RequestBodyFilter BodyFilterFunc + PCB *pcb + Cassette *cassette } // RoundTrip is an implementation of http.RoundTripper. @@ -289,7 +302,7 @@ func (t *vcrTransport) RoundTrip(req *http.Request) (*http.Response, error) { // the request if one exists. if trackNumber := t.PCB.seekTrack(t.Cassette, copiedReq); trackNumber != trackNotFound { // only the played back response is filtered. Never the live response! - resp = t.PCB.filterHeader(t.PCB.filterBody(t.Cassette.replayResponse(trackNumber, copiedReq)), copiedReq.Header) + resp = t.PCB.filterResponse(t.Cassette.replayResponse(trackNumber, copiedReq), copiedReq.Header) requestMatched = true }