Skip to content

Commit

Permalink
Add advanced filtering (#32)
Browse files Browse the repository at this point in the history
Add advanced filtering:

When running tests where multiple request types are used, it if quite difficult to set up proper filtering.

To help this, made it possible to chain multiple filters and check URL and method.

Furthermore the URL can now be modified.

For future-proofing the Request and Response structs can now be extended with more stuff without breaking compatibility.
  • Loading branch information
klauspost authored and seborama committed Oct 24, 2018
1 parent a20ea7b commit a744811
Show file tree
Hide file tree
Showing 14 changed files with 1,220 additions and 108 deletions.
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,11 @@ This simply redirects all **govcr** logging to the OS's standard Null device (e.

### Influencing request comparison programatically at runtime.

`RequestFilterFunc` receives the request Header / Body to allow their transformation. Both the live request and the replayed request are filtered at comparison time. **Transformations are not persisted and only for the purpose of influencing comparison**.
`RequestFilters` receives the request Header / Body to allow their transformation. Both the live request and the replayed request are filtered at comparison time. **Transformations are not persisted and only for the purpose of influencing comparison**.

### Runtime transforming of the response before sending it back to the client.

`ResponseFilterFunc` is the flip side of `RequestFilterFunc`. It receives the response Header / Body to allow their transformation. Unlike `RequestFilterFunc`, this influences the response returned from the request to the client. The request header is also passed to `ResponseFilterFunc` but read-only and solely for the purpose of extracting request data for situations where it is needed to transform the Response.
`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.

## Examples

Expand Down Expand Up @@ -276,12 +276,12 @@ func Example4() {
}
```

### Example 5 - Custom VCR with a ExcludeHeaderFunc and ResponseFilterFunc
### Example 5 - Custom VCR with a ExcludeHeaderFunc and ResponseFilters

This example shows how to handle situations where a transaction Id in the header needs to be present in the response.
This could be as part of a contract validation between server and client.

Note: `RequestFilterFunc` achieves a similar purpose with the **request** Header / Body.
Note: `RequestFilters` achieves a similar purpose with the **request** Header / Body.
This is useful when some of the data in the **request** Header / Body needs to be transformed
before it can be evaluated for comparison for playback.

Expand All @@ -306,7 +306,7 @@ const example5CassetteName = "MyCassette5"
// When replaying, the request will have a different Transaction Id than that which was recorded.
// Hence the protocol (of this fictional example) is broken.
// To circumvent that, we inject the new request's X-Transaction-Id into the recorded response.
// Without the ResponseFilterFunc, the X-Transaction-Id in the header would not match that
// Without the ResponseFilters, the X-Transaction-Id in the header would not match that
// of the recorded response and our fictional application would reject the response on validation!
func Example5() {
vcr := govcr.NewVCR(example5CassetteName,
Expand All @@ -315,12 +315,10 @@ func Example5() {
// ignore the X-Transaction-Id since it changes per-request
return strings.ToLower(key) == "x-transaction-id"
},
ResponseFilterFunc: func(respHeader http.Header, respBody string, reqHeader http.Header) (*http.Header, *string) {
// overwrite X-Transaction-Id in the Response with that from the Request
respHeader.Set("X-Transaction-Id", reqHeader.Get("X-Transaction-Id"))

return &respHeader, &respBody
},
ResponseFilters: govcr.ResponseFilters{
// overwrite X-Transaction-Id in the Response with that from the Request
govcr.ResponseTransferHeaderKeys("X-Transaction-Id"),
},
Logging: true,
})

Expand Down
23 changes: 23 additions & 0 deletions cassette.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ type request struct {
Body []byte
}

// Request transforms internal request to a filter request.
func (r request) Request() Request {
res := Request{
Header: r.Header,
Body: r.Body,
Method: r.Method,
}
if r.URL != nil {
res.URL = *r.URL
}
return res
}

// response is a recorded HTTP response.
type response struct {
Status string
Expand All @@ -40,6 +53,16 @@ type response struct {
TLS *tls.ConnectionState
}

// Response returns the internal response to a filter response.
func (r response) Response(req Request) Response {
return Response{
req: req,
Body: r.Body,
Header: r.Header,
StatusCode: r.StatusCode,
}
}

// track is a recording (HTTP request + response) in a cassette.
type track struct {
Request request
Expand Down
1 change: 0 additions & 1 deletion doc.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

/*
Package govcr records and replays HTTP interactions for offline unit / behavioural / integration tests thereby acting as an HTTP mock.
Expand Down
6 changes: 2 additions & 4 deletions examples/example5.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,9 @@ func Example5() {
// ignore the X-Transaction-Id since it changes per-request
return strings.ToLower(key) == "x-transaction-id"
},
ResponseFilterFunc: func(respHeader http.Header, respBody []byte, reqHeader http.Header) (*http.Header, *[]byte) {
ResponseFilters: govcr.ResponseFilters{
// overwrite X-Transaction-Id in the Response with that from the Request
respHeader.Set("X-Transaction-Id", reqHeader.Get("X-Transaction-Id"))

return &respHeader, &respBody
govcr.ResponseTransferHeaderKeys("X-Transaction-Id"),
},
Logging: true,
})
Expand Down
112 changes: 112 additions & 0 deletions examples/example6.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package main

import (
"fmt"
"math/rand"
"net/http"
"time"

"github.com/seborama/govcr"
)

const example6CassetteName = "MyCassette6"

// Example6 is an example use of govcr.
// This will show how to do conditional rewrites.
// For example, your request has a "/order/{random}" path
// and we want to rewrite it to /order/1234 so we can match it later.
// We change the response status code.
// We add headers based on request method.
func Example6() {
cfg := govcr.VCRConfig{
Logging: true,
}

// The filter will neutralize a value in the URL.
// In this case we rewrite /order/{random} to /order/1234
replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request {
// Replace path with a predictable one.
req.URL.Path = "/order/1234"
return req
})
// Only execute when we match path.
replacePath = replacePath.OnPath(`example\.com\/order\/`)

// Add to request filters.
cfg.RequestFilters.Add(replacePath)
cfg.RequestFilters.Add(govcr.RequestDeleteHeaderKeys("X-Transaction-Id"))

// Add filters
cfg.ResponseFilters.Add(
// Always transfer 'X-Transaction-Id' as in example 5.
govcr.ResponseTransferHeaderKeys("X-Transaction-Id"),

// Change status 404 to 202.
func(resp govcr.Response) govcr.Response {
if resp.StatusCode == http.StatusNotFound {
resp.StatusCode = http.StatusAccepted
}
return resp
},

// Add header if method was "GET"
govcr.ResponseFilter(func(resp govcr.Response) govcr.Response {
resp.Header.Add("method-was-get", "true")
return resp
}).OnMethod(http.MethodGet),

// Add header if method was "POST"
govcr.ResponseFilter(func(resp govcr.Response) govcr.Response {
resp.Header.Add("method-was-post", "true")
return resp
}).OnMethod(http.MethodPost),

// Add actual request URL to header.
govcr.ResponseFilter(func(resp govcr.Response) govcr.Response {
url := resp.Request().URL
resp.Header.Add("get-url", url.String())
return resp
}).OnMethod(http.MethodGet),
)

orderID := fmt.Sprint(rand.Int63())
vcr := govcr.NewVCR(example6CassetteName, &cfg)

// create a request with our custom header and a random url part.
req, err := http.NewRequest("POST", "http://www.example.com/order/"+orderID, nil)
if err != nil {
fmt.Println(err)
}
runRequest(req, vcr)

// create a request with our custom header and a random url part.
req, err = http.NewRequest("GET", "http://www.example.com/order/"+orderID, nil)
if err != nil {
fmt.Println(err)
}
runRequest(req, vcr)

}

func runRequest(req *http.Request, vcr *govcr.VCRControlPanel) {
req.Header.Add("X-Transaction-Id", time.Now().String())
// run the request
resp, err := vcr.Client.Do(req)
if err != nil {
fmt.Println(err)
return
}
// verify outcome
if req.Header.Get("X-Transaction-Id") != resp.Header.Get("X-Transaction-Id") {
fmt.Println("Header transaction Id verification failed - this would be the live request!")
} else {
fmt.Println("Header transaction Id verification passed - this would be the replayed track!")
}

// print outcome.
fmt.Println("Status code:", resp.StatusCode, " (should be 404 on real and 202 on replay)")
fmt.Println("method-was-get:", resp.Header.Get("method-was-get"), "(should never be true on GET)")
fmt.Println("method-was-post:", resp.Header.Get("method-was-post"), "(should be true on replay on POST)")
fmt.Println("get-url:", resp.Header.Get("get-url"), "(actual url of the request, not of the track)")
fmt.Printf("%+v\n", vcr.Stats())
}
117 changes: 117 additions & 0 deletions examples/example7.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"net/http/httptest"
"regexp"

"github.com/seborama/govcr"
)

const example7CassetteName = "MyCassette7"

// Order is out example body we want to modify.
type Order struct {
ID string `json:"id"`
Name string `json:"name"`
}

// Example7 is an example use of govcr.
// This will show how bodies can be rewritten.
// We will take a varying ID from the request URL, neutralize it and also change the ID in the body of the response.
func Example7() {
cfg := govcr.VCRConfig{
Logging: true,
}

// Regex to extract the ID from the URL.
reOrderID := regexp.MustCompile(`/order/([^/]+)`)

// Create a local test server that serves out responses.
handler := func(w http.ResponseWriter, r *http.Request) {
id := reOrderID.FindStringSubmatch(r.URL.String())
if len(id) < 2 {
w.WriteHeader(404)
return
}

w.WriteHeader(200)
b, err := json.Marshal(Order{
ID: id[1],
Name: "Test Order",
})
if err != nil {
w.WriteHeader(500)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(200)
w.Write(b)
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()

// The filter will neutralize a value in the URL.
// In this case we rewrite /order/{random} to /order/1234
// and replacing the host so it doesn't depend on the random port number.
replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request {
req.URL.Path = "/order/1234"
req.URL.Host = "127.0.0.1"
return req
})

// Only execute when we match path.
cfg.RequestFilters.Add(replacePath.OnPath(`/order/`))

cfg.ResponseFilters.Add(
govcr.ResponseFilter(func(resp govcr.Response) govcr.Response {
req := resp.Request()

// Find the requested ID:
orderID := reOrderID.FindStringSubmatch(req.URL.String())

// Unmarshal body.
var o Order
err := json.Unmarshal(resp.Body, &o)
if err != nil {
panic(err)
}

// Change the ID
o.ID = orderID[1]

// Replace the body.
resp.Body, err = json.Marshal(o)
if err != nil {
panic(err)
}
return resp
}).OnStatus(200),
)

orderID := fmt.Sprint(rand.Int63())
vcr := govcr.NewVCR(example7CassetteName, &cfg)

// create a request with our custom header and a random url part.
req, err := http.NewRequest("GET", server.URL+"/order/"+orderID, nil)
if err != nil {
fmt.Println(err)
}
fmt.Println("GET", req.URL.String())

// run the request
resp, err := vcr.Client.Do(req)
if err != nil {
fmt.Println("Error:", err)
return
}
// print outcome.
fmt.Println("Status code:", resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println("Returned Body:", string(body))
fmt.Printf("%+v\n", vcr.Stats())
}
4 changes: 3 additions & 1 deletion examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ func runExample(name, cassetteName string, f func()) {
f()
fmt.Println("2nd run =======================================================")
f()
fmt.Println("Complete ======================================================\n\n")
fmt.Println("Complete ======================================================")
}
func main() {
runExample("Example1", example1CassetteName, Example1)
runExample("Example2", example2CassetteName, Example2)
runExample("Example3", example3CassetteName, Example3)
runExample("Example4", example4CassetteName, Example4)
runExample("Example5", example5CassetteName, Example5)
runExample("Example6", example6CassetteName, Example6)
runExample("Example7", example7CassetteName, Example7)
}
Loading

0 comments on commit a744811

Please sign in to comment.