├── doc.go ├── examples ├── example1.go ├── main.go ├── example4.go ├── example2.go ├── example3.go ├── example5.go ├── example7.go └── example6.go ├── .gitignore ├── Makefile ├── govcr_example4_test.go ├── govcr_example1_test.go ├── vcr_transport.go ├── govcr_example3_test.go ├── vcr_transport_test.go ├── govcr_example2_test.go ├── pcb.go ├── response.go ├── govcr_example7_test.go ├── request.go ├── request_test.go ├── response_test.go ├── govcr_example6_test.go ├── cassette_test.go ├── govcr.go ├── govcr_test.go ├── LICENSE ├── cassette.go └── README.md /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package govcr records and replays HTTP interactions for offline unit / behavioural / integration tests thereby acting as an HTTP mock. 3 | 4 | This project was inspired by php-vcr which is a PHP port of VCR for ruby. 5 | 6 | For usage and more information, please refer to the project's README at: 7 | 8 | https://github.com/seborama/govcr 9 | */ 10 | package govcr 11 | -------------------------------------------------------------------------------- /examples/example1.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/seborama/govcr" 7 | ) 8 | 9 | const example1CassetteName = "MyCassette1" 10 | 11 | // Example1 is an example use of govcr. 12 | func Example1() { 13 | vcr := govcr.NewVCR(example1CassetteName, nil) 14 | vcr.Client.Get("http://www.example.com/foo") 15 | fmt.Printf("%+v\n", vcr.Stats()) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project-specific folders 2 | /.idea 3 | /.vscode 4 | govcr-fixtures 5 | # Project-specific files 6 | *.swp 7 | *.DS_Store 8 | 9 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 10 | *.o 11 | *.a 12 | *.so 13 | 14 | # Folders 15 | _obj 16 | _test 17 | 18 | # Architecture specific extensions/prefixes 19 | *.[568vq] 20 | [568vq].out 21 | 22 | *.cgo1.go 23 | *.cgo2.c 24 | _cgo_defun.c 25 | _cgo_gotypes.go 26 | _cgo_export.* 27 | 28 | _testmain.go 29 | 30 | *.exe 31 | *.test 32 | *.prof 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DEPS:=$$(go list -f '{{range .TestImports}}{{.}} {{end}}' ./...) 2 | MAINFILES=$$(go list -f '{{join .GoFiles " "}}') 3 | 4 | deps: 5 | go get -d -t -v ./... $(DEPS) 6 | 7 | test: deps 8 | go test -cover -race -parallel 2 ./... 9 | 10 | examples: deps 11 | go run ./examples/*.go 12 | 13 | lint: deps 14 | @-echo "NOTE: some linters (gotype) require a recent 'go install'" 15 | go get -u github.com/alecthomas/gometalinter 16 | gometalinter --install --update 17 | gometalinter --deadline=300s --disable=dupl --concurrency=2 ./... 18 | 19 | goconvey: 20 | @-killall goconvey 21 | @-echo "NOTE: you may be required to perform a recent 'go install'" 22 | ${GOPATH}/bin/goconvey -depth=100 -timeout=600s -excludedDirs=.git,.vscode,.idea -packages=2 -cover -poll=5000ms -port=6020 1>/dev/null 2>&1 & 23 | 24 | godoc: 25 | @-killall ${GOROOT}/bin/godoc 26 | ${GOROOT}/bin/godoc -index_throttle 0.50 -index -play -analysis type -http=:6060 1>/dev/null 2>&1 & 27 | sleep 5 28 | open http://127.0.0.1:6060 29 | 30 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/seborama/govcr" 8 | ) 9 | 10 | func runExample(name, cassetteName string, f func()) { 11 | fmt.Println("Running " + name + "...") 12 | fmt.Println("1st run =======================================================") 13 | if err := govcr.DeleteCassette(cassetteName, ""); err != nil { 14 | fmt.Printf("Error deleting cassette '%s' - %s\n", cassetteName, err.Error()) 15 | os.Exit(10) 16 | } 17 | f() 18 | fmt.Println("2nd run =======================================================") 19 | f() 20 | fmt.Println("Complete ======================================================") 21 | fmt.Println() 22 | } 23 | func main() { 24 | runExample("Example1", example1CassetteName, Example1) 25 | runExample("Example2", example2CassetteName, Example2) 26 | runExample("Example3", example3CassetteName, Example3) 27 | runExample("Example4", example4CassetteName, Example4) 28 | runExample("Example5", example5CassetteName, Example5) 29 | runExample("Example6", example6CassetteName, Example6) 30 | runExample("Example7", example7CassetteName, Example7) 31 | } 32 | -------------------------------------------------------------------------------- /examples/example4.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/seborama/govcr" 9 | ) 10 | 11 | const example4CassetteName = "MyCassette4" 12 | 13 | // Example4 is an example use of govcr. 14 | // The request contains a customer header 'X-Custom-My-Date' which varies with every request. 15 | // This example shows how to exclude a particular header from the request to facilitate 16 | // matching a previous recording. 17 | // Without the RequestFilters, the headers would not match and hence the playback would not 18 | // happen! 19 | func Example4() { 20 | vcr := govcr.NewVCR(example4CassetteName, 21 | &govcr.VCRConfig{ 22 | RequestFilters: govcr.RequestFilters{ 23 | govcr.RequestDeleteHeaderKeys("X-Custom-My-Date"), 24 | }, 25 | Logging: true, 26 | }) 27 | 28 | // create a request with our custom header 29 | req, err := http.NewRequest("POST", "http://www.example.com/foo", nil) 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | req.Header.Add("X-Custom-My-Date", time.Now().String()) 34 | 35 | // run the request 36 | vcr.Client.Do(req) 37 | fmt.Printf("%+v\n", vcr.Stats()) 38 | } 39 | -------------------------------------------------------------------------------- /govcr_example4_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | 8 | "github.com/seborama/govcr" 9 | ) 10 | 11 | const example4CassetteName = "MyCassette4" 12 | 13 | func runTestEx4() { 14 | // Create vcr and make http call 15 | vcr := govcr.NewVCR(example4CassetteName, nil) 16 | resp, _ := vcr.Client.Get("http://www.example.com/foo") 17 | 18 | // Show results 19 | fmt.Printf("%d ", resp.StatusCode) 20 | fmt.Printf("%s ", resp.Header.Get("Content-Type")) 21 | 22 | body, _ := ioutil.ReadAll(resp.Body) 23 | resp.Body.Close() 24 | fmt.Printf("%v ", strings.Contains(string(body), "domain in examples without prior coordination or asking for permission.")) 25 | 26 | fmt.Printf("%+v\n", vcr.Stats()) 27 | } 28 | 29 | // Example_simpleVCR is an example use of govcr. 30 | // It shows a simple use of a Long Play cassette (i.e. compressed). 31 | func Example_number4SimpleVCR() { 32 | // Delete cassette to enable live HTTP call 33 | govcr.DeleteCassette(example4CassetteName, "") 34 | 35 | // 1st run of the test - will use live HTTP calls 36 | runTestEx4() 37 | // 2nd run of the test - will use playback 38 | runTestEx4() 39 | 40 | // Output: 41 | // 404 text/html; charset=UTF-8 true {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 42 | // 404 text/html; charset=UTF-8 true {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 43 | } 44 | -------------------------------------------------------------------------------- /govcr_example1_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | 8 | "github.com/seborama/govcr" 9 | ) 10 | 11 | const example1CassetteName = "MyCassette1" 12 | 13 | func runTestEx1() { 14 | // Create vcr and make http call 15 | vcr := govcr.NewVCR(example1CassetteName, nil) 16 | resp, _ := vcr.Client.Get("http://www.example.com/foo") 17 | 18 | // Show results 19 | fmt.Printf("%d ", resp.StatusCode) 20 | fmt.Printf("%s ", resp.Header.Get("Content-Type")) 21 | 22 | body, _ := ioutil.ReadAll(resp.Body) 23 | resp.Body.Close() 24 | fmt.Printf("%v ", strings.Contains(string(body), "domain in examples without prior coordination or asking for permission.")) 25 | 26 | fmt.Printf("%+v\n", vcr.Stats()) 27 | } 28 | 29 | // Example_simpleVCR is an example use of govcr. 30 | // It shows how to use govcr in the simplest case when the default 31 | // http.Client suffices. 32 | func Example_number1SimpleVCR() { 33 | // Delete cassette to enable live HTTP call 34 | govcr.DeleteCassette(example1CassetteName, "") 35 | 36 | // 1st run of the test - will use live HTTP calls 37 | runTestEx1() 38 | // 2nd run of the test - will use playback 39 | runTestEx1() 40 | 41 | // Output: 42 | // 404 text/html; charset=UTF-8 true {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 43 | // 404 text/html; charset=UTF-8 true {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 44 | } 45 | -------------------------------------------------------------------------------- /examples/example2.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/seborama/govcr" 10 | ) 11 | 12 | const example2CassetteName = "MyCassette2" 13 | 14 | // myApp is an application container. 15 | type myApp struct { 16 | httpClient *http.Client 17 | } 18 | 19 | func (app myApp) Get(url string) { 20 | app.httpClient.Get(url) 21 | } 22 | 23 | // Example2 is an example use of govcr. 24 | // It shows the use of a VCR with a custom Client. 25 | // Here, the app executes a GET request. 26 | func Example2() { 27 | // Create a custom http.Transport. 28 | tr := http.DefaultTransport.(*http.Transport) 29 | tr.TLSClientConfig = &tls.Config{ 30 | InsecureSkipVerify: true, // just an example, not recommended 31 | } 32 | 33 | // Create an instance of myApp. 34 | // It uses the custom Transport created above and a custom Timeout. 35 | myapp := &myApp{ 36 | httpClient: &http.Client{ 37 | Transport: tr, 38 | Timeout: 15 * time.Second, 39 | }, 40 | } 41 | 42 | // Instantiate VCR. 43 | vcr := govcr.NewVCR(example2CassetteName, 44 | &govcr.VCRConfig{ 45 | Client: myapp.httpClient, 46 | }) 47 | 48 | // Inject VCR's http.Client wrapper. 49 | // The original transport has been preserved, only just wrapped into VCR's. 50 | myapp.httpClient = vcr.Client 51 | 52 | // Run request and display stats. 53 | myapp.Get("https://www.example.com/foo") 54 | fmt.Printf("%+v\n", vcr.Stats()) 55 | } 56 | -------------------------------------------------------------------------------- /examples/example3.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/seborama/govcr" 10 | 11 | "strings" 12 | ) 13 | 14 | const example3CassetteName = "MyCassette3" 15 | 16 | func (app myApp) Post(url string) { 17 | // beware: don't use a ReadCloser, only a Reader! 18 | body := strings.NewReader(`{"Msg": "This is an example request"}`) 19 | app.httpClient.Post(url, "application/json", body) 20 | } 21 | 22 | // Example3 is an example use of govcr. 23 | // It shows the use of a VCR with a custom Client. 24 | // Here, the app executes a POST request. 25 | func Example3() { 26 | // Create a custom http.Transport. 27 | tr := http.DefaultTransport.(*http.Transport) 28 | tr.TLSClientConfig = &tls.Config{ 29 | InsecureSkipVerify: true, // just an example, not recommended 30 | } 31 | 32 | // Create an instance of myApp. 33 | // It uses the custom Transport created above and a custom Timeout. 34 | myapp := &myApp{ 35 | httpClient: &http.Client{ 36 | Transport: tr, 37 | Timeout: 15 * time.Second, 38 | }, 39 | } 40 | 41 | // Instantiate VCR. 42 | vcr := govcr.NewVCR(example3CassetteName, 43 | &govcr.VCRConfig{ 44 | Client: myapp.httpClient, 45 | }) 46 | 47 | // Inject VCR's http.Client wrapper. 48 | // The original transport has been preserved, only just wrapped into VCR's. 49 | myapp.httpClient = vcr.Client 50 | 51 | // Run request and display stats. 52 | myapp.Post("https://www.example.com/foo") 53 | fmt.Printf("%+v\n", vcr.Stats()) 54 | } 55 | -------------------------------------------------------------------------------- /vcr_transport.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // vcrTransport is the heart of VCR. It provides 8 | // an http.RoundTripper that wraps over the default 9 | // one provided by Go's http package or a custom one 10 | // if specified when calling NewVCR. 11 | type vcrTransport struct { 12 | PCB *pcb 13 | Cassette *cassette 14 | } 15 | 16 | // RoundTrip is an implementation of http.RoundTripper. 17 | func (t *vcrTransport) RoundTrip(req *http.Request) (*http.Response, error) { 18 | // Note: by convention resp should be nil if an error occurs with HTTP 19 | var resp *http.Response 20 | 21 | // copy the request before the body is closed by the HTTP server. 22 | copiedReq, err := copyRequest(req) 23 | if err != nil { 24 | t.PCB.Logger.Println(err) 25 | return nil, err 26 | } 27 | 28 | // attempt to use a track from the cassette that matches 29 | // the request if one exists. 30 | if filteredResp, trackNumber, err := t.PCB.seekTrack(t.Cassette, copiedReq); trackNumber != trackNotFound { 31 | // Only the played back response is filtered. 32 | // The live request and response should NOT EVER be changed! 33 | 34 | // TODO: add a test to confirm err is replaying errors correctly (see cassette_test.go / Test_trackReplaysError) 35 | return filteredResp, err 36 | } 37 | 38 | t.PCB.Logger.Printf("INFO - Cassette '%s' - Executing request to live server for %s %s\n", t.Cassette.Name, req.Method, req.URL.String()) 39 | resp, err = t.PCB.Transport.RoundTrip(req) 40 | 41 | if !t.PCB.DisableRecording { 42 | // the VCR is not in read-only mode so 43 | t.PCB.Logger.Printf("INFO - Cassette '%s' - Recording new track for %s %s as %s %s\n", t.Cassette.Name, req.Method, req.URL.String(), copiedReq.Method, copiedReq.URL) 44 | if err := recordNewTrackToCassette(t.Cassette, copiedReq, resp, err); err != nil { 45 | t.PCB.Logger.Println(err) 46 | } 47 | } 48 | 49 | return resp, err 50 | } 51 | -------------------------------------------------------------------------------- /examples/example5.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/seborama/govcr" 9 | ) 10 | 11 | const example5CassetteName = "MyCassette5" 12 | 13 | // Example5 is an example use of govcr. 14 | // Supposing a fictional application where the request contains a custom header 15 | // 'X-Transaction-Id' which must be matched in the response from the server. 16 | // When replaying, the request will have a different Transaction Id than that which was recorded. 17 | // Hence the protocol (of this fictional example) is broken. 18 | // To circumvent that, we inject the new request's X-Transaction-Id into the recorded response. 19 | // Without the ResponseFilterFunc, the X-Transaction-Id in the header would not match that 20 | // of the recorded response and our fictional application would reject the response on validation! 21 | func Example5() { 22 | vcr := govcr.NewVCR(example5CassetteName, 23 | &govcr.VCRConfig{ 24 | RequestFilters: govcr.RequestFilters{ 25 | govcr.RequestDeleteHeaderKeys("X-Transaction-Id"), 26 | }, 27 | ResponseFilters: govcr.ResponseFilters{ 28 | // overwrite X-Transaction-Id in the Response with that from the Request 29 | govcr.ResponseTransferHeaderKeys("X-Transaction-Id"), 30 | }, 31 | Logging: true, 32 | }) 33 | 34 | // create a request with our custom header 35 | req, err := http.NewRequest("POST", "http://www.example.com/foo5", nil) 36 | if err != nil { 37 | fmt.Println(err) 38 | } 39 | req.Header.Add("X-Transaction-Id", time.Now().String()) 40 | 41 | // run the request 42 | resp, err := vcr.Client.Do(req) 43 | if err != nil { 44 | fmt.Println(err) 45 | } 46 | 47 | // verify outcome 48 | if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") { 49 | fmt.Println("Header transaction Id verification failed - this would be the live request!") 50 | } else { 51 | fmt.Println("Header transaction Id verification passed - this would be the replayed track!") 52 | } 53 | 54 | fmt.Printf("%+v\n", vcr.Stats()) 55 | } 56 | -------------------------------------------------------------------------------- /govcr_example3_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/seborama/govcr" 11 | ) 12 | 13 | const example3CassetteName = "MyCassette3" 14 | 15 | func runTestEx3() { 16 | var samples = []struct { 17 | method string 18 | body string 19 | }{ 20 | {"GET", "domain in examples without prior coordination or asking for permission."}, 21 | {"POST", "404 - Not Found"}, 22 | {"PUT", ""}, 23 | {"DELETE", ""}, 24 | } 25 | 26 | // Create vcr 27 | vcr := govcr.NewVCR(example3CassetteName, 28 | &govcr.VCRConfig{ 29 | RequestFilters: govcr.RequestFilters{ 30 | govcr.RequestDeleteHeaderKeys("X-Custom-My-Date"), 31 | }, 32 | }) 33 | 34 | for _, td := range samples { 35 | // Create a request with our custom header 36 | req, _ := http.NewRequest(td.method, "http://www.example.com/foo", nil) 37 | req.Header.Add("X-Custom-My-Date", time.Now().String()) 38 | 39 | // Make http call 40 | resp, _ := vcr.Client.Do(req) 41 | 42 | // Show results 43 | fmt.Printf("%d ", resp.StatusCode) 44 | fmt.Printf("%s ", resp.Header.Get("Content-Type")) 45 | 46 | body, _ := ioutil.ReadAll(resp.Body) 47 | resp.Body.Close() 48 | fmt.Printf("%v ", strings.Contains(string(body), td.body)) 49 | } 50 | 51 | fmt.Printf("%+v\n", vcr.Stats()) 52 | } 53 | 54 | // Example_simpleVCR is an example use of govcr. 55 | // It shows how to use govcr in the simplest case when the default 56 | // http.Client suffices. 57 | func Example_number3HeaderExclusionVCR() { 58 | // Delete cassette to enable live HTTP call 59 | govcr.DeleteCassette(example3CassetteName, "") 60 | 61 | // 1st run of the test - will use live HTTP calls 62 | runTestEx3() 63 | // 2nd run of the test - will use playback 64 | runTestEx3() 65 | 66 | // Output: 67 | // 404 text/html; charset=UTF-8 true 404 text/html; charset=UTF-8 true 404 text/html; charset=UTF-8 true 404 text/html; charset=UTF-8 true {TracksLoaded:0 TracksRecorded:4 TracksPlayed:0} 68 | // 404 text/html; charset=UTF-8 true 404 text/html; charset=UTF-8 true 404 text/html; charset=UTF-8 true 404 text/html; charset=UTF-8 true {TracksLoaded:4 TracksRecorded:0 TracksPlayed:4} 69 | } 70 | -------------------------------------------------------------------------------- /vcr_transport_test.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | type mockRoundTripper struct{} 12 | 13 | func (t *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 14 | return &http.Response{ 15 | Request: req, 16 | StatusCode: http.StatusMovedPermanently, 17 | }, nil 18 | } 19 | 20 | func Test_vcrTransport_RoundTrip_doesNotChangeLiveReqOrLiveResp(t *testing.T) { 21 | logger := log.New(os.Stderr, "", log.LstdFlags) 22 | out, err := os.OpenFile(os.DevNull, os.O_WRONLY|os.O_APPEND, 0600) 23 | if err != nil { 24 | t.Errorf("unable to initialise logger - error = %v", err) 25 | return 26 | } 27 | defer func() { out.Close() }() 28 | logger.SetOutput(out) 29 | 30 | mutateReq := RequestFilter(func(req Request) Request { 31 | req.Method = "INVALID" 32 | req.URL.Host = "host.changed.internal" 33 | return req 34 | }) 35 | requestFilters := RequestFilters{} 36 | requestFilters.Add(mutateReq) 37 | 38 | mutateResp := ResponseFilter(func(resp Response) Response { 39 | resp.StatusCode = -9999 40 | return resp 41 | }) 42 | responseFilters := ResponseFilters{} 43 | responseFilters.Add(mutateResp) 44 | 45 | mrt := &mockRoundTripper{} 46 | transport := &vcrTransport{ 47 | PCB: &pcb{ 48 | DisableRecording: true, 49 | Transport: mrt, 50 | RequestFilter: requestFilters.combined(), 51 | ResponseFilter: responseFilters.combined(), 52 | Logger: logger, 53 | CassettePath: "", 54 | }, 55 | Cassette: &cassette{}, 56 | } 57 | 58 | req, err := http.NewRequest("GET", "https://example.com/path?query", toReadCloser([]byte("Lorem ipsum dolor sit amet"))) 59 | if err != nil { 60 | t.Errorf("req http.NewRequest() error = %v", err) 61 | return 62 | } 63 | 64 | wantReq, err := http.NewRequest("GET", "https://example.com/path?query", toReadCloser([]byte("Lorem ipsum dolor sit amet"))) 65 | if err != nil { 66 | t.Errorf("wantReq http.NewRequest() error = %v", err) 67 | return 68 | } 69 | 70 | gotResp, err := transport.RoundTrip(req) 71 | if err != nil { 72 | t.Errorf("vcrTransport.RoundTrip() error = %v", err) 73 | return 74 | } 75 | wantResp := http.Response{ 76 | Request: wantReq, 77 | StatusCode: http.StatusMovedPermanently, 78 | } 79 | 80 | if !reflect.DeepEqual(req, wantReq) { 81 | t.Errorf("vcrTransport.RoundTrip() Request has been modified = %+v, want %+v", req, wantReq) 82 | } 83 | 84 | if !reflect.DeepEqual(gotResp, &wantResp) { 85 | t.Errorf("vcrTransport.RoundTrip() Response has been modified = %+v, want %+v", gotResp, wantResp) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /govcr_example2_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/seborama/govcr" 12 | ) 13 | 14 | const example2CassetteName = "MyCassette2" 15 | 16 | // myApp is an application container. 17 | type myApp struct { 18 | httpClient *http.Client 19 | } 20 | 21 | func (app *myApp) Get(url string) (*http.Response, error) { 22 | return app.httpClient.Get(url) 23 | } 24 | 25 | func (app *myApp) Post(url string) (*http.Response, error) { 26 | // beware: don't use a ReadCloser, only a Reader! 27 | body := strings.NewReader(`{"Msg": "This is an example request"}`) 28 | return app.httpClient.Post(url, "application/json", body) 29 | } 30 | 31 | func runTestEx2(app *myApp) { 32 | var samples = []struct { 33 | f func(string) (*http.Response, error) 34 | body string 35 | }{ 36 | {app.Get, "domain in examples without prior coordination or asking for permission."}, 37 | {app.Post, "404 - Not Found"}, 38 | } 39 | 40 | // Instantiate VCR. 41 | vcr := govcr.NewVCR(example2CassetteName, 42 | &govcr.VCRConfig{ 43 | Client: app.httpClient, 44 | }) 45 | 46 | // Inject VCR's http.Client wrapper. 47 | // The original transport has been preserved, only just wrapped into VCR's. 48 | app.httpClient = vcr.Client 49 | 50 | for _, td := range samples { 51 | // Run HTTP call 52 | resp, _ := td.f("https://www.example.com/foo") 53 | 54 | // Show results 55 | fmt.Printf("%d ", resp.StatusCode) 56 | fmt.Printf("%s ", resp.Header.Get("Content-Type")) 57 | 58 | body, _ := ioutil.ReadAll(resp.Body) 59 | resp.Body.Close() 60 | fmt.Printf("%v - ", strings.Contains(string(body), td.body)) 61 | } 62 | 63 | fmt.Printf("%+v\n", vcr.Stats()) 64 | } 65 | 66 | // Example2 is an example use of govcr. 67 | // It shows the use of a VCR with a custom Client. 68 | // Here, the app executes a GET request. 69 | func Example_number2CustomClientVCR1() { 70 | // Create a custom http.Transport. 71 | tr := http.DefaultTransport.(*http.Transport) 72 | tr.TLSClientConfig = &tls.Config{ 73 | InsecureSkipVerify: true, // just an example, not recommended 74 | } 75 | 76 | // Create an instance of myApp. 77 | // It uses the custom Transport created above and a custom Timeout. 78 | app := &myApp{ 79 | httpClient: &http.Client{ 80 | Transport: tr, 81 | Timeout: 15 * time.Second, 82 | }, 83 | } 84 | 85 | // Delete cassette to enable live HTTP call 86 | govcr.DeleteCassette(example2CassetteName, "") 87 | 88 | // 1st run of the test - will use live HTTP calls 89 | runTestEx2(app) 90 | // 2nd run of the test - will use playback 91 | runTestEx2(app) 92 | 93 | // Output: 94 | // 404 text/html; charset=UTF-8 true - 404 text/html; charset=UTF-8 true - {TracksLoaded:0 TracksRecorded:2 TracksPlayed:0} 95 | // 404 text/html; charset=UTF-8 true - 404 text/html; charset=UTF-8 true - {TracksLoaded:2 TracksRecorded:0 TracksPlayed:2} 96 | } 97 | -------------------------------------------------------------------------------- /examples/example7.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "net/http" 9 | "net/http/httptest" 10 | "regexp" 11 | 12 | "github.com/seborama/govcr" 13 | ) 14 | 15 | const example7CassetteName = "MyCassette7" 16 | 17 | // Order is out example body we want to modify. 18 | type Order struct { 19 | ID string `json:"id"` 20 | Name string `json:"name"` 21 | } 22 | 23 | // Example7 is an example use of govcr. 24 | // This will show how bodies can be rewritten. 25 | // We will take a varying ID from the request URL, neutralize it and also change the ID in the body of the response. 26 | func Example7() { 27 | cfg := govcr.VCRConfig{ 28 | Logging: true, 29 | } 30 | 31 | // Regex to extract the ID from the URL. 32 | reOrderID := regexp.MustCompile(`/order/([^/]+)`) 33 | 34 | // Create a local test server that serves out responses. 35 | handler := func(w http.ResponseWriter, r *http.Request) { 36 | id := reOrderID.FindStringSubmatch(r.URL.String()) 37 | if len(id) < 2 { 38 | w.WriteHeader(404) 39 | return 40 | } 41 | 42 | w.WriteHeader(200) 43 | b, err := json.Marshal(Order{ 44 | ID: id[1], 45 | Name: "Test Order", 46 | }) 47 | if err != nil { 48 | w.WriteHeader(500) 49 | return 50 | } 51 | w.Header().Add("Content-Type", "application/json") 52 | w.WriteHeader(200) 53 | w.Write(b) 54 | } 55 | server := httptest.NewServer(http.HandlerFunc(handler)) 56 | defer server.Close() 57 | 58 | // The filter will neutralize a value in the URL. 59 | // In this case we rewrite /order/{random} to /order/1234 60 | // and replacing the host so it doesn't depend on the random port number. 61 | replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request { 62 | req.URL.Path = "/order/1234" 63 | req.URL.Host = "127.0.0.1" 64 | return req 65 | }) 66 | 67 | // Only execute when we match path. 68 | cfg.RequestFilters.Add(replacePath.OnPath(`/order/`)) 69 | 70 | cfg.ResponseFilters.Add( 71 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 72 | req := resp.Request() 73 | 74 | // Find the requested ID: 75 | orderID := reOrderID.FindStringSubmatch(req.URL.String()) 76 | 77 | // Unmarshal body. 78 | var o Order 79 | err := json.Unmarshal(resp.Body, &o) 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | // Change the ID 85 | o.ID = orderID[1] 86 | 87 | // Replace the body. 88 | resp.Body, err = json.Marshal(o) 89 | if err != nil { 90 | panic(err) 91 | } 92 | return resp 93 | }).OnStatus(200), 94 | ) 95 | 96 | orderID := fmt.Sprint(rand.Int63()) 97 | vcr := govcr.NewVCR(example7CassetteName, &cfg) 98 | 99 | // create a request with our custom header and a random url part. 100 | req, err := http.NewRequest("GET", server.URL+"/order/"+orderID, nil) 101 | if err != nil { 102 | fmt.Println(err) 103 | } 104 | fmt.Println("GET", req.URL.String()) 105 | 106 | // run the request 107 | resp, err := vcr.Client.Do(req) 108 | if err != nil { 109 | fmt.Println("Error:", err) 110 | return 111 | } 112 | // print outcome. 113 | fmt.Println("Status code:", resp.StatusCode) 114 | body, _ := ioutil.ReadAll(resp.Body) 115 | fmt.Println("Returned Body:", string(body)) 116 | fmt.Printf("%+v\n", vcr.Stats()) 117 | } 118 | -------------------------------------------------------------------------------- /pcb.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // pcb stands for Printed Circuit Board. It is a structure that holds some 10 | // facilities that are passed to the VCR machine to modify its internals. 11 | type pcb struct { 12 | Transport http.RoundTripper 13 | RequestFilter RequestFilter 14 | ResponseFilter ResponseFilter 15 | Logger *log.Logger 16 | DisableRecording bool 17 | CassettePath string 18 | } 19 | 20 | const trackNotFound = -1 21 | 22 | func (pcbr *pcb) seekTrack(cassette *cassette, req *http.Request) (*http.Response, int, error) { 23 | filteredRequest, err := newRequest(req, pcbr.Logger) 24 | if err != nil { 25 | return nil, trackNotFound, nil 26 | } 27 | // See warning in govcr.Request definition comment. 28 | filteredRequest = pcbr.RequestFilter(filteredRequest) 29 | 30 | for trackNumber := range cassette.Tracks { 31 | if pcbr.trackMatches(cassette, trackNumber, filteredRequest) { 32 | pcbr.Logger.Printf("INFO - Cassette '%s' - Found a matching track for %s %s\n", cassette.Name, req.Method, req.URL.String()) 33 | 34 | // See warning in govcr.Request definition comment. 35 | request, err := newRequest(req, pcbr.Logger) 36 | if err != nil { 37 | return nil, trackNotFound, nil 38 | } 39 | replayedResponse, err := cassette.replayResponse(trackNumber, req) 40 | filteredResp := pcbr.filterResponse(replayedResponse, request) 41 | 42 | return filteredResp, trackNumber, err 43 | } 44 | } 45 | 46 | return nil, trackNotFound, nil 47 | } 48 | 49 | // Matches checks whether the track is a match for the supplied request. 50 | func (pcbr *pcb) trackMatches(cassette *cassette, trackNumber int, request Request) bool { 51 | track := cassette.Tracks[trackNumber] 52 | 53 | // apply filter function to track header / body 54 | filteredTrackRequest := pcbr.RequestFilter(track.Request.Request()) 55 | 56 | return !track.replayed && 57 | filteredTrackRequest.Method == request.Method && 58 | filteredTrackRequest.URL.String() == request.URL.String() && 59 | pcbr.headerResembles(filteredTrackRequest.Header, request.Header) && 60 | pcbr.bodyResembles(filteredTrackRequest.Body, request.Body) 61 | } 62 | 63 | // headerResembles compares HTTP headers for equivalence. 64 | func (pcbr *pcb) headerResembles(header1 http.Header, header2 http.Header) bool { 65 | for k := range header1 { 66 | // TODO: a given header may have several values (and in any order) 67 | if GetFirstValue(header1, k) != GetFirstValue(header2, k) { 68 | return false 69 | } 70 | } 71 | 72 | // finally assert the number of headers match 73 | return len(header1) == len(header2) 74 | } 75 | 76 | // bodyResembles compares HTTP bodies for equivalence. 77 | func (pcbr *pcb) bodyResembles(body1 []byte, body2 []byte) bool { 78 | return bytes.Equal(body1, body2) 79 | } 80 | 81 | // filterResponse applies the PCB ResponseFilter filter functions to the Response. 82 | func (pcbr *pcb) filterResponse(resp *http.Response, req Request) *http.Response { 83 | body, err := readResponseBody(resp) 84 | if err != nil { 85 | pcbr.Logger.Printf("ERROR - Unable to filter response body so leaving it untouched: %s\n", err.Error()) 86 | return resp 87 | } 88 | 89 | filtResp := Response{ 90 | req: copyGovcrRequest(&req), // See warning in govcr.Request definition comment. 91 | Body: body, 92 | Header: cloneHeader(resp.Header), 93 | StatusCode: resp.StatusCode, 94 | } 95 | if pcbr.ResponseFilter != nil { 96 | // See warning in govcr.Request definition comment, for req.Request. 97 | filtResp = pcbr.ResponseFilter(filtResp) 98 | } 99 | resp.Header = filtResp.Header 100 | resp.Body = toReadCloser(filtResp.Body) 101 | resp.StatusCode = filtResp.StatusCode 102 | resp.Status = http.StatusText(resp.StatusCode) 103 | 104 | return resp 105 | } 106 | -------------------------------------------------------------------------------- /examples/example6.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/seborama/govcr" 10 | ) 11 | 12 | const example6CassetteName = "MyCassette6" 13 | 14 | // Example6 is an example use of govcr. 15 | // This will show how to do conditional rewrites. 16 | // For example, your request has a "/order/{random}" path 17 | // and we want to rewrite it to /order/1234 so we can match it later. 18 | // We change the response status code. 19 | // We add headers based on request method. 20 | func Example6() { 21 | cfg := govcr.VCRConfig{ 22 | Logging: true, 23 | } 24 | 25 | // The filter will neutralize a value in the URL. 26 | // In this case we rewrite /order/{random} to /order/1234 27 | replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request { 28 | // Replace path with a predictable one. 29 | req.URL.Path = "/order/1234" 30 | return req 31 | }) 32 | // Only execute when we match path. 33 | replacePath = replacePath.OnPath(`example\.com\/order\/`) 34 | 35 | // Add to request filters. 36 | cfg.RequestFilters.Add(replacePath) 37 | cfg.RequestFilters.Add(govcr.RequestDeleteHeaderKeys("X-Transaction-Id")) 38 | 39 | // Add filters 40 | cfg.ResponseFilters.Add( 41 | // Always transfer 'X-Transaction-Id' as in example 5. 42 | govcr.ResponseTransferHeaderKeys("X-Transaction-Id"), 43 | 44 | // Change status 404 to 202. 45 | func(resp govcr.Response) govcr.Response { 46 | if resp.StatusCode == http.StatusNotFound { 47 | resp.StatusCode = http.StatusAccepted 48 | } 49 | return resp 50 | }, 51 | 52 | // Add header if method was "GET" 53 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 54 | resp.Header.Add("method-was-get", "true") 55 | return resp 56 | }).OnMethod(http.MethodGet), 57 | 58 | // Add header if method was "POST" 59 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 60 | resp.Header.Add("method-was-post", "true") 61 | return resp 62 | }).OnMethod(http.MethodPost), 63 | 64 | // Add actual request URL to header. 65 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 66 | url := resp.Request().URL 67 | resp.Header.Add("get-url", url.String()) 68 | return resp 69 | }).OnMethod(http.MethodGet), 70 | ) 71 | 72 | orderID := fmt.Sprint(rand.Int63()) 73 | vcr := govcr.NewVCR(example6CassetteName, &cfg) 74 | 75 | // create a request with our custom header and a random url part. 76 | req, err := http.NewRequest("POST", "http://www.example.com/order/"+orderID, nil) 77 | if err != nil { 78 | fmt.Println(err) 79 | } 80 | runRequest(req, vcr) 81 | 82 | // create a request with our custom header and a random url part. 83 | req, err = http.NewRequest("GET", "http://www.example.com/order/"+orderID, nil) 84 | if err != nil { 85 | fmt.Println(err) 86 | } 87 | runRequest(req, vcr) 88 | 89 | } 90 | 91 | func runRequest(req *http.Request, vcr *govcr.VCRControlPanel) { 92 | req.Header.Add("X-Transaction-Id", time.Now().String()) 93 | // run the request 94 | resp, err := vcr.Client.Do(req) 95 | if err != nil { 96 | fmt.Println(err) 97 | return 98 | } 99 | // verify outcome 100 | if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") { 101 | fmt.Println("Header transaction Id verification failed - this would be the live request!") 102 | } else { 103 | fmt.Println("Header transaction Id verification passed - this would be the replayed track!") 104 | } 105 | 106 | // print outcome. 107 | fmt.Println("Status code:", resp.StatusCode, " (should be 404 on real and 202 on replay)") 108 | fmt.Println("method-was-get:", resp.Header.Get("method-was-get"), "(should never be true on GET)") 109 | fmt.Println("method-was-post:", resp.Header.Get("method-was-post"), "(should be true on replay on POST)") 110 | fmt.Println("get-url:", resp.Header.Get("get-url"), "(actual url of the request, not of the track)") 111 | fmt.Printf("%+v\n", vcr.Stats()) 112 | } 113 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | ) 7 | 8 | // ResponseFilter is a hook function that is used to filter the Response Header / Body. 9 | // 10 | // It works similarly to RequestFilterFunc but applies to the Response and also receives a 11 | // copy of the Request context (if you need to pick info from it to override the response). 12 | // 13 | // Return the modified response. 14 | type ResponseFilter func(resp Response) Response 15 | 16 | // ResponseFilters is a slice of ResponseFilter 17 | type ResponseFilters []ResponseFilter 18 | 19 | // Response provides the response parameters. 20 | // When returned from a ResponseFilter these values will be returned instead. 21 | type Response struct { 22 | req Request 23 | 24 | // The content returned in the response. 25 | Body []byte 26 | Header http.Header 27 | StatusCode int 28 | } 29 | 30 | // Request returns the request. 31 | // This is the request after RequestFilters have been applied. 32 | func (r Response) Request() Request { 33 | // Copied to avoid modifications. 34 | return r.req 35 | } 36 | 37 | // ResponseAddHeaderValue will add/overwrite a header to the response when it is returned from vcr playback. 38 | func ResponseAddHeaderValue(key, value string) ResponseFilter { 39 | return func(resp Response) Response { 40 | resp.Header.Add(key, value) 41 | return resp 42 | } 43 | } 44 | 45 | // ResponseDeleteHeaderKeys will delete one or more headers on the response when returned from vcr playback. 46 | func ResponseDeleteHeaderKeys(keys ...string) ResponseFilter { 47 | return func(resp Response) Response { 48 | for _, key := range keys { 49 | resp.Header.Del(key) 50 | } 51 | return resp 52 | } 53 | } 54 | 55 | // ResponseTransferHeaderKeys will transfer one or more header from the Request to the Response. 56 | func ResponseTransferHeaderKeys(keys ...string) ResponseFilter { 57 | return func(resp Response) Response { 58 | for _, key := range keys { 59 | resp.Header.Add(key, resp.req.Header.Get(key)) 60 | } 61 | return resp 62 | } 63 | } 64 | 65 | // ResponseChangeBody will allows to change the body. 66 | // Supply a function that does input to output transformation. 67 | func ResponseChangeBody(fn func(b []byte) []byte) ResponseFilter { 68 | return func(resp Response) Response { 69 | resp.Body = fn(resp.Body) 70 | return resp 71 | } 72 | } 73 | 74 | // OnMethod will return a Response filter that will only apply 'r' 75 | // if the method of the response matches. 76 | // Original filter is unmodified. 77 | func (r ResponseFilter) OnMethod(method string) ResponseFilter { 78 | return func(resp Response) Response { 79 | if resp.req.Method != method { 80 | return resp 81 | } 82 | return r(resp) 83 | } 84 | } 85 | 86 | // OnPath will return a Response filter that will only apply 'r' 87 | // if the url string of the Response matches the supplied regex. 88 | // Original filter is unmodified. 89 | func (r ResponseFilter) OnPath(pathRegEx string) ResponseFilter { 90 | if pathRegEx == "" { 91 | pathRegEx = ".*" 92 | } 93 | re := regexp.MustCompile(pathRegEx) 94 | return func(resp Response) Response { 95 | if !re.MatchString(resp.req.URL.String()) { 96 | return resp 97 | } 98 | return r(resp) 99 | } 100 | } 101 | 102 | // OnStatus will return a Response filter that will only apply 'r' if the response status matches. 103 | // Original filter is unmodified. 104 | func (r ResponseFilter) OnStatus(status int) ResponseFilter { 105 | return func(resp Response) Response { 106 | if resp.StatusCode != status { 107 | return resp 108 | } 109 | return r(resp) 110 | } 111 | } 112 | 113 | // Add one or more filters at the end of the filter chain. 114 | func (r *ResponseFilters) Add(filters ...ResponseFilter) { 115 | v := *r 116 | v = append(v, filters...) 117 | *r = v 118 | } 119 | 120 | // Prepend one or more filters before the current ones. 121 | func (r *ResponseFilters) Prepend(filters ...ResponseFilter) { 122 | src := *r 123 | dst := make(ResponseFilters, 0, len(filters)+len(src)) 124 | dst = append(dst, filters...) 125 | *r = append(dst, src...) 126 | } 127 | 128 | // combined returns the filters as a single filter. 129 | func (r ResponseFilters) combined() ResponseFilter { 130 | return func(resp Response) Response { 131 | for _, filter := range r { 132 | resp = filter(resp) 133 | } 134 | return resp 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /govcr_example7_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "net/http" 9 | "net/http/httptest" 10 | "regexp" 11 | 12 | "github.com/seborama/govcr" 13 | ) 14 | 15 | const example7CassetteName = "MyCassette7" 16 | 17 | // runTestEx7 is an example use of govcr. 18 | // This will show how bodies can be rewritten. 19 | // We will take a varying ID from the request URL, neutralize it and also change the ID in the body of the response. 20 | func runTestEx7(rng *rand.Rand) { 21 | cfg := govcr.VCRConfig{ 22 | Logging: true, 23 | } 24 | 25 | // Order is out example body we want to modify. 26 | type Order struct { 27 | ID string `json:"id"` 28 | Name string `json:"name"` 29 | } 30 | 31 | // Regex to extract the ID from the URL. 32 | reOrderID := regexp.MustCompile(`/order/([^/]+)`) 33 | 34 | // Create a local test server that serves out responses. 35 | handler := func(w http.ResponseWriter, r *http.Request) { 36 | id := reOrderID.FindStringSubmatch(r.URL.String()) 37 | if len(id) < 2 { 38 | w.WriteHeader(404) 39 | return 40 | } 41 | 42 | w.WriteHeader(200) 43 | b, err := json.Marshal(Order{ 44 | ID: id[1], 45 | Name: "Test Order", 46 | }) 47 | if err != nil { 48 | w.WriteHeader(500) 49 | return 50 | } 51 | w.Header().Add("Content-Type", "application/json") 52 | w.WriteHeader(200) 53 | w.Write(b) 54 | } 55 | server := httptest.NewServer(http.HandlerFunc(handler)) 56 | defer server.Close() 57 | 58 | // The filter will neutralize a value in the URL. 59 | // In this case we rewrite /order/{random} to /order/1234 60 | // and replacing the host so it doesn't depend on the random port number. 61 | replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request { 62 | req.URL.Path = "/order/1234" 63 | req.URL.Host = "127.0.0.1" 64 | return req 65 | }) 66 | 67 | // Only execute when we match path. 68 | cfg.RequestFilters.Add(replacePath.OnPath(`/order/`)) 69 | 70 | cfg.ResponseFilters.Add( 71 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 72 | req := resp.Request() 73 | 74 | // Find the requested ID: 75 | orderID := reOrderID.FindStringSubmatch(req.URL.String()) 76 | 77 | // Unmarshal body. 78 | var o Order 79 | err := json.Unmarshal(resp.Body, &o) 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | // Change the ID 85 | o.ID = orderID[1] 86 | 87 | // Replace the body. 88 | resp.Body, err = json.Marshal(o) 89 | if err != nil { 90 | panic(err) 91 | } 92 | return resp 93 | }).OnStatus(200), 94 | ) 95 | 96 | orderID := fmt.Sprint(rng.Uint64()) 97 | vcr := govcr.NewVCR(example7CassetteName, &cfg) 98 | 99 | // create a request with our custom header and a random url part. 100 | req, err := http.NewRequest("GET", server.URL+"/order/"+orderID, nil) 101 | if err != nil { 102 | fmt.Println(err) 103 | } 104 | 105 | // run the request 106 | resp, err := vcr.Client.Do(req) 107 | if err != nil { 108 | fmt.Println("Error:", err) 109 | return 110 | } 111 | // print outcome. 112 | // Remove host name for consistent output 113 | req.URL.Host = "127.0.0.1" 114 | fmt.Println("GET", req.URL.String()) 115 | fmt.Println("Status code:", resp.StatusCode) 116 | body, _ := ioutil.ReadAll(resp.Body) 117 | fmt.Println("Returned Body:", string(body)) 118 | fmt.Printf("%+v\n", vcr.Stats()) 119 | } 120 | 121 | // Example_number7BodyInjection will show how bodies can be rewritten. 122 | // We will take a varying ID from the request URL, neutralize it and also change the ID in the body of the response. 123 | func Example_number7BodyInjection() { 124 | // Delete cassette to enable live HTTP call 125 | govcr.DeleteCassette(example7CassetteName, "") 126 | 127 | // We need a predictable RNG 128 | rng := rand.New(rand.NewSource(7)) 129 | 130 | // 1st run of the test - will use live HTTP calls 131 | runTestEx7(rng) 132 | // 2nd run of the test - will use playback 133 | runTestEx7(rng) 134 | 135 | // Output: 136 | //GET http://127.0.0.1/order/8475284246537043955 137 | //Status code: 200 138 | //Returned Body: {"id":"8475284246537043955","name":"Test Order"} 139 | //{TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 140 | //GET http://127.0.0.1/order/2135276795452531224 141 | //Status code: 200 142 | //Returned Body: {"id":"2135276795452531224","name":"Test Order"} 143 | //{TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 144 | } 145 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "regexp" 7 | ) 8 | 9 | // A RequestFilter can be used to remove / amend undesirable header / body elements from the request. 10 | // 11 | // For instance, if your application sends requests with a timestamp held in a part of 12 | // the header / body, you likely want to remove it or force a static timestamp via 13 | // RequestFilterFunc to ensure that the request body matches those saved on the cassette's track. 14 | // 15 | // A Filter should return the request with any modified values. 16 | type RequestFilter func(req Request) Request 17 | 18 | // RequestFilters is a slice of RequestFilter 19 | type RequestFilters []RequestFilter 20 | 21 | // A Request provides the request parameters. 22 | // Notice of warning: 23 | // 'Request' contains fields that are subject to shallow copy: 24 | // - url.URL which itself contains a pointer. 25 | // - Header which is a map. 26 | // - Body which is a slice. 27 | // As a result, when copying a 'Request', the shallow copy 28 | // shares those above mentioned fields' data! 29 | // A change to the (shallow) copy will also change the source object! 30 | type Request struct { 31 | Header http.Header 32 | Body []byte 33 | Method string 34 | URL url.URL 35 | } 36 | 37 | func copyGovcrRequest(req *Request) Request { 38 | bodyData := make([]byte, len(req.Body)) 39 | copy(bodyData, req.Body) 40 | 41 | copiedReq := Request{ 42 | Header: cloneHeader(req.Header), 43 | Body: bodyData, 44 | Method: req.Method, 45 | URL: *copyURL(&req.URL), 46 | } 47 | 48 | return copiedReq 49 | } 50 | 51 | // RequestAddHeaderValue will add or overwrite a header to the request 52 | // before the request is matched against the cassette. 53 | func RequestAddHeaderValue(key, value string) RequestFilter { 54 | return func(req Request) Request { 55 | req.Header.Add(key, value) 56 | return req 57 | } 58 | } 59 | 60 | // RequestDeleteHeaderKeys will delete one or more header keys on the request 61 | // before the request is matched against the cassette. 62 | func RequestDeleteHeaderKeys(keys ...string) RequestFilter { 63 | return func(req Request) Request { 64 | for _, key := range keys { 65 | req.Header.Del(key) 66 | } 67 | return req 68 | } 69 | } 70 | 71 | // RequestExcludeHeaderFunc is a hook function that is used to filter the Header. 72 | // 73 | // Typically this can be used to remove / amend undesirable custom headers from the request. 74 | // 75 | // For instance, if your application sends requests with a timestamp held in a custom header, 76 | // you likely want to exclude it from the comparison to ensure that the request headers are 77 | // considered a match with those saved on the cassette's track. 78 | // 79 | // Parameters: 80 | // - parameter 1 - Name of header key in the Request 81 | // 82 | // Return value: 83 | // true - exclude header key from comparison 84 | // false - retain header key for comparison 85 | // 86 | // Deprecated - This function will be removed on or after April 25th 2019 87 | func RequestExcludeHeaderFunc(fn func(key string) bool) RequestFilter { 88 | return func(req Request) Request { 89 | for key := range req.Header { 90 | if fn(key) { 91 | req.Header.Del(key) 92 | } 93 | } 94 | return req 95 | } 96 | } 97 | 98 | // OnMethod will return a new filter that will only apply 'r' 99 | // if the method of the request matches. 100 | // Original filter is unmodified. 101 | func (r RequestFilter) OnMethod(method string) RequestFilter { 102 | return func(req Request) Request { 103 | if req.Method != method { 104 | return req 105 | } 106 | return r(req) 107 | } 108 | } 109 | 110 | // OnPath will return a request filter that will only apply 'r' 111 | // if the url string of the request matches the supplied regex. 112 | // Original filter is unmodified. 113 | func (r RequestFilter) OnPath(pathRegEx string) RequestFilter { 114 | if pathRegEx == "" { 115 | pathRegEx = ".*" 116 | } 117 | re := regexp.MustCompile(pathRegEx) 118 | return func(req Request) Request { 119 | if !re.MatchString(req.URL.String()) { 120 | return req 121 | } 122 | return r(req) 123 | } 124 | } 125 | 126 | // Add one or more filters at the end of the filter chain. 127 | func (r *RequestFilters) Add(filters ...RequestFilter) { 128 | v := *r 129 | v = append(v, filters...) 130 | *r = v 131 | } 132 | 133 | // Prepend one or more filters before the current ones. 134 | func (r *RequestFilters) Prepend(filters ...RequestFilter) { 135 | src := *r 136 | dst := make(RequestFilters, 0, len(filters)+len(src)) 137 | dst = append(dst, filters...) 138 | *r = append(dst, src...) 139 | } 140 | 141 | // combined returns the filters as a single filter. 142 | func (r RequestFilters) combined() RequestFilter { 143 | return func(req Request) Request { 144 | for _, filter := range r { 145 | req = filter(req) 146 | } 147 | return req 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "net/http" 5 | "net/textproto" 6 | "net/url" 7 | "testing" 8 | ) 9 | 10 | func mustParseURL(s string) url.URL { 11 | u, err := url.Parse(s) 12 | if err != nil { 13 | panic(err) 14 | } 15 | return *u 16 | } 17 | 18 | func failIfCalledRequestFilter(t *testing.T) RequestFilter { 19 | return func(req Request) Request { 20 | t.Fatal("Filter was called unexpectedly") 21 | return req 22 | } 23 | } 24 | 25 | // mustCallRequestFilterOnce will return a request filter that will record 26 | // how many times it was called. 27 | // The returned function will test if the filter was called once and fail otherwise. 28 | func mustCallRequestFilterOnce(t *testing.T) (RequestFilter, func()) { 29 | var n int 30 | return func(req Request) Request { 31 | n++ 32 | return req 33 | }, func() { 34 | if n != 1 { 35 | t.Fatalf("Filter was called %d times, should be called once", n) 36 | } 37 | } 38 | } 39 | 40 | func requestTestBase() Request { 41 | return Request{ 42 | Header: map[string][]string{textproto.CanonicalMIMEHeaderKey("a-header"): {"a-value"}}, 43 | Body: nil, 44 | Method: http.MethodGet, 45 | URL: mustParseURL("https://127.0.0.1/example-url/id/42"), 46 | } 47 | } 48 | 49 | func TestRequestFilter_OnMethod(t *testing.T) { 50 | f := failIfCalledRequestFilter(t).OnMethod(http.MethodPost) 51 | f(requestTestBase()) 52 | 53 | f, ok := mustCallRequestFilterOnce(t) 54 | f = f.OnMethod(http.MethodGet) 55 | f(requestTestBase()) 56 | ok() 57 | } 58 | 59 | func TestRequestFilter_OnPath(t *testing.T) { 60 | f := failIfCalledRequestFilter(t).OnPath("non-existing-path") 61 | f(requestTestBase()) 62 | 63 | f, ok := mustCallRequestFilterOnce(t) 64 | f = f.OnPath(`/example-url/id/`) 65 | f(requestTestBase()) 66 | ok() 67 | 68 | // Empty matches everything 69 | f, ok = mustCallRequestFilterOnce(t) 70 | f = f.OnPath("") 71 | f(requestTestBase()) 72 | ok() 73 | } 74 | 75 | func TestRequestAddHeaderValue(t *testing.T) { 76 | r := RequestAddHeaderValue("new-header", "new-value")(requestTestBase()) 77 | if r.Header.Get("new-header") != "new-value" { 78 | t.Error("did not get expected new header") 79 | } 80 | // Check if existing is still untouched. 81 | if r.Header.Get("a-header") != "a-value" { 82 | t.Error("did not get expected old header") 83 | } 84 | } 85 | 86 | func TestRequestDeleteHeaderKeys(t *testing.T) { 87 | r := RequestDeleteHeaderKeys("non-existing", "a-header")(requestTestBase()) 88 | if r.Header.Get("a-header") != "" { 89 | t.Error("'a-header' not removed") 90 | } 91 | if len(r.Header) != 0 { 92 | t.Errorf("want no headers, got %d (%+v)", len(r.Header), r.Header) 93 | } 94 | } 95 | 96 | func TestRequestExcludeHeaderFunc(t *testing.T) { 97 | req := requestTestBase() 98 | req.Header.Add("another-header", "yeah") 99 | header1, header2 := textproto.CanonicalMIMEHeaderKey("a-header"), textproto.CanonicalMIMEHeaderKey("another-header") 100 | 101 | // We expect both headers to be checked. 102 | want := map[string]struct{}{header1: {}, header2: {}} 103 | r := RequestExcludeHeaderFunc(func(key string) bool { 104 | _, ok := want[key] 105 | if !ok { 106 | t.Errorf("got unexpected key %q", key) 107 | } 108 | // Delete so we check we only get key once. 109 | delete(want, key) 110 | // Delete 'a-header' 111 | return header1 == key 112 | }) 113 | req = r(req) 114 | if len(want) > 0 { 115 | t.Errorf("header was not checked: %v", want) 116 | } 117 | if len(req.Header) != 1 { 118 | t.Fatalf("unexpected header count, want one: %v", req.Header) 119 | } 120 | if req.Header.Get("another-header") != "yeah" { 121 | t.Errorf("unexpected header value: %s", req.Header.Get("another-header")) 122 | } 123 | } 124 | 125 | func TestRequestFilters_Add(t *testing.T) { 126 | var f RequestFilters 127 | f1, ok1 := mustCallRequestFilterOnce(t) 128 | f2, ok2 := mustCallRequestFilterOnce(t) 129 | f.Add(f1, f2) 130 | f.combined()(requestTestBase()) 131 | ok1() 132 | ok2() 133 | } 134 | 135 | func TestRequestFilters_Prepend(t *testing.T) { 136 | var f RequestFilters 137 | var firstRan bool 138 | first := func(req Request) Request { 139 | firstRan = true 140 | return req 141 | } 142 | second := func(req Request) Request { 143 | if !firstRan { 144 | t.Fatal("second ran before first") 145 | } 146 | return req 147 | } 148 | third := func(req Request) Request { 149 | if !firstRan { 150 | t.Fatal("third ran before first") 151 | } 152 | return req 153 | } 154 | f.Add(second) 155 | f.Prepend(first) 156 | f.Add(third) 157 | f.combined()(requestTestBase()) 158 | } 159 | 160 | func TestRequestFilters_combined(t *testing.T) { 161 | var f RequestFilters 162 | f1, ok1 := mustCallRequestFilterOnce(t) 163 | f2, ok2 := mustCallRequestFilterOnce(t) 164 | f3, ok3 := mustCallRequestFilterOnce(t) 165 | f.Add(f1, f2, f3) 166 | f.combined()(requestTestBase()) 167 | ok1() 168 | ok2() 169 | ok3() 170 | } 171 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/textproto" 7 | "testing" 8 | ) 9 | 10 | func failIfCalledResponseFilter(t *testing.T) ResponseFilter { 11 | return func(resp Response) Response { 12 | t.Fatal("Filter was called unexpectedly") 13 | return resp 14 | } 15 | } 16 | 17 | // mustCallResponseFilterOnce will return a request filter that will record 18 | // how many times it was called. 19 | // The returned function will test if the filter was called once and fail otherwise. 20 | func mustCallResponseFilterOnce(t *testing.T) (ResponseFilter, func()) { 21 | var n int 22 | return func(resp Response) Response { 23 | n++ 24 | return resp 25 | }, func() { 26 | if n != 1 { 27 | t.Fatalf("Filter was called %d times, should be called once", n) 28 | } 29 | } 30 | } 31 | 32 | func responseTestBase() Response { 33 | return Response{ 34 | req: requestTestBase(), 35 | Header: map[string][]string{textproto.CanonicalMIMEHeaderKey("a-respheader"): {"a-value"}}, 36 | Body: []byte(`sample body`), 37 | StatusCode: http.StatusCreated, 38 | } 39 | } 40 | 41 | func TestResponse_Request(t *testing.T) { 42 | r := responseTestBase() 43 | want := requestTestBase() 44 | req := r.Request() 45 | if want.URL.String() != req.URL.String() { 46 | t.Errorf("Request does not match: (want) %v != (got) %v", want, req) 47 | } 48 | } 49 | 50 | func TestResponseAddHeaderValue(t *testing.T) { 51 | resp := ResponseAddHeaderValue("header-key", "value")(responseTestBase()) 52 | if resp.Header.Get("header-key") != "value" { 53 | t.Fatalf("new header not found %+v", resp.Header) 54 | } 55 | if resp.Header.Get("a-respheader") != "a-value" { 56 | t.Fatalf("existing header not found %+v", resp.Header) 57 | } 58 | // Check request is untouched. 59 | req := resp.Request() 60 | if req.Header.Get("header-key") != "" { 61 | t.Error("'header-key' was added on request") 62 | } 63 | } 64 | 65 | func TestResponseDeleteHeaderKeys(t *testing.T) { 66 | r := responseTestBase() 67 | r.Header.Add("a-header", "a-value") 68 | r = ResponseDeleteHeaderKeys("non-existing", "a-respheader", "a-header")(r) 69 | if r.Header.Get("a-respheader") != "" { 70 | t.Error("'a-header' not removed") 71 | } 72 | if len(r.Header) != 0 { 73 | t.Errorf("want no headers, got %d (%+v)", len(r.Header), r.Header) 74 | } 75 | // Check request is untouched. 76 | req := r.Request() 77 | if req.Header.Get("a-header") != "a-value" { 78 | t.Error("'a-header' was changed on request") 79 | } 80 | } 81 | 82 | func TestResponseTransferHeaderKeys(t *testing.T) { 83 | r := ResponseTransferHeaderKeys("a-header")(responseTestBase()) 84 | if r.Header.Get("a-header") != "a-value" { 85 | t.Errorf("'a-header' not transferred, %v", r.Header) 86 | } 87 | } 88 | 89 | func TestResponseChangeBody(t *testing.T) { 90 | r := ResponseChangeBody(func(b []byte) []byte { 91 | if !bytes.Equal(b, []byte(`sample body`)) { 92 | t.Fatalf("unexpected body: %s", string(b)) 93 | } 94 | return []byte(`new body`) 95 | })(responseTestBase()) 96 | if !bytes.Equal(r.Body, []byte(`new body`)) { 97 | t.Fatalf("unexpected body after filter: %s", string(r.Body)) 98 | } 99 | } 100 | 101 | func TestResponseFilter_OnMethod(t *testing.T) { 102 | f := failIfCalledResponseFilter(t).OnMethod(http.MethodPost) 103 | f(responseTestBase()) 104 | 105 | f, ok := mustCallResponseFilterOnce(t) 106 | f.OnMethod(http.MethodGet)(responseTestBase()) 107 | ok() 108 | } 109 | 110 | func TestResponseFilter_OnPath(t *testing.T) { 111 | f := failIfCalledResponseFilter(t).OnPath(`non-existing-path`) 112 | f(responseTestBase()) 113 | 114 | f, ok := mustCallResponseFilterOnce(t) 115 | f.OnPath(`/example-url/id/`)(responseTestBase()) 116 | ok() 117 | 118 | // Empty matches everything 119 | f, ok = mustCallResponseFilterOnce(t) 120 | f = f.OnPath("") 121 | f(responseTestBase()) 122 | ok() 123 | } 124 | 125 | func TestResponseFilter_OnStatus(t *testing.T) { 126 | f := failIfCalledResponseFilter(t).OnStatus(http.StatusNotFound) 127 | f(responseTestBase()) 128 | 129 | f, ok := mustCallResponseFilterOnce(t) 130 | f.OnStatus(http.StatusCreated)(responseTestBase()) 131 | ok() 132 | } 133 | 134 | func TestResponseFilter_Add(t *testing.T) { 135 | var f ResponseFilters 136 | f1, ok1 := mustCallResponseFilterOnce(t) 137 | f2, ok2 := mustCallResponseFilterOnce(t) 138 | f.Add(f1, f2) 139 | f.combined()(responseTestBase()) 140 | ok1() 141 | ok2() 142 | } 143 | 144 | func TestResponseFilter_Prepend(t *testing.T) { 145 | var f ResponseFilters 146 | var firstRan bool 147 | first := func(r Response) Response { 148 | firstRan = true 149 | return r 150 | } 151 | second := func(r Response) Response { 152 | if !firstRan { 153 | t.Fatal("second ran before first") 154 | } 155 | return r 156 | } 157 | third := func(r Response) Response { 158 | if !firstRan { 159 | t.Fatal("third ran before first") 160 | } 161 | return r 162 | } 163 | f.Add(second) 164 | f.Prepend(first) 165 | f.Add(third) 166 | f.combined()(responseTestBase()) 167 | } 168 | 169 | func TestResponseFilter_combined(t *testing.T) { 170 | var f ResponseFilters 171 | f1, ok1 := mustCallResponseFilterOnce(t) 172 | f2, ok2 := mustCallResponseFilterOnce(t) 173 | f3, ok3 := mustCallResponseFilterOnce(t) 174 | f.Add(f1, f2, f3) 175 | f.combined()(responseTestBase()) 176 | ok1() 177 | ok2() 178 | ok3() 179 | } 180 | -------------------------------------------------------------------------------- /govcr_example6_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/seborama/govcr" 10 | ) 11 | 12 | const example6CassetteName = "MyCassette6" 13 | 14 | // Example6 is an example use of govcr. 15 | // This will show how to do conditional rewrites. 16 | // For example, your request has a "/order/{random}" path 17 | // and we want to rewrite it to /order/1234 so we can match it later. 18 | // We change the response status code. 19 | // We add headers based on request method. 20 | func runTestEx6(rng *rand.Rand) { 21 | cfg := govcr.VCRConfig{ 22 | Logging: true, 23 | } 24 | 25 | // The filter will neutralize a value in the URL. 26 | // In this case we rewrite /order/{random} to /order/1234 27 | replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request { 28 | // Replace path with a predictable one. 29 | req.URL.Path = "/order/1234" 30 | return req 31 | }) 32 | // Only execute when we match path. 33 | replacePath = replacePath.OnPath(`example\.com\/order\/`) 34 | 35 | // Add to request filters. 36 | cfg.RequestFilters.Add(replacePath) 37 | cfg.RequestFilters.Add(govcr.RequestDeleteHeaderKeys("X-Transaction-Id")) 38 | 39 | // Add filters 40 | cfg.ResponseFilters.Add( 41 | // Always transfer 'X-Transaction-Id' as in example 5. 42 | govcr.ResponseTransferHeaderKeys("X-Transaction-Id"), 43 | 44 | // Change status 404 to 202. 45 | func(resp govcr.Response) govcr.Response { 46 | if resp.StatusCode == http.StatusNotFound { 47 | resp.StatusCode = http.StatusAccepted 48 | } 49 | return resp 50 | }, 51 | 52 | // Add header if method was "GET" 53 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 54 | resp.Header.Add("method-was-get", "true") 55 | return resp 56 | }).OnMethod(http.MethodGet), 57 | 58 | // Add header if method was "POST" 59 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 60 | resp.Header.Add("method-was-post", "true") 61 | return resp 62 | }).OnMethod(http.MethodPost), 63 | 64 | // Add actual request URL to header. 65 | govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { 66 | url := resp.Request().URL 67 | resp.Header.Add("get-url", url.String()) 68 | return resp 69 | }).OnMethod(http.MethodGet), 70 | ) 71 | 72 | orderID := fmt.Sprint(rng.Uint64()) 73 | vcr := govcr.NewVCR(example6CassetteName, &cfg) 74 | 75 | // create a request with our custom header and a random url part. 76 | req, err := http.NewRequest("POST", "http://www.example.com/order/"+orderID, nil) 77 | if err != nil { 78 | fmt.Println(err) 79 | } 80 | runExample6Request(req, vcr) 81 | 82 | // create a request with our custom header and a random url part. 83 | req, err = http.NewRequest("GET", "http://www.example.com/order/"+orderID, nil) 84 | if err != nil { 85 | fmt.Println(err) 86 | } 87 | runExample6Request(req, vcr) 88 | 89 | } 90 | 91 | func runExample6Request(req *http.Request, vcr *govcr.VCRControlPanel) { 92 | req.Header.Add("X-Transaction-Id", time.Now().String()) 93 | // run the request 94 | resp, err := vcr.Client.Do(req) 95 | if err != nil { 96 | fmt.Println(err) 97 | return 98 | } 99 | // verify outcome 100 | if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") { 101 | fmt.Println("Header transaction Id verification FAILED - this would be the live request!") 102 | } else { 103 | fmt.Println("Header transaction Id verification passed - this would be the replayed track!") 104 | } 105 | 106 | // print outcome. 107 | fmt.Println("Status code:", resp.StatusCode, " (should be 404 on real and 202 on replay)") 108 | fmt.Println("method-was-get:", resp.Header.Get("method-was-get"), "(should never be true on GET)") 109 | fmt.Println("method-was-post:", resp.Header.Get("method-was-post"), "(should be true on replay on POST)") 110 | fmt.Println("get-url:", resp.Header.Get("get-url"), "(actual url of the request, not of the track)") 111 | fmt.Printf("%+v\n", vcr.Stats()) 112 | } 113 | 114 | // Example_simpleVCR is an example use of govcr. 115 | // It shows how to use govcr in the simplest case when the default 116 | // http.Client suffices. 117 | func Example_number6ConditionalRewrites() { 118 | // Delete cassette to enable live HTTP call 119 | govcr.DeleteCassette(example6CassetteName, "") 120 | 121 | // We need a predictable RNG 122 | rng := rand.New(rand.NewSource(6)) 123 | 124 | // 1st run of the test - will use live HTTP calls 125 | runTestEx6(rng) 126 | // 2nd run of the test - will use playback 127 | runTestEx6(rng) 128 | 129 | // Output: 130 | //Header transaction Id verification FAILED - this would be the live request! 131 | //Status code: 404 (should be 404 on real and 202 on replay) 132 | //method-was-get: (should never be true on GET) 133 | //method-was-post: (should be true on replay on POST) 134 | //get-url: (actual url of the request, not of the track) 135 | //{TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 136 | //Header transaction Id verification FAILED - this would be the live request! 137 | //Status code: 404 (should be 404 on real and 202 on replay) 138 | //method-was-get: (should never be true on GET) 139 | //method-was-post: (should be true on replay on POST) 140 | //get-url: (actual url of the request, not of the track) 141 | //{TracksLoaded:0 TracksRecorded:2 TracksPlayed:0} 142 | //Header transaction Id verification passed - this would be the replayed track! 143 | //Status code: 202 (should be 404 on real and 202 on replay) 144 | //method-was-get: (should never be true on GET) 145 | //method-was-post: true (should be true on replay on POST) 146 | //get-url: (actual url of the request, not of the track) 147 | //{TracksLoaded:2 TracksRecorded:0 TracksPlayed:1} 148 | //Header transaction Id verification passed - this would be the replayed track! 149 | //Status code: 202 (should be 404 on real and 202 on replay) 150 | //method-was-get: true (should never be true on GET) 151 | //method-was-post: (should be true on replay on POST) 152 | //get-url: http://www.example.com/order/7790075977082629872 (actual url of the request, not of the track) 153 | //{TracksLoaded:2 TracksRecorded:0 TracksPlayed:2} 154 | } 155 | -------------------------------------------------------------------------------- /cassette_test.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "net/http" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func Test_trackReplaysError(t *testing.T) { 13 | t1 := track{ 14 | ErrType: "*net.OpError", 15 | ErrMsg: "Some test error", 16 | Response: response{}, 17 | } 18 | 19 | _, err := t1.response(&http.Request{}) 20 | want := "govcr govcr: *net.OpError: Some test error" 21 | if err != nil && err.Error() != want { 22 | t.Errorf("got error '%s', want '%s'\n", err.Error(), want) 23 | } 24 | } 25 | 26 | func Test_cassette_gzipFilter(t *testing.T) { 27 | type fields struct { 28 | Name string 29 | Path string 30 | Tracks []track 31 | stats Stats 32 | } 33 | type args struct { 34 | data bytes.Buffer 35 | } 36 | tests := []struct { 37 | name string 38 | fields fields 39 | args args 40 | want []byte 41 | wantErr bool 42 | }{ 43 | { 44 | name: "Should not compress data", 45 | fields: fields{ 46 | Name: "cassette", 47 | }, 48 | args: args{ 49 | data: *bytes.NewBufferString(`data`), 50 | }, 51 | want: []byte(`data`), 52 | wantErr: false, 53 | }, 54 | { 55 | name: "Should compress data when cassette name is *.gz", 56 | fields: fields{ 57 | Name: "cassette.gz", 58 | }, 59 | args: args{ 60 | data: *bytes.NewBufferString(`data`), 61 | }, 62 | want: []byte{31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 74, 73, 44, 73, 4, 4, 0, 0, 255, 255, 99, 243, 243, 173, 4, 0, 0, 0}, 63 | wantErr: false, 64 | }, 65 | } 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | k7 := &cassette{ 69 | Name: tt.fields.Name, 70 | Path: tt.fields.Path, 71 | Tracks: tt.fields.Tracks, 72 | stats: tt.fields.stats, 73 | } 74 | got, err := k7.gzipFilter(tt.args.data) 75 | if (err != nil) != tt.wantErr { 76 | t.Errorf("cassette.gzipFilter() error = %v, wantErr %v", err, tt.wantErr) 77 | return 78 | } 79 | if !reflect.DeepEqual(got, tt.want) { 80 | t.Errorf("cassette.gzipFilter() = %v, want %v", got, tt.want) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func Test_cassette_isLongPlay(t *testing.T) { 87 | type fields struct { 88 | Name string 89 | Path string 90 | Tracks []track 91 | stats Stats 92 | } 93 | tests := []struct { 94 | name string 95 | fields fields 96 | want bool 97 | }{ 98 | { 99 | name: "Should detect Long Play cassette (i.e. compressed)", 100 | fields: fields{ 101 | Name: "cassette.gz", 102 | }, 103 | want: true, 104 | }, 105 | { 106 | name: "Should detect Normal Play cassette (i.e. not compressed)", 107 | fields: fields{ 108 | Name: "cassette", 109 | }, 110 | want: false, 111 | }, 112 | } 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | k7 := &cassette{ 116 | Name: tt.fields.Name, 117 | Path: tt.fields.Path, 118 | Tracks: tt.fields.Tracks, 119 | stats: tt.fields.stats, 120 | } 121 | if got := k7.isLongPlay(); got != tt.want { 122 | t.Errorf("cassette.isLongPlay() = %v, want %v", got, tt.want) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func Test_cassette_gunzipFilter(t *testing.T) { 129 | type fields struct { 130 | Name string 131 | Path string 132 | Tracks []track 133 | stats Stats 134 | } 135 | type args struct { 136 | data []byte 137 | } 138 | tests := []struct { 139 | name string 140 | fields fields 141 | args args 142 | want []byte 143 | wantErr bool 144 | }{ 145 | { 146 | name: "Should not compress data", 147 | fields: fields{ 148 | Name: "cassette", 149 | }, 150 | args: args{ 151 | data: []byte(`data`), 152 | }, 153 | want: []byte(`data`), 154 | wantErr: false, 155 | }, 156 | { 157 | name: "Should de-compress data when cassette name is *.gz", 158 | fields: fields{ 159 | Name: "cassette.gz", 160 | }, 161 | args: args{ 162 | data: []byte{31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 74, 73, 44, 73, 4, 4, 0, 0, 255, 255, 99, 243, 243, 173, 4, 0, 0, 0}, 163 | }, 164 | want: []byte(`data`), 165 | wantErr: false, 166 | }, 167 | } 168 | for _, tt := range tests { 169 | t.Run(tt.name, func(t *testing.T) { 170 | k7 := &cassette{ 171 | Name: tt.fields.Name, 172 | Path: tt.fields.Path, 173 | Tracks: tt.fields.Tracks, 174 | stats: tt.fields.stats, 175 | } 176 | got, err := k7.gunzipFilter(tt.args.data) 177 | if (err != nil) != tt.wantErr { 178 | t.Errorf("cassette.gunzipFilter() error = %v, wantErr %v", err, tt.wantErr) 179 | return 180 | } 181 | if !reflect.DeepEqual(got, tt.want) { 182 | t.Errorf("cassette.gunzipFilter() = %v, want %v", got, tt.want) 183 | } 184 | }) 185 | } 186 | } 187 | 188 | func Test_cassetteNameToFilename(t *testing.T) { 189 | type args struct { 190 | cassetteName string 191 | cassettePath string 192 | } 193 | tests := []struct { 194 | name string 195 | args args 196 | want string 197 | }{ 198 | { 199 | name: "Should return normal cassette name", 200 | args: args{ 201 | cassetteName: "MyCassette", 202 | }, 203 | want: "MyCassette.cassette", 204 | }, 205 | { 206 | name: "Should return normalised gz cassette name", 207 | args: args{ 208 | cassetteName: "MyCassette.gz", 209 | }, 210 | want: "MyCassette.cassette.gz", 211 | }, 212 | } 213 | for _, tt := range tests { 214 | t.Run(tt.name, func(t *testing.T) { 215 | if got := cassetteNameToFilename(tt.args.cassetteName, tt.args.cassettePath); !strings.HasSuffix(got, tt.want) { 216 | t.Errorf("cassetteNameToFilename() = %v, want suffix %v", got, tt.want) 217 | } 218 | }) 219 | } 220 | } 221 | 222 | func Test_cassette_addTrack(t *testing.T) { 223 | type fields struct { 224 | removeTLS bool 225 | } 226 | type args struct { 227 | track track 228 | } 229 | tests := []struct { 230 | name string 231 | fields fields 232 | args args 233 | }{ 234 | { 235 | name: "with tls, keep", 236 | fields: fields{ 237 | removeTLS: false, 238 | }, 239 | args: args{ 240 | track: track{ 241 | Response: response{ 242 | TLS: &tls.ConnectionState{}, 243 | }, 244 | }, 245 | }, 246 | }, 247 | { 248 | name: "with tls, remove", 249 | fields: fields{ 250 | removeTLS: true, 251 | }, 252 | args: args{ 253 | track: track{ 254 | Response: response{ 255 | TLS: &tls.ConnectionState{}, 256 | }, 257 | }, 258 | }, 259 | }, 260 | { 261 | name: "without tls, keep", 262 | fields: fields{ 263 | removeTLS: false, 264 | }, 265 | args: args{ 266 | track: track{ 267 | Response: response{ 268 | TLS: nil, 269 | }, 270 | }, 271 | }, 272 | }, 273 | { 274 | name: "without tls, remove", 275 | fields: fields{ 276 | removeTLS: true, 277 | }, 278 | args: args{ 279 | track: track{ 280 | Response: response{ 281 | TLS: nil, 282 | }, 283 | }, 284 | }, 285 | }, 286 | } 287 | for _, tt := range tests { 288 | t.Run(tt.name, func(t *testing.T) { 289 | k7 := &cassette{ 290 | Name: tt.name, 291 | Path: tt.name, 292 | removeTLS: tt.fields.removeTLS, 293 | } 294 | k7.addTrack(&tt.args.track) 295 | gotTLS := k7.Tracks[0].Response.TLS != nil 296 | if gotTLS && tt.fields.removeTLS { 297 | t.Errorf("got TLS, but it should have been removed") 298 | } 299 | if !gotTLS && !tt.fields.removeTLS && tt.args.track.Response.TLS != nil { 300 | t.Errorf("tls was removed, but shouldn't") 301 | } 302 | }) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /govcr.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | // VCRControlPanel holds the parts of a VCR that can be interacted with. 15 | // Client is the HTTP client associated with the VCR. 16 | type VCRControlPanel struct { 17 | Client *http.Client 18 | } 19 | 20 | // Stats returns Stats about the cassette and VCR session. 21 | func (vcr *VCRControlPanel) Stats() Stats { 22 | vcrT := vcr.Client.Transport.(*vcrTransport) 23 | return vcrT.Cassette.Stats() 24 | } 25 | 26 | const defaultCassettePath = "./govcr-fixtures/" 27 | 28 | // VCRConfig holds a set of options for the VCR. 29 | type VCRConfig struct { 30 | Client *http.Client 31 | 32 | // Filter to run before request is matched against cassettes. 33 | RequestFilters RequestFilters 34 | 35 | // Filter to run before a response is returned. 36 | ResponseFilters ResponseFilters 37 | 38 | // LongPlay will compress data on cassettes. 39 | LongPlay bool 40 | DisableRecording bool 41 | Logging bool 42 | CassettePath string 43 | 44 | // RemoveTLS will remove TLS from the Response when recording. 45 | // TLS information is rarely needed and takes up a lot of space. 46 | RemoveTLS bool 47 | } 48 | 49 | // NewVCR creates a new VCR and loads a cassette. 50 | // A RoundTripper can be provided when a custom Transport is needed (for example to provide 51 | // certificates, etc) 52 | func NewVCR(cassetteName string, vcrConfig *VCRConfig) *VCRControlPanel { 53 | if vcrConfig == nil { 54 | vcrConfig = &VCRConfig{} 55 | } 56 | 57 | // set up logging 58 | logger := log.New(os.Stderr, "", log.LstdFlags) 59 | if !vcrConfig.Logging { 60 | out, _ := os.OpenFile(os.DevNull, os.O_WRONLY|os.O_APPEND, 0600) 61 | logger.SetOutput(out) 62 | } 63 | 64 | // use a default client if none provided 65 | if vcrConfig.Client == nil { 66 | vcrConfig.Client = http.DefaultClient 67 | } 68 | 69 | // use a default transport if none provided 70 | if vcrConfig.Client.Transport == nil { 71 | vcrConfig.Client.Transport = http.DefaultTransport 72 | } 73 | 74 | // load cassette 75 | cassette, err := loadCassette(cassetteName, vcrConfig.CassettePath) 76 | if err != nil { 77 | logger.Fatal(err) 78 | } 79 | cassette.removeTLS = vcrConfig.RemoveTLS 80 | 81 | // create PCB 82 | pcbr := &pcb{ 83 | // TODO: create appropriate test! 84 | DisableRecording: vcrConfig.DisableRecording, 85 | Transport: vcrConfig.Client.Transport, 86 | RequestFilter: vcrConfig.RequestFilters.combined(), 87 | ResponseFilter: vcrConfig.ResponseFilters.combined(), 88 | Logger: logger, 89 | CassettePath: vcrConfig.CassettePath, 90 | } 91 | 92 | // create VCR's HTTP client 93 | vcrClient := &http.Client{ 94 | Transport: &vcrTransport{ 95 | PCB: pcbr, 96 | Cassette: cassette, 97 | }, 98 | } 99 | 100 | // copy the attributes of the original http.Client 101 | vcrClient.CheckRedirect = vcrConfig.Client.CheckRedirect 102 | vcrClient.Jar = vcrConfig.Client.Jar 103 | vcrClient.Timeout = vcrConfig.Client.Timeout 104 | 105 | // return 106 | return &VCRControlPanel{ 107 | Client: vcrClient, 108 | } 109 | } 110 | 111 | func newRequest(req *http.Request, logger *log.Logger) (Request, error) { 112 | bodyData, err := readRequestBody(req) 113 | if err != nil { 114 | logger.Println(err) 115 | return Request{}, err 116 | } 117 | 118 | request := Request{ 119 | Header: cloneHeader(req.Header), 120 | Body: bodyData, 121 | Method: req.Method, 122 | } 123 | 124 | if req.URL != nil { 125 | request.URL = *copyURL(req.URL) 126 | } 127 | 128 | return request, nil 129 | } 130 | 131 | // GetFirstValue is a utility function that extracts the first value of a header key. 132 | // The reason for this function is that some servers require case sensitive headers which 133 | // prevent the use of http.Header.Get() as it expects header keys to be canonicalized. 134 | func GetFirstValue(hdr http.Header, key string) string { 135 | for k, val := range hdr { 136 | if strings.ToLower(k) == strings.ToLower(key) { 137 | if len(val) > 0 { 138 | return val[0] 139 | } 140 | return "" 141 | } 142 | } 143 | 144 | return "" 145 | } 146 | 147 | // copyRequest makes a copy an HTTP request. 148 | // It ensures that the original request Body stream is restored to its original state 149 | // and can be read from again. 150 | // TODO: should perform a deep copy of the TLS property as with URL 151 | func copyRequest(req *http.Request) (*http.Request, error) { 152 | if req == nil { 153 | return nil, nil 154 | } 155 | 156 | // get a deep copy without body considerations 157 | copiedReq := copyRequestWithoutBody(req) 158 | 159 | // deal with the Body 160 | bodyCopy, err := readRequestBody(req) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | // restore Body stream state 166 | req.Body = toReadCloser(bodyCopy) 167 | copiedReq.Body = toReadCloser(bodyCopy) 168 | 169 | return copiedReq, nil 170 | } 171 | 172 | // copyRequestWithoutBody makes a copy an HTTP request but not the Body (set to nil). 173 | // TODO: should perform a deep copy of the TLS property as with URL 174 | func copyRequestWithoutBody(req *http.Request) *http.Request { 175 | if req == nil { 176 | return nil 177 | } 178 | 179 | // get a shallow copy 180 | copiedReq := *req 181 | 182 | // remove the channel reference 183 | copiedReq.Cancel = nil 184 | 185 | // deal with the URL 186 | if req.URL != nil { 187 | copiedReq.URL = copyURL(req.URL) 188 | } 189 | copiedReq.Header = cloneHeader(req.Header) 190 | 191 | return &copiedReq 192 | } 193 | 194 | func copyURL(url *url.URL) *url.URL { 195 | // shallow copy 196 | copiedURL := *url 197 | 198 | if url.User != nil { 199 | // BEWARE: obj == &*obj in Go, with obj being a pointer 200 | userInfo := *url.User 201 | copiedURL.User = &userInfo 202 | } 203 | 204 | return &copiedURL 205 | } 206 | 207 | // cloneHeader return return a deep copy of the header. 208 | func cloneHeader(h http.Header) http.Header { 209 | if h == nil { 210 | return nil 211 | } 212 | 213 | copied := make(http.Header, len(h)) 214 | for k, v := range h { 215 | copied[k] = append([]string{}, v...) 216 | } 217 | return copied 218 | } 219 | 220 | // readRequestBody reads the Body data stream and restores its states. 221 | // It ensures the stream is restored to its original state and can be read from again. 222 | // TODO - readRequestBody and readResponseBody are so similar - perhaps create a new interface Bodyer and extend http.Request and http.Response to implement it. This would allow to merge readRequestBody and readResponseBody 223 | func readRequestBody(req *http.Request) ([]byte, error) { 224 | if req == nil || req.Body == nil { 225 | return nil, nil 226 | } 227 | 228 | // dump the data 229 | bodyWriter := bytes.NewBuffer(nil) 230 | 231 | _, err := io.Copy(bodyWriter, req.Body) 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | bodyData := bodyWriter.Bytes() 237 | 238 | // restore original state of the Body source stream 239 | req.Body.Close() 240 | req.Body = toReadCloser(bodyData) 241 | 242 | return bodyData, nil 243 | } 244 | 245 | // readResponseBody reads the Body data stream and restores its states. 246 | // It ensures the stream is restored to its original state and can be read from again. 247 | func readResponseBody(resp *http.Response) ([]byte, error) { 248 | if resp == nil || resp.Body == nil { 249 | return nil, nil 250 | } 251 | 252 | // dump the data 253 | bodyWriter := bytes.NewBuffer(nil) 254 | 255 | _, err := io.Copy(bodyWriter, resp.Body) 256 | if err != nil { 257 | return nil, err 258 | } 259 | resp.Body.Close() 260 | 261 | bodyData := bodyWriter.Bytes() 262 | 263 | // restore original state of the Body source stream 264 | resp.Body = toReadCloser(bodyData) 265 | 266 | return bodyData, nil 267 | } 268 | 269 | func toReadCloser(body []byte) io.ReadCloser { 270 | return ioutil.NopCloser(bytes.NewReader(body)) 271 | } 272 | -------------------------------------------------------------------------------- /govcr_test.go: -------------------------------------------------------------------------------- 1 | package govcr_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "testing" 10 | 11 | "net/http/httptest" 12 | 13 | "github.com/seborama/govcr" 14 | ) 15 | 16 | func TestPlaybackOrder(t *testing.T) { 17 | cassetteName := "TestPlaybackOrder" 18 | clientNum := 1 19 | 20 | // create a test server 21 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | fmt.Fprintf(w, "Hello, client %d", clientNum) 23 | clientNum++ 24 | })) 25 | 26 | fmt.Println("Phase 1 ================================================") 27 | 28 | if err := govcr.DeleteCassette(cassetteName, ""); err != nil { 29 | t.Fatalf("err from govcr.DeleteCassette(): Expected nil, got %s", err) 30 | } 31 | 32 | vcr := createVCR(cassetteName, false) 33 | client := vcr.Client 34 | 35 | // run requests 36 | for i := 1; i <= 10; i++ { 37 | resp, _ := client.Get(ts.URL) 38 | 39 | // check outcome of the request 40 | expectedBody := fmt.Sprintf("Hello, client %d", i) 41 | checkResponseForTestPlaybackOrder(t, resp, expectedBody) 42 | 43 | if !govcr.CassetteExistsAndValid(cassetteName, "") { 44 | t.Fatalf("CassetteExists: expected true, got false") 45 | } 46 | 47 | checkStats(t, vcr.Stats(), 0, i, 0) 48 | } 49 | 50 | fmt.Println("Phase 2 - Playback =====================================") 51 | clientNum = 1 52 | 53 | // re-run request and expect play back from vcr 54 | vcr = createVCR(cassetteName, false) 55 | client = vcr.Client 56 | 57 | // run requests 58 | for i := 1; i <= 10; i++ { 59 | resp, _ := client.Get(ts.URL) 60 | 61 | // check outcome of the request 62 | expectedBody := fmt.Sprintf("Hello, client %d", i) 63 | checkResponseForTestPlaybackOrder(t, resp, expectedBody) 64 | 65 | if !govcr.CassetteExistsAndValid(cassetteName, "") { 66 | t.Fatalf("CassetteExists: expected true, got false") 67 | } 68 | 69 | checkStats(t, vcr.Stats(), 10, 0, i) 70 | } 71 | } 72 | 73 | func TestNonUtf8EncodableBinaryBody(t *testing.T) { 74 | cassetteName := "TestNonUtf8EncodableBinaryBody" 75 | clientNum := 1 76 | 77 | // create a test server 78 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 | data := generateBinaryBody(clientNum) 80 | written, err := w.Write(data) 81 | if written != len(data) { 82 | t.Fatalf("** Only %d bytes out of %d were written", written, len(data)) 83 | } 84 | if err != nil { 85 | t.Fatalf("err from w.Write(): Expected nil, got %s", err) 86 | } 87 | clientNum++ 88 | })) 89 | 90 | fmt.Println("Phase 1 ================================================") 91 | 92 | if err := govcr.DeleteCassette(cassetteName, ""); err != nil { 93 | t.Fatalf("err from govcr.DeleteCassette(): Expected nil, got %s", err) 94 | } 95 | 96 | vcr := createVCR(cassetteName, false) 97 | client := vcr.Client 98 | 99 | // run requests 100 | for i := 1; i <= 10; i++ { 101 | resp, _ := client.Get(ts.URL) 102 | 103 | // check outcome of the request 104 | expectedBody := generateBinaryBody(i) 105 | checkResponseForTestPlaybackOrder(t, resp, expectedBody) 106 | 107 | if !govcr.CassetteExistsAndValid(cassetteName, "") { 108 | t.Fatalf("CassetteExists: expected true, got false") 109 | } 110 | 111 | checkStats(t, vcr.Stats(), 0, i, 0) 112 | } 113 | 114 | fmt.Println("Phase 2 - Playback =====================================") 115 | clientNum = 1 116 | 117 | // re-run request and expect play back from vcr 118 | vcr = createVCR(cassetteName, false) 119 | client = vcr.Client 120 | 121 | // run requests 122 | for i := 1; i <= 10; i++ { 123 | resp, _ := client.Get(ts.URL) 124 | 125 | // check outcome of the request 126 | expectedBody := generateBinaryBody(i) 127 | checkResponseForTestPlaybackOrder(t, resp, expectedBody) 128 | 129 | if !govcr.CassetteExistsAndValid(cassetteName, "") { 130 | t.Fatalf("CassetteExists: expected true, got false") 131 | } 132 | 133 | checkStats(t, vcr.Stats(), 10, 0, i) 134 | } 135 | } 136 | 137 | func TestLongPlay(t *testing.T) { 138 | cassetteName := t.Name() + ".gz" 139 | clientNum := 1 140 | 141 | // create a test server 142 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 | fmt.Fprintf(w, "Hello, client %d", clientNum) 144 | clientNum++ 145 | })) 146 | 147 | fmt.Println("Phase 1 ================================================") 148 | 149 | if err := govcr.DeleteCassette(cassetteName, ""); err != nil { 150 | t.Fatalf("err from govcr.DeleteCassette(): Expected nil, got %s", err) 151 | } 152 | 153 | vcr := createVCR(cassetteName, true) 154 | client := vcr.Client 155 | 156 | // run requests 157 | for i := 1; i <= 10; i++ { 158 | resp, _ := client.Get(ts.URL) 159 | 160 | // check outcome of the request 161 | expectedBody := fmt.Sprintf("Hello, client %d", i) 162 | checkResponseForTestPlaybackOrder(t, resp, expectedBody) 163 | 164 | if !govcr.CassetteExistsAndValid(cassetteName, "") { 165 | t.Fatalf("CassetteExists: expected true, got false") 166 | } 167 | 168 | checkStats(t, vcr.Stats(), 0, i, 0) 169 | } 170 | 171 | fmt.Println("Phase 2 - Playback =====================================") 172 | clientNum = 1 173 | 174 | // re-run request and expect play back from vcr 175 | vcr = createVCR(cassetteName, false) 176 | client = vcr.Client 177 | 178 | // run requests 179 | for i := 1; i <= 10; i++ { 180 | resp, _ := client.Get(ts.URL) 181 | 182 | // check outcome of the request 183 | expectedBody := fmt.Sprintf("Hello, client %d", i) 184 | checkResponseForTestPlaybackOrder(t, resp, expectedBody) 185 | 186 | if !govcr.CassetteExistsAndValid(cassetteName, "") { 187 | t.Fatalf("CassetteExists: expected true, got false") 188 | } 189 | 190 | checkStats(t, vcr.Stats(), 10, 0, i) 191 | } 192 | } 193 | 194 | func createVCR(cassetteName string, lp bool) *govcr.VCRControlPanel { 195 | // create a custom http.Transport. 196 | tr := http.DefaultTransport.(*http.Transport) 197 | tr.TLSClientConfig = &tls.Config{ 198 | InsecureSkipVerify: true, // just an example, strongly discouraged 199 | } 200 | 201 | // create a vcr 202 | return govcr.NewVCR(cassetteName, 203 | &govcr.VCRConfig{ 204 | Client: &http.Client{Transport: tr}, 205 | LongPlay: lp, 206 | }) 207 | } 208 | 209 | func checkResponseForTestPlaybackOrder(t *testing.T, resp *http.Response, expectedBody interface{}) { 210 | if resp.StatusCode != http.StatusOK { 211 | t.Fatalf("resp.StatusCode: Expected %d, got %d", http.StatusOK, resp.StatusCode) 212 | } 213 | 214 | if resp.Body == nil { 215 | t.Fatalf("resp.Body: Expected non-nil, got nil") 216 | } 217 | 218 | bodyData, err := ioutil.ReadAll(resp.Body) 219 | if err != nil { 220 | t.Fatalf("err from ioutil.ReadAll(): Expected nil, got %s", err) 221 | } 222 | resp.Body.Close() 223 | 224 | var expectedBodyBytes []byte 225 | switch expectedBody.(type) { 226 | case []byte: 227 | var ok bool 228 | expectedBodyBytes, ok = expectedBody.([]byte) 229 | if !ok { 230 | t.Fatalf("expectedBody: cannot assert to type '[]byte'") 231 | } 232 | 233 | case string: 234 | expectedBodyString, ok := expectedBody.(string) 235 | if !ok { 236 | t.Fatalf("expectedBody: cannot assert to type 'string'") 237 | } 238 | expectedBodyBytes = []byte(expectedBodyString) 239 | 240 | default: 241 | t.Fatalf("Unexpected type for 'expectedBody' variable") 242 | } 243 | 244 | if !bytes.Equal(bodyData, expectedBodyBytes) { 245 | t.Fatalf("Body: expected '%v', got '%v'", expectedBody, bodyData) 246 | } 247 | } 248 | 249 | func checkStats(t *testing.T, actualStats govcr.Stats, expectedTracksLoaded, expectedTracksRecorded, expectedTrackPlayed int) { 250 | if actualStats.TracksLoaded != expectedTracksLoaded { 251 | t.Fatalf("Expected %d track loaded, got %d", expectedTracksLoaded, actualStats.TracksLoaded) 252 | } 253 | 254 | if actualStats.TracksRecorded != expectedTracksRecorded { 255 | t.Fatalf("Expected %d track recorded, got %d", expectedTracksRecorded, actualStats.TracksRecorded) 256 | } 257 | 258 | if actualStats.TracksPlayed != expectedTrackPlayed { 259 | t.Fatalf("Expected %d track played, got %d", expectedTrackPlayed, actualStats.TracksPlayed) 260 | } 261 | } 262 | 263 | func generateBinaryBody(sequence int) []byte { 264 | data := make([]byte, 256, 257) 265 | for i := range data { 266 | data[i] = byte(i) 267 | } 268 | data = append(data, byte(sequence)) 269 | return data 270 | } 271 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /cassette.go: -------------------------------------------------------------------------------- 1 | package govcr 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/tls" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "net" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "path/filepath" 18 | "regexp" 19 | "strings" 20 | ) 21 | 22 | // request is a recorded HTTP request. 23 | type request struct { 24 | Method string 25 | URL *url.URL 26 | Header http.Header 27 | Body []byte 28 | } 29 | 30 | // Request transforms internal "request" to a filter "Request". 31 | func (r request) Request() Request { 32 | res := Request{ 33 | Header: r.Header, 34 | Body: r.Body, 35 | Method: r.Method, 36 | } 37 | if r.URL != nil { 38 | // res.URL = *r.URL 39 | res.URL = *copyURL(r.URL) 40 | } 41 | return res 42 | } 43 | 44 | // response is a recorded HTTP response. 45 | type response struct { 46 | Status string 47 | StatusCode int 48 | Proto string 49 | ProtoMajor int 50 | ProtoMinor int 51 | 52 | Header http.Header 53 | Body []byte 54 | ContentLength int64 55 | TransferEncoding []string 56 | Trailer http.Header 57 | TLS *tls.ConnectionState 58 | } 59 | 60 | // Response returns the internal "response" to a filter "Response". 61 | func (r response) Response(req Request) Response { 62 | return Response{ 63 | req: req, 64 | Body: r.Body, 65 | Header: r.Header, 66 | StatusCode: r.StatusCode, 67 | } 68 | } 69 | 70 | // track is a recording (HTTP request + response) in a cassette. 71 | type track struct { 72 | Request request 73 | Response response 74 | ErrType string 75 | ErrMsg string 76 | 77 | // replayed indicates whether the track has already been processed in the cassette playback. 78 | replayed bool 79 | } 80 | 81 | func (t *track) response(req *http.Request) (*http.Response, error) { 82 | var ( 83 | err error 84 | resp = &http.Response{} 85 | ) 86 | 87 | // create a ReadCloser to supply to resp 88 | bodyReadCloser := toReadCloser(t.Response.Body) 89 | 90 | // create error object 91 | switch t.ErrType { 92 | case "*net.OpError": 93 | err = &net.OpError{ 94 | Op: "govcr", 95 | Net: "govcr", 96 | Source: nil, 97 | Addr: nil, 98 | Err: errors.New(t.ErrType + ": " + t.ErrMsg), 99 | } 100 | case "": 101 | err = nil 102 | 103 | default: 104 | err = errors.New(t.ErrType + ": " + t.ErrMsg) 105 | } 106 | 107 | if err != nil { 108 | // No need to parse the response. 109 | // By convention, when an HTTP error occurred, the response should be empty 110 | // (or Go's http package will show a warning message at runtime). 111 | return resp, err 112 | } 113 | 114 | // re-create the response object from track record 115 | tls := t.Response.TLS 116 | 117 | resp.Status = t.Response.Status 118 | resp.StatusCode = t.Response.StatusCode 119 | resp.Proto = t.Response.Proto 120 | resp.ProtoMajor = t.Response.ProtoMajor 121 | resp.ProtoMinor = t.Response.ProtoMinor 122 | 123 | resp.Header = t.Response.Header 124 | resp.Body = bodyReadCloser 125 | resp.ContentLength = t.Response.ContentLength 126 | resp.TransferEncoding = t.Response.TransferEncoding 127 | resp.Trailer = t.Response.Trailer 128 | 129 | // See notes on http.Response.Request - Body is nil because it has already been consumed 130 | resp.Request = copyRequestWithoutBody(req) 131 | 132 | resp.TLS = tls 133 | 134 | return resp, nil 135 | } 136 | 137 | // newTrack creates a new track from an HTTP request and response. 138 | func newTrack(req *http.Request, resp *http.Response, reqErr error) (*track, error) { 139 | var ( 140 | k7Request request 141 | k7Response response 142 | ) 143 | 144 | // build request object 145 | if req != nil { 146 | bodyData, err := readRequestBody(req) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | k7Request = request{ 152 | Method: req.Method, 153 | URL: req.URL, 154 | Header: req.Header, 155 | Body: bodyData, 156 | } 157 | } 158 | 159 | // build response object 160 | if resp != nil { 161 | bodyData, err := readResponseBody(resp) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | k7Response = response{ 167 | Status: resp.Status, 168 | StatusCode: resp.StatusCode, 169 | Proto: resp.Proto, 170 | ProtoMajor: resp.ProtoMajor, 171 | ProtoMinor: resp.ProtoMinor, 172 | 173 | Header: resp.Header, 174 | Body: bodyData, 175 | ContentLength: resp.ContentLength, 176 | TransferEncoding: resp.TransferEncoding, 177 | Trailer: resp.Trailer, 178 | TLS: resp.TLS, 179 | } 180 | } 181 | 182 | // build track object 183 | var reqErrType, reqErrMsg string 184 | if reqErr != nil { 185 | reqErrType = fmt.Sprintf("%T", reqErr) 186 | reqErrMsg = reqErr.Error() 187 | } 188 | 189 | track := &track{ 190 | Request: k7Request, 191 | Response: k7Response, 192 | ErrType: reqErrType, 193 | ErrMsg: reqErrMsg, 194 | } 195 | 196 | return track, nil 197 | } 198 | 199 | // Stats holds information about the cassette and 200 | // VCR runtime. 201 | type Stats struct { 202 | // TracksLoaded is the number of tracks that were loaded from the cassette. 203 | TracksLoaded int 204 | 205 | // TracksRecorded is the number of new tracks recorded by VCR. 206 | TracksRecorded int 207 | 208 | // TracksPlayed is the number of tracks played back straight from the cassette. 209 | // I.e. tracks that were already present on the cassette and were played back. 210 | TracksPlayed int 211 | } 212 | 213 | // cassette contains a set of tracks. 214 | type cassette struct { 215 | Name string 216 | Path string `json:"-"` 217 | Tracks []track 218 | 219 | // stats is not exported since it doesn't need serialising 220 | stats Stats 221 | removeTLS bool 222 | } 223 | 224 | func (k7 *cassette) isLongPlay() bool { 225 | return strings.HasSuffix(k7.Name, ".gz") 226 | } 227 | 228 | // TODO - this feels wrong - the cassette should just replay, not replace the track resp.req with the live req 229 | // if it must be done, then it should be done somewhere else, either vcrTransport (or PCB, to be confirmed) 230 | func (k7 *cassette) replayResponse(trackNumber int, req *http.Request) (*http.Response, error) { 231 | if trackNumber >= len(k7.Tracks) { 232 | return nil, nil 233 | } 234 | track := &k7.Tracks[trackNumber] 235 | 236 | // mark the track as replayed so it doesn't get re-used 237 | track.replayed = true 238 | 239 | return track.response(req) 240 | } 241 | 242 | // saveCassette writes a cassette to file. 243 | func (k7 *cassette) save() error { 244 | data, err := json.Marshal(k7) 245 | if err != nil { 246 | return err 247 | } 248 | 249 | tData, err := transformInterfacesInJSON(data) 250 | if err != nil { 251 | return err 252 | } 253 | 254 | var iData bytes.Buffer 255 | if err := json.Indent(&iData, tData, "", " "); err != nil { 256 | return err 257 | } 258 | 259 | filename := cassetteNameToFilename(k7.Name, k7.Path) 260 | path := filepath.Dir(filename) 261 | if err := os.MkdirAll(path, 0750); err != nil { 262 | return err 263 | } 264 | 265 | gData, err := k7.gzipFilter(iData) 266 | if err != nil { 267 | return err 268 | } 269 | 270 | return ioutil.WriteFile(filename, gData, 0640) 271 | } 272 | 273 | // gzipFilter compresses the cassette data in gzip format if the cassette 274 | // name ends with '.gz', otherwise data is left as is (i.e. de-compressed) 275 | func (k7 *cassette) gzipFilter(data bytes.Buffer) ([]byte, error) { 276 | if k7.isLongPlay() { 277 | return compress(data.Bytes()) 278 | } 279 | return data.Bytes(), nil 280 | } 281 | 282 | // gunzipFilter de-compresses the cassette data in gzip format if the cassette 283 | // name ends with '.gz', otherwise data is left as is (i.e. de-compressed) 284 | func (k7 *cassette) gunzipFilter(data []byte) ([]byte, error) { 285 | if k7.isLongPlay() { 286 | return decompress(data) 287 | } 288 | return data, nil 289 | } 290 | 291 | // addTrack adds a track to a cassette. 292 | func (k7 *cassette) addTrack(track *track) { 293 | // TODO: refactor this to be handled by the PCB? 294 | if k7.removeTLS { 295 | track.Response.TLS = nil 296 | } 297 | k7.Tracks = append(k7.Tracks, *track) 298 | } 299 | 300 | // Stats returns the cassette's Stats. 301 | func (k7 *cassette) Stats() Stats { 302 | k7.stats.TracksRecorded = k7.numberOfTracks() - k7.stats.TracksLoaded 303 | k7.stats.TracksPlayed = k7.tracksPlayed() - k7.stats.TracksRecorded 304 | 305 | return k7.stats 306 | } 307 | 308 | func (k7 *cassette) tracksPlayed() int { 309 | replayed := 0 310 | 311 | for _, t := range k7.Tracks { 312 | if t.replayed { 313 | replayed++ 314 | } 315 | } 316 | 317 | return replayed 318 | } 319 | 320 | func (k7 *cassette) numberOfTracks() int { 321 | return len(k7.Tracks) 322 | } 323 | 324 | // DeleteCassette removes the cassette file from disk. 325 | func DeleteCassette(cassetteName, cassettePath string) error { 326 | filename := cassetteNameToFilename(cassetteName, cassettePath) 327 | 328 | err := os.Remove(filename) 329 | if os.IsNotExist(err) { 330 | // the file does not exist so this is not an error since we wanted it gone! 331 | err = nil 332 | } 333 | 334 | return err 335 | } 336 | 337 | // CassetteExistsAndValid verifies a cassette file exists and is seemingly valid. 338 | func CassetteExistsAndValid(cassetteName, cassettePath string) bool { 339 | _, err := readCassetteFromFile(cassetteName, cassettePath) 340 | return err == nil 341 | } 342 | 343 | // cassetteNameToFilename returns the filename associated to the cassette. 344 | func cassetteNameToFilename(cassetteName, cassettePath string) string { 345 | if cassetteName == "" || cassetteName == ".gz" { 346 | return "" 347 | } 348 | 349 | if cassettePath == "" { 350 | cassettePath = defaultCassettePath 351 | } 352 | 353 | fPath, err := filepath.Abs(filepath.Join(cassettePath, adjustCassetteName(cassetteName))) 354 | if err != nil { 355 | log.Fatal(err) 356 | } 357 | 358 | return fPath 359 | } 360 | 361 | // adjustCassetteName moves the '.gz' suffix to the end of the cassette name 362 | // instead of the middle 363 | func adjustCassetteName(cassetteName string) string { 364 | if strings.HasSuffix(cassetteName, ".gz") { 365 | return strings.TrimSuffix(cassetteName, ".gz") + ".cassette.gz" 366 | } 367 | return cassetteName + ".cassette" 368 | } 369 | 370 | // transformInterfacesInJSON looks for known properties in the JSON that are defined as interface{} 371 | // in their original Go structure and don't Unmarshal correctly. 372 | // 373 | // Example x509.Certificate.PublicKey: 374 | // When the type is rsa.PublicKey, Unmarshal attempts to map property "N" to a float64 because it is a number. 375 | // However, it really is a big.Int which does not fit float64 and makes Unmarshal fail. 376 | // 377 | // This is not an ideal solution but it works. In the future, we could consider adding a property that 378 | // records the original type and re-creates it post Unmarshal. 379 | func transformInterfacesInJSON(jsonString []byte) ([]byte, error) { 380 | // TODO: precompile this regexp perhaps via a receiver 381 | regex, err := regexp.Compile(`("PublicKey":{"N":)([0-9]+),`) 382 | if err != nil { 383 | return []byte{}, err 384 | } 385 | 386 | return []byte(regex.ReplaceAllString(string(jsonString), `$1"$2",`)), nil 387 | } 388 | 389 | func loadCassette(cassetteName, cassettePath string) (*cassette, error) { 390 | k7, err := readCassetteFromFile(cassetteName, cassettePath) 391 | if err != nil { 392 | return nil, err 393 | } 394 | 395 | // provide an empty cassette as a minimum 396 | if k7 == nil { 397 | k7 = &cassette{Name: cassetteName, Path: cassettePath} 398 | } 399 | 400 | // initial stats 401 | k7.stats.TracksLoaded = len(k7.Tracks) 402 | 403 | return k7, nil 404 | } 405 | 406 | // readCassetteFromFile reads the cassette file, if present. 407 | func readCassetteFromFile(cassetteName, cassettePath string) (*cassette, error) { 408 | filename := cassetteNameToFilename(cassetteName, cassettePath) 409 | 410 | k7 := &cassette{ 411 | Name: cassetteName, 412 | Path: cassettePath, 413 | } 414 | 415 | data, err := ioutil.ReadFile(filename) 416 | if os.IsNotExist(err) { 417 | return k7, nil 418 | } else if err != nil { 419 | return nil, err 420 | } 421 | 422 | cData, err := k7.gunzipFilter(data) 423 | if err != nil { 424 | return nil, err 425 | } 426 | 427 | // NOTE: Properties which are of type 'interface{}' are not handled very well 428 | if err := json.Unmarshal(cData, k7); err != nil { 429 | return nil, err 430 | } 431 | 432 | return k7, nil 433 | } 434 | 435 | // recordNewTrackToCassette saves a new track to a cassette. 436 | func recordNewTrackToCassette(cassette *cassette, req *http.Request, resp *http.Response, httpErr error) error { 437 | // create track 438 | track, err := newTrack(req, resp, httpErr) 439 | if err != nil { 440 | return err 441 | } 442 | 443 | // mark track as replayed since it's coming from a live request! 444 | track.replayed = true 445 | 446 | // add track to cassette 447 | cassette.addTrack(track) 448 | 449 | // save cassette 450 | return cassette.save() 451 | } 452 | 453 | // compress data and return the result 454 | func compress(data []byte) ([]byte, error) { 455 | var out bytes.Buffer 456 | 457 | w := gzip.NewWriter(&out) 458 | if _, err := io.Copy(w, bytes.NewBuffer(data)); err != nil { 459 | return nil, err 460 | } 461 | if err := w.Close(); err != nil { 462 | return nil, err 463 | } 464 | 465 | return out.Bytes(), nil 466 | } 467 | 468 | // decompress data and return the result 469 | func decompress(data []byte) ([]byte, error) { 470 | r, err := gzip.NewReader(bytes.NewBuffer(data)) 471 | if err != nil { 472 | return nil, err 473 | } 474 | data, err = ioutil.ReadAll(r) 475 | if err != nil { 476 | return nil, err 477 | } 478 | 479 | return data, nil 480 | } 481 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # govcr 2 | 3 | Records and replays HTTP / HTTPS interactions for offline unit / behavioural / integration tests thereby acting as an HTTP mock. 4 | 5 | This project was inspired by [php-vcr](https://github.com/php-vcr/php-vcr) which is a PHP port of [VCR](https://github.com/vcr/vcr) for ruby. 6 | 7 | This project is an adaptation for Google's Go / Golang programming language. 8 | 9 | ## Install 10 | 11 | ```bash 12 | go get github.com/seborama/govcr 13 | ``` 14 | 15 | For all available releases, please check the [releases](https://github.com/seborama/govcr/releases) tab on github. 16 | 17 | You can pick a specific major release for compatibility. For example, to use a v4.x release, use this command: 18 | 19 | ```bash 20 | go get gopkg.in/seborama/govcr.v4 21 | ``` 22 | 23 | And your source code would use this import: 24 | 25 | ```go 26 | import "gopkg.in/seborama/govcr.v4" 27 | ``` 28 | 29 | ## Glossary of Terms 30 | 31 | **VCR**: Video Cassette Recorder. In this context, a VCR refers to the overall engine and data that this project provides. A VCR is both an HTTP recorder and player. When you use a VCR, HTTP requests are replayed from previous recordings (**tracks** saved in **cassette** files on the filesystem). When no previous recording exists for the request, it is performed live on the HTTP server the request is intended to, after what it is saved to a **track** on the **cassette**. 32 | 33 | **cassette**: a sequential collection of **tracks**. This is in effect a JSON file saved under directory `./govcr-fixtures` (default). The **cassette** is given a name when creating the **VCR** which becomes the filename (with an extension of `.cassette`). 34 | 35 | **Long Play cassette**: a cassette compressed in gzip format. Such cassettes have a name that ends with '`.gz`'. 36 | 37 | **tracks**: a record of an HTTP request. It contains the request data, the response data, if available, or the error that occurred. 38 | 39 | **PCB**: Printed Circuit Board. This is an analogy that refers to the ability to supply customisations to some aspects of the behaviour of the **VCR** (for instance, disable recordings or ignore certain HTTP headers in the request when looking for a previously recorded **track**). 40 | 41 | ## Documentation 42 | 43 | **govcr** is a wrapper around the Go `http.Client` which offers the ability to replay pre-recorded HTTP requests ('**tracks**') instead of live HTTP calls. 44 | 45 | **govcr** can replay both successful and failed HTTP transactions. 46 | 47 | The code documentation can be found on [godoc](http://godoc.org/github.com/seborama/govcr). 48 | 49 | When using **govcr**'s `http.Client`, the request is matched against the **tracks** on the '**cassette**': 50 | 51 | - The **track** is played where a matching one exists on the **cassette**, 52 | - or the request is executed live to the HTTP server and then recorded on **cassette** for the next time (unless option **`DisableRecording`** is used). 53 | 54 | When multiple matching **tracks** exist for the same request on the **cassette** (this can be crafted manually inside the **cassette** or can be simulated when using **`RequestFilters`**), the **tracks** will be replayed in the same order as they were recorded. See the tests for an example (`TestPlaybackOrder`). 55 | 56 | When the last request matching **track** has been replayed, **govcr** cycles back to the first **track** again and so on. 57 | 58 | **Cassette** recordings are saved under `./govcr-fixtures` (by default) as `*.cassette` files in JSON format. 59 | 60 | You can enable **Long Play** mode that will compress the cassette content. This is enabled by using the cassette name suffix `.gz`. The compression used is standard gzip. 61 | 62 | It should be noted that the cassette name will be of the form 'MyCassette.gz" in your code but it will appear as "MyCassette.cassette.gz" on the file system. You can use `gzip` to compress and de-compress cassettes at will. See `TestLongPlay()` in `govcr_test.go` for an example usage. After running this test, notice the presence of the file `govcr-fixtures/TestLongPlay.cassette.gz`. You can view its contents in various ways such as with the `gzcat` command. 63 | 64 | ### VCRConfig 65 | 66 | This structure contains parameters for configuring your **govcr** recorder. 67 | 68 | #### `VCRConfig.CassettePath` - change the location of **cassette** files 69 | 70 | Example: 71 | 72 | ```go 73 | vcr := govcr.NewVCR("MyCassette", 74 | &govcr.VCRConfig{ 75 | CassettePath: "./govcr-fixtures", 76 | }) 77 | ``` 78 | 79 | #### `VCRConfig.DisableRecording` - playback or execute live without recording 80 | 81 | Example: 82 | 83 | ```go 84 | vcr := govcr.NewVCR("MyCassette", 85 | &govcr.VCRConfig{ 86 | DisableRecording: true, 87 | }) 88 | ``` 89 | 90 | In this configuration, govcr will still playback from **cassette** when a previously recorded **track** (HTTP interaction) exists or execute the request live if not. But in the latter case, it won't record a new **track** as per default behaviour. 91 | 92 | #### `VCRConfig.Logging` - disable logging 93 | 94 | Example: 95 | 96 | ```go 97 | vcr := govcr.NewVCR("MyCassette", 98 | &govcr.VCRConfig{ 99 | Logging: false, 100 | }) 101 | ``` 102 | 103 | This simply redirects all **govcr** logging to the OS's standard Null device (e.g. `nul` on Windows, or `/dev/null` on UN*X, etc). 104 | 105 | #### `VCRConfig.RemoveTLS` - disable TLS recording 106 | 107 | Example: 108 | 109 | ```go 110 | vcr := govcr.NewVCR("MyCassette", 111 | &govcr.VCRConfig{ 112 | RemoveTLS: true, 113 | }) 114 | ``` 115 | 116 | As RemoveTLS is enabled, **govcr** will not record the TLS data from the HTTP response on the cassette track. 117 | 118 | ## Features 119 | 120 | - Record extensive details about the request, response or error (network error, timeout, etc) to provide as accurate a playback as possible compared to the live HTTP request. 121 | 122 | - Recordings are JSON files and can be read in an editor. 123 | 124 | - Custom Go `http.Client`'s can be supplied. 125 | 126 | - Custom Go `http.Transport` / `http.RoundTrippers`. 127 | 128 | - http / https supported and any other protocol implemented by the supplied `http.Client`'s `http.RoundTripper`. 129 | 130 | - Hook to define HTTP headers that should be ignored from the HTTP request when attempting to retrieve a **track** for playback. 131 | This is useful to deal with non-static HTTP headers (for example, containing a timestamp). 132 | 133 | - 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. 134 | 135 | - 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). 136 | 137 | - Ability to switch off automatic recordings. 138 | This allows to play back existing records or make 139 | a live HTTP call without recording it to the **cassette**. 140 | 141 | - Record SSL certificates. 142 | 143 | ## Filter functions 144 | 145 | In some scenarios, it may not possible to match **tracks** the way they were recorded. 146 | 147 | For instance, the request contains a timestamp or a dynamically changing identifier, etc. 148 | 149 | In other situations, the response needs a transformation to be receivable. 150 | 151 | Building on from the above example, the response may need to provide the same identifier that the request sent. 152 | 153 | Filters help you deal with this kind of practical aspects of dynamic exchanges. 154 | 155 | Refer to [examples/example6.go](examples/example6.go) for advanced examples. 156 | 157 | ### Influencing request comparison programatically at runtime. 158 | 159 | `RequestFilters` receives the **request** Header / Body to allow their transformation. Both the live request and the recorded request on **track** are filtered in order to influence their comparison (e.g. remove an HTTP header to ignore it completely when matching). 160 | 161 | **Transformations are not persisted and only for the purpose of influencing comparison**. 162 | 163 | ### Runtime transforming of the response before sending it back to the client. 164 | 165 | `ResponseFilters` is the flip side of `RequestFilters`. It receives the **response** Header / Body to allow their transformation. Unlike `RequestFilters`, this influences the response returned from the request to the client. The request header is also passed to `ResponseFilter` but read-only and solely for the purpose of extracting request data for situations where it is needed to transform the Response (such as to retrieve an identifier that must be the same in the request and the response). 166 | 167 | ## Examples 168 | 169 | ### Example 1 - Simple VCR 170 | 171 | When no special HTTP Transport is required by your `http.Client`, you can use VCR with the default transport: 172 | 173 | ```go 174 | package main 175 | 176 | import ( 177 | "fmt" 178 | 179 | "github.com/seborama/govcr" 180 | ) 181 | 182 | const example1CassetteName = "MyCassette1" 183 | 184 | // Example1 is an example use of govcr. 185 | func Example1() { 186 | vcr := govcr.NewVCR(example1CassetteName, nil) 187 | vcr.Client.Get("http://example.com/foo") 188 | fmt.Printf("%+v\n", vcr.Stats()) 189 | } 190 | ``` 191 | 192 | If the **cassette** exists and a **track** matches the request, it will be played back without any real HTTP call to the live server. 193 | 194 | Otherwise, a real live HTTP call will be made and recorded in a new track added to the **cassette**. 195 | 196 | **Tip:** 197 | 198 | To experiment with this example, run it at least twice: the first time (when the **cassette** does **not** exist), it will make a live call. Subsequent executions will use the **track** on **cassette** to retrieve the recorded response instead of making a live call. 199 | 200 | ### Example 2 - Custom VCR Transport 201 | 202 | Sometimes, your application will create its own `http.Client` wrapper or will initialise the `http.Client`'s Transport (for instance when using https). 203 | 204 | In such cases, you can pass the `http.Client` object of your application to VCR. 205 | 206 | VCR will wrap your `http.Client` with its own which you can inject back into your application. 207 | 208 | ```go 209 | package main 210 | 211 | import ( 212 | "crypto/tls" 213 | "fmt" 214 | "net/http" 215 | "time" 216 | 217 | "github.com/seborama/govcr" 218 | ) 219 | 220 | const example2CassetteName = "MyCassette2" 221 | 222 | // myApp is an application container. 223 | type myApp struct { 224 | httpClient *http.Client 225 | } 226 | 227 | func (app myApp) Get(url string) { 228 | app.httpClient.Get(url) 229 | } 230 | 231 | // Example2 is an example use of govcr. 232 | // It shows the use of a VCR with a custom Client. 233 | // Here, the app executes a GET request. 234 | func Example2() { 235 | // Create a custom http.Transport. 236 | tr := http.DefaultTransport.(*http.Transport) 237 | tr.TLSClientConfig = &tls.Config{ 238 | InsecureSkipVerify: true, // just an example, not recommended 239 | } 240 | 241 | // Create an instance of myApp. 242 | // It uses the custom Transport created above and a custom Timeout. 243 | myapp := &myApp{ 244 | httpClient: &http.Client{ 245 | Transport: tr, 246 | Timeout: 15 * time.Second, 247 | }, 248 | } 249 | 250 | // Instantiate VCR. 251 | vcr := govcr.NewVCR(example2CassetteName, 252 | &govcr.VCRConfig{ 253 | Client: myapp.httpClient, 254 | }) 255 | 256 | // Inject VCR's http.Client wrapper. 257 | // The original transport has been preserved, only just wrapped into VCR's. 258 | myapp.httpClient = vcr.Client 259 | 260 | // Run request and display stats. 261 | myapp.Get("https://example.com/foo") 262 | fmt.Printf("%+v\n", vcr.Stats()) 263 | } 264 | ``` 265 | 266 | ### Example 3 - Custom VCR, POST method 267 | 268 | Please refer to the source file in the [examples](examples) directory. 269 | 270 | This example is identical to Example 2 but with a POST request rather than a GET. 271 | 272 | ### Example 4 - Custom VCR with a RequestFilters 273 | 274 | This example shows how to handle situations where a header in the request needs to be ignored (or the **track** would not match and hence would not be replayed). 275 | 276 | For this example, logging is switched on. This is achieved with `Logging: true` in `VCRConfig` when calling `NewVCR`. 277 | 278 | ```go 279 | package main 280 | 281 | import ( 282 | "fmt" 283 | "strings" 284 | "time" 285 | 286 | "net/http" 287 | 288 | "github.com/seborama/govcr" 289 | ) 290 | 291 | const example4CassetteName = "MyCassette4" 292 | 293 | // Example4 is an example use of govcr. 294 | // The request contains a custom header 'X-Custom-My-Date' which varies with every request. 295 | // This example shows how to exclude a particular header from the request to facilitate 296 | // matching a previous recording. 297 | // Without the RequestFilters, the headers would not match and hence the playback would not 298 | // happen! 299 | func Example4() { 300 | vcr := govcr.NewVCR(example4CassetteName, 301 | &govcr.VCRConfig{ 302 | RequestFilters: govcr.RequestFilters{ 303 | govcr.RequestDeleteHeaderKeys("X-Custom-My-Date"), 304 | }, 305 | Logging: true, 306 | }) 307 | 308 | // create a request with our custom header 309 | req, err := http.NewRequest("POST", "http://example.com/foo", nil) 310 | if err != nil { 311 | fmt.Println(err) 312 | } 313 | req.Header.Add("X-Custom-My-Date", time.Now().String()) 314 | 315 | // run the request 316 | vcr.Client.Do(req) 317 | fmt.Printf("%+v\n", vcr.Stats()) 318 | } 319 | ``` 320 | 321 | **Tip:** 322 | 323 | Remove the RequestFilters from the VCRConfig and re-run the example. Check the stats: notice how the tracks **no longer** replay. 324 | 325 | ### Example 5 - Custom VCR with a RequestFilters and ResponseFilters 326 | 327 | This example shows how to handle situations where a transaction Id in the header needs to be present in the response. 328 | This could be as part of a contract validation between server and client. 329 | 330 | Note: This is useful when some of the data in the **request** Header / Body needs to be transformed 331 | before it can be evaluated for comparison for playback. 332 | 333 | ```go 334 | package main 335 | 336 | import ( 337 | "fmt" 338 | "strings" 339 | "time" 340 | 341 | "net/http" 342 | 343 | "github.com/seborama/govcr" 344 | ) 345 | 346 | const example5CassetteName = "MyCassette5" 347 | 348 | // Example5 is an example use of govcr. 349 | // Supposing a fictional application where the request contains a custom header 350 | // 'X-Transaction-Id' which must be matched in the response from the server. 351 | // When replaying, the request will have a different Transaction Id than that which was recorded. 352 | // Hence the protocol (of this fictional example) is broken. 353 | // To circumvent that, we inject the new request's X-Transaction-Id into the recorded response. 354 | // Without the ResponseFilters, the X-Transaction-Id in the header would not match that 355 | // of the recorded response and our fictional application would reject the response on validation! 356 | func Example5() { 357 | vcr := govcr.NewVCR(example5CassetteName, 358 | &govcr.VCRConfig{ 359 | RequestFilters: govcr.RequestFilters{ 360 | govcr.RequestDeleteHeaderKeys("X-Transaction-Id"), 361 | }, 362 | ResponseFilters: govcr.ResponseFilters{ 363 | // overwrite X-Transaction-Id in the Response with that from the Request 364 | govcr.ResponseTransferHeaderKeys("X-Transaction-Id"), 365 | }, 366 | Logging: true, 367 | }) 368 | 369 | // create a request with our custom header 370 | req, err := http.NewRequest("POST", "http://example.com/foo5", nil) 371 | if err != nil { 372 | fmt.Println(err) 373 | } 374 | req.Header.Add("X-Transaction-Id", time.Now().String()) 375 | 376 | // run the request 377 | resp, err := vcr.Client.Do(req) 378 | if err != nil { 379 | fmt.Println(err) 380 | } 381 | 382 | // verify outcome 383 | if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") { 384 | fmt.Println("Header transaction Id verification failed - this would be the live request!") 385 | } else { 386 | fmt.Println("Header transaction Id verification passed - this would be the replayed track!") 387 | } 388 | 389 | fmt.Printf("%+v\n", vcr.Stats()) 390 | } 391 | ``` 392 | 393 | ### More examples 394 | 395 | Refer to [examples/example6.go](examples/example6.go) for advanced examples. 396 | 397 | All examples are in the [examples](examples) directory. 398 | 399 | ### Stats 400 | 401 | VCR provides some statistics. 402 | 403 | To access the stats, call `vcr.Stats()` where vcr is the `VCR` instance obtained from `NewVCR(...)`. 404 | 405 | ### Run the examples 406 | 407 | Please refer to the `examples` directory for examples of code and uses. 408 | 409 | **Observe the output of the examples between the `1st run` and the `2nd run` of each example.** 410 | 411 | The first time they run, they perform a live HTTP call (`Executing request to live server`). 412 | 413 | However, on second execution (and sub-sequent executions as long as the **cassette** is not deleted) 414 | **govcr** retrieves the previously recorded request and plays it back without live HTTP call (`Found a matching track`). You can disconnect from the internet and still playback HTTP requests endlessly! 415 | 416 | #### Make utility 417 | 418 | ```bash 419 | make examples 420 | ``` 421 | 422 | #### Manually 423 | 424 | ```bash 425 | cd examples 426 | go run *.go 427 | ``` 428 | 429 | #### Output 430 | 431 | First execution - notice the stats show that a **track** was recorded (from a live HTTP call). 432 | 433 | Second execution - no **track** is recorded (no live HTTP call) but 1 **track** is loaded and played back. 434 | 435 | ```bash 436 | Running Example1... 437 | 1st run ======================================================= 438 | {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 439 | 2nd run ======================================================= 440 | {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 441 | Complete ====================================================== 442 | 443 | Running Example2... 444 | 1st run ======================================================= 445 | {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 446 | 2nd run ======================================================= 447 | {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 448 | Complete ====================================================== 449 | 450 | Running Example3... 451 | 1st run ======================================================= 452 | {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 453 | 2nd run ======================================================= 454 | {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 455 | Complete ====================================================== 456 | 457 | Running Example4... 458 | 1st run ======================================================= 459 | 2018/10/25 00:12:56 INFO - Cassette 'MyCassette4' - Executing request to live server for POST http://www.example.com/foo 460 | 2018/10/25 00:12:56 INFO - Cassette 'MyCassette4' - Recording new track for POST http://www.example.com/foo as POST http://www.example.com/foo 461 | {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 462 | 2nd run ======================================================= 463 | 2018/10/25 00:12:56 INFO - Cassette 'MyCassette4' - Found a matching track for POST http://www.example.com/foo 464 | {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 465 | Complete ====================================================== 466 | 467 | Running Example5... 468 | 1st run ======================================================= 469 | 2018/10/25 00:12:56 INFO - Cassette 'MyCassette5' - Executing request to live server for POST http://www.example.com/foo5 470 | 2018/10/25 00:12:56 INFO - Cassette 'MyCassette5' - Recording new track for POST http://www.example.com/foo5 as POST http://www.example.com/foo5 471 | Header transaction Id verification failed - this would be the live request! 472 | {TracksLoaded:0 TracksRecorded:1 TracksPlayed:0} 473 | 2nd run ======================================================= 474 | 2018/10/25 00:12:56 INFO - Cassette 'MyCassette5' - Found a matching track for POST http://www.example.com/foo5 475 | Header transaction Id verification passed - this would be the replayed track! 476 | {TracksLoaded:1 TracksRecorded:0 TracksPlayed:1} 477 | Complete ====================================================== 478 | ``` 479 | 480 | ## Run the tests 481 | 482 | ```bash 483 | make test 484 | ``` 485 | 486 | or 487 | 488 | ```bash 489 | go test -race -cover 490 | ``` 491 | 492 | ## Bugs 493 | 494 | - None known 495 | 496 | ## Improvements 497 | 498 | - When unmarshaling the cassette fails, rather than fail altogether, it would be preferable to revert to live HTTP call. 499 | 500 | - The code has a number of TODO's which should either be taken action upon or removed! 501 | 502 | ## Limitations 503 | 504 | ### Go empty interfaces (`interface{}`) 505 | 506 | Some properties / objects in http.Response are defined as `interface{}`. 507 | This can cause json.Unmarshall to fail (example: when the original type was `big.Int` with a big interger indeed - `json.Unmarshal` attempts to convert to float64 and fails). 508 | 509 | Currently, this is dealt with by converting the output of the JSON produced by `json.Marshal` (big.Int is changed to a string). 510 | 511 | ### Support for multiple values in HTTP headers 512 | 513 | Repeat HTTP headers may not be properly handled. A long standing TODO in the code exists but so far no one has complained :-) 514 | 515 | ### HTTP transport errors 516 | 517 | **govcr** also records `http.Client` errors (network down, blocking firewall, timeout, etc) in the **track** for future play back. 518 | 519 | Since `errors` is an interface, when it is unmarshalled into JSON, the Go type of the `error` is lost. 520 | 521 | To circumvent this, **govcr** serialises the object type (`ErrType`) and the error message (`ErrMsg`) in the **track** record. 522 | 523 | Objects cannot be created by name at runtime in Go. Rather than re-create the original error object, *govcr* creates a standard error object with an error string made of both the `ErrType` and `ErrMsg`. 524 | 525 | In practice, the implications for you depend on how much you care about the error type. If all you need to know is that an error occurred, you won't mind this limitation. 526 | 527 | Mitigation: Support for common errors (network down) has been implemented. Support for more error types can be implemented, if there is appetite for it. 528 | 529 | ## Contribute 530 | 531 | You are welcome to submit a PR to contribute. 532 | 533 | Please follow a TDD workflow: tests must be present and avoid toxic DDT (dev driven testing). 534 | --------------------------------------------------------------------------------