| package mockhttpclient |
| |
| import ( |
| "bytes" |
| "fmt" |
| "io" |
| "math" |
| "net/http" |
| "reflect" |
| "sync" |
| |
| "github.com/texttheater/golang-levenshtein/levenshtein" |
| "go.skia.org/infra/go/sklog" |
| "go.skia.org/infra/go/util" |
| ) |
| |
| const ( |
| TEST_FAILED_STATUS_CODE = 599 |
| ) |
| |
| // URLMock implements http.RoundTripper but returns mocked responses. It |
| // provides two methods for mocking responses to requests for particular URLs: |
| // |
| // - Mock: Adds a fake response for the given URL to be used every time a |
| // request is made for that URL. |
| // |
| // - MockOnce: Adds a fake response for the given URL to be used one time. |
| // MockOnce may be called multiple times for the same URL in order to |
| // simulate the response changing over time. Takes precedence over mocks |
| // specified using Mock. |
| // |
| // Examples: |
| // |
| // // Mock out a URL to always respond with the same body. |
| // m := NewURLMock() |
| // m.Mock("https://www.google.com", MockGetDialogue([]byte("Here's a response."))) |
| // res, _ := m.Client().Get("https://www.google.com") |
| // respBody, _ := io.ReadAll(res.Body) // respBody == []byte("Here's a response.") |
| // |
| // // Mock out a URL to give different responses. |
| // m.MockOnce("https://www.google.com", MockGetDialogue([]byte("hi"))) |
| // m.MockOnce("https://www.google.com", MockGetDialogue([]byte("Second response."))) |
| // res1, _ := m.Client().Get("https://www.google.com") |
| // body1, _ := io.ReadAll(res1.Body) // body1 == []byte("hi") |
| // res2, _ := m.Client().Get("https://www.google.com") |
| // body2, _ := io.ReadAll(res2.Body) // body2 == []byte("Second response.") |
| // // Fall back on the value previously set using Mock(): |
| // res3, _ := m.Client().Get("https://www.google.com") |
| // body3, _ := io.ReadAll(res3.Body) // body3 == []byte("Here's a response.") |
| type URLMock struct { |
| mtx sync.Mutex |
| mockAlways map[string]MockDialogue |
| mockOnce map[string][]MockDialogue |
| } |
| |
| var DONT_CARE_REQUEST = []byte{0, 1, 2, 3, 4} |
| |
| type MockDialogue struct { |
| requestMethod string |
| requestType string |
| requestPayload []byte |
| requestHeaders map[string][]string |
| |
| responseStatus string |
| responseCode int |
| responsePayload []byte |
| responseHeaders map[string][]string |
| } |
| |
| // ResponseHeader adds the given header to the response. |
| func (md *MockDialogue) ResponseHeader(key, value string) { |
| if md.responseHeaders == nil { |
| md.responseHeaders = map[string][]string{} |
| } |
| md.responseHeaders[key] = append(md.responseHeaders[key], value) |
| } |
| |
| // RequestHeader adds the given header to the request. |
| func (md *MockDialogue) RequestHeader(key, value string) { |
| if md.requestHeaders == nil { |
| md.requestHeaders = map[string][]string{} |
| } |
| md.requestHeaders[key] = append(md.requestHeaders[key], value) |
| } |
| |
| func (md *MockDialogue) GetResponse(r *http.Request) (*http.Response, error) { |
| if md.requestMethod != r.Method { |
| return nil, fmt.Errorf("Wrong Method, expected %q, but was %q", md.requestMethod, r.Method) |
| } |
| for key, vals := range md.requestHeaders { |
| for _, val := range vals { |
| if !util.In(val, r.Header[key]) { |
| return nil, fmt.Errorf("Request does not include header \"%s: %s\"", key, val) |
| } |
| } |
| } |
| if md.requestPayload == nil { |
| if r.Body != nil { |
| requestBody, _ := io.ReadAll(r.Body) |
| return nil, fmt.Errorf("No request payload expected, but was %s (%#v) ", string(requestBody), r.Body) |
| } |
| } else { |
| if ct := r.Header.Get("Content-Type"); md.requestType != ct { |
| return nil, fmt.Errorf("Content-Type was wrong, expected %q, but was %q", md.requestType, ct) |
| } |
| defer util.Close(r.Body) |
| requestBody, err := io.ReadAll(r.Body) |
| if err != nil { |
| return nil, fmt.Errorf("Error reading request body: %s", err) |
| } |
| if !reflect.DeepEqual(md.requestPayload, DONT_CARE_REQUEST) && !reflect.DeepEqual(md.requestPayload, requestBody) { |
| return nil, fmt.Errorf("Wrong request payload, expected \n%s, but was \n%s", md.requestPayload, requestBody) |
| } |
| } |
| return &http.Response{ |
| Body: &respBodyCloser{bytes.NewReader(md.responsePayload)}, |
| Header: md.responseHeaders, |
| Status: md.responseStatus, |
| StatusCode: md.responseCode, |
| }, nil |
| } |
| |
| // ServeHTTP implements http.Handler. |
| func (md MockDialogue) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| resp, err := md.GetResponse(r) |
| if err != nil { |
| http.Error(w, err.Error(), TEST_FAILED_STATUS_CODE) |
| return |
| } |
| defer util.Close(resp.Body) |
| // TODO(benjaminwagner): I don't see an easy way to include resp.Status. |
| w.WriteHeader(resp.StatusCode) |
| _, err = io.Copy(w, resp.Body) |
| if err != nil { |
| http.Error(w, err.Error(), TEST_FAILED_STATUS_CODE) |
| return |
| } |
| } |
| |
| func MockGetDialogue(responseBody []byte) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "GET", |
| requestType: "", |
| requestPayload: nil, |
| |
| responseStatus: "OK", |
| responseCode: http.StatusOK, |
| responsePayload: responseBody, |
| } |
| } |
| |
| func MockGetError(responseStatus string, responseCode int) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "GET", |
| requestType: "", |
| requestPayload: nil, |
| |
| responseStatus: responseStatus, |
| responseCode: responseCode, |
| responsePayload: []byte{}, |
| } |
| } |
| |
| func MockGetWithRequestDialogue(requestType string, requestBody, responseBody []byte) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "GET", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: "OK", |
| responseCode: http.StatusOK, |
| responsePayload: responseBody, |
| } |
| } |
| |
| func MockGetDialogueWithResponseHeaders(responseBody []byte, responseHeaders map[string][]string) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "GET", |
| requestType: "", |
| requestPayload: nil, |
| responseStatus: "OK", |
| responseCode: http.StatusOK, |
| responsePayload: responseBody, |
| responseHeaders: responseHeaders, |
| } |
| } |
| |
| func MockPostDialogue(requestType string, requestBody, responseBody []byte) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "POST", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: "OK", |
| responseCode: http.StatusOK, |
| responsePayload: responseBody, |
| } |
| } |
| |
| func MockPostDialogueWithResponseCode(requestType string, requestBody, responseBody []byte, responseCode int) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "POST", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: "OK", |
| responseCode: responseCode, |
| responsePayload: responseBody, |
| } |
| } |
| |
| func MockPostError(requestType string, requestBody []byte, responseStatus string, responseCode int) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "POST", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: responseStatus, |
| responseCode: responseCode, |
| responsePayload: []byte{}, |
| } |
| } |
| |
| func MockPutDialogue(requestType string, requestBody, responseBody []byte) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "PUT", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: "OK", |
| responseCode: http.StatusOK, |
| responsePayload: responseBody, |
| } |
| } |
| |
| func MockPatchDialogue(requestType string, requestBody, responseBody []byte) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "PATCH", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: "OK", |
| responseCode: http.StatusOK, |
| responsePayload: responseBody, |
| } |
| } |
| |
| func MockDeleteDialogueWithResponseCode(requestType string, requestBody, responseBody []byte, responseCode int) MockDialogue { |
| return MockDialogue{ |
| requestMethod: "DELETE", |
| requestType: requestType, |
| requestPayload: requestBody, |
| |
| responseStatus: "OK", |
| responseCode: responseCode, |
| responsePayload: responseBody, |
| } |
| } |
| |
| // Mock adds a mocked response for the given URL; whenever this URLMock is used |
| // as a transport for an http.Client, requests to the given URL will always |
| // receive the given body in their responses. Mocks specified using Mock() are |
| // independent of those specified using MockOnce(), except that those specified |
| // using MockOnce() take precedence when present. |
| func (m *URLMock) Mock(url string, md MockDialogue) { |
| m.mtx.Lock() |
| defer m.mtx.Unlock() |
| m.mockAlways[url] = md |
| } |
| |
| // MockOnce adds a mocked response for the given URL, to be used exactly once. |
| // Mocks are stored in a FIFO queue and removed from the queue as they are |
| // requested. Therefore, multiple requests to the same URL must each correspond |
| // to a call to MockOnce, in the same order that the requests will be made. |
| // Mocks specified this way are independent of those specified using Mock(), |
| // except that those specified using MockOnce() take precedence when present. |
| func (m *URLMock) MockOnce(url string, md MockDialogue) { |
| m.mtx.Lock() |
| defer m.mtx.Unlock() |
| if _, ok := m.mockOnce[url]; !ok { |
| m.mockOnce[url] = []MockDialogue{} |
| } |
| m.mockOnce[url] = append(m.mockOnce[url], md) |
| } |
| |
| // Client returns an http.Client instance which uses the URLMock. |
| func (m *URLMock) Client() *http.Client { |
| return &http.Client{ |
| Transport: m, |
| } |
| } |
| |
| // RoundTrip is an implementation of http.RoundTripper.RoundTrip. It fakes |
| // responses for requests to URLs based on past calls to Mock() and MockOnce(). |
| func (m *URLMock) RoundTrip(r *http.Request) (*http.Response, error) { |
| url := r.URL.String() |
| var md *MockDialogue |
| // Unlock not deferred because we want to be able to handle multiple |
| // requests simultaneously. |
| closest := "(no mocked URLs)" |
| m.mtx.Lock() |
| if resps, ok := m.mockOnce[url]; ok { |
| if resps != nil && len(resps) > 0 { |
| md = &resps[0] |
| m.mockOnce[url] = m.mockOnce[url][1:] |
| } |
| } else if data, ok := m.mockAlways[url]; ok { |
| md = &data |
| } else { |
| // For debugging; find the closest match. |
| min := math.MaxInt32 |
| for mocked := range m.mockOnce { |
| d := levenshtein.DistanceForStrings([]rune(url), []rune(mocked), levenshtein.DefaultOptions) |
| if d < min { |
| min = d |
| closest = mocked |
| } |
| } |
| for mocked := range m.mockAlways { |
| d := levenshtein.DistanceForStrings([]rune(url), []rune(mocked), levenshtein.DefaultOptions) |
| if d < min { |
| min = d |
| closest = mocked |
| } |
| } |
| |
| } |
| m.mtx.Unlock() |
| if md == nil { |
| return nil, fmt.Errorf("Unknown URL %q; closest match: %s", url, closest) |
| } |
| return md.GetResponse(r) |
| } |
| |
| // Empty returns true iff all of the URLs registered via MockOnce() have been |
| // used. |
| func (m *URLMock) Empty() bool { |
| m.mtx.Lock() |
| defer m.mtx.Unlock() |
| for url, resps := range m.mockOnce { |
| if resps != nil && len(resps) > 0 { |
| sklog.Errorf("not empty: %s", url) |
| return false |
| } |
| } |
| return true |
| } |
| |
| // List returns the list of all URLs registered via MockOnce. |
| func (m *URLMock) List() []string { |
| rv := []string{} |
| for url, resps := range m.mockOnce { |
| if resps != nil && len(resps) > 0 { |
| rv = append(rv, url) |
| } |
| } |
| return rv |
| } |
| |
| // respBodyCloser is a wrapper which lets us pretend to implement io.ReadCloser |
| // by wrapping a bytes.Reader. |
| type respBodyCloser struct { |
| io.Reader |
| } |
| |
| // Close is a stub method which lets us pretend to implement io.ReadCloser. |
| func (r respBodyCloser) Close() error { |
| return nil |
| } |
| |
| // NewURLMock returns an empty URLMock instance. |
| func NewURLMock() *URLMock { |
| return &URLMock{ |
| mockAlways: map[string]MockDialogue{}, |
| mockOnce: map[string][]MockDialogue{}, |
| } |
| } |
| |
| // New returns a new mocked HTTPClient. |
| func New(urlMap map[string]MockDialogue) *http.Client { |
| m := NewURLMock() |
| for k, v := range urlMap { |
| m.Mock(k, v) |
| } |
| return m.Client() |
| } |