Files
gocryptotrader/exchanges/mock/recording.go
Ryan O'Hara-Reid 6d8ba0a96a Initial implementation of HTTP mock testing framework (#310)
* Initial implementation of HTTP mock testing framework

Convert to VCR testing server. Segregate live testing via build tags.

Converted Binance to VCR server

Convert Bitstamp to VCR mocking tests

Added VCR mock testing for localbitcoins

* Add server generation for concurrent testing

* Fix linter issues

* Fix linter issue

* fix race - potentially

* revert auto assigning of host vals

* Fix requested changes

* Adds mock testing for ANX
Switch to using TestMain functionality
Added cron job usage for travis-ci to live testing
Added appveyor scheduled build check for live testing

* WOOPS

* silly correction

* Fixes fantastic linter issues

* fixed another whoopsie

* WOOO!

* Adds gemini mock testing with additional fixes

* Add docs and sharedvalue

* Added tls using httptest package

* Fixed issues

* added explicit mock recording reference to error

* Fix requested changes

* strip port from mock files as they are not needed on tls server

* Change incorrect names

* fix requested changes

* lbank update

* Fix another issue

* Updated readme
2019-08-23 15:20:02 +10:00

440 lines
10 KiB
Go

package mock
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"github.com/thrasher-corp/gocryptotrader/common"
)
// HTTPResponse defines expected response from the end point including request
// data for pathing on the VCR server
type HTTPResponse struct {
Data json.RawMessage `json:"data"`
QueryString string `json:"queryString"`
BodyParams string `json:"bodyParams"`
Headers map[string][]string `json:"headers"`
}
// HTTPRecord will record the request and response to a default JSON file for
// mocking purposes
func HTTPRecord(res *http.Response, service string, respContents []byte) error {
if res == nil {
return errors.New("http.Response cannot be nil")
}
if res.Request == nil {
return errors.New("http.Request cannot be nil")
}
if res.Request.Method == "" {
return errors.New("request method not supplied")
}
if service == "" {
return errors.New("service not supplied cannot access correct mock file")
}
service = strings.ToLower(service)
fileout := filepath.Join(DefaultDirectory, service, service+".json")
contents, err := common.ReadFile(fileout)
if err != nil {
return err
}
var m VCRMock
err = json.Unmarshal(contents, &m)
if err != nil {
return err
}
if m.Routes == nil {
m.Routes = make(map[string]map[string][]HTTPResponse)
}
var httpResponse HTTPResponse
cleanedContents, err := CheckResponsePayload(respContents)
if err != nil {
return err
}
err = json.Unmarshal(cleanedContents, &httpResponse.Data)
if err != nil {
return err
}
var body string
if res.Request.GetBody != nil {
bodycopy, bodyErr := res.Request.GetBody()
if bodyErr != nil {
return bodyErr
}
payload, bodyErr := ioutil.ReadAll(bodycopy)
if bodyErr != nil {
return bodyErr
}
body = string(payload)
}
switch res.Request.Header.Get(contentType) {
case applicationURLEncoded:
vals, urlErr := url.ParseQuery(body)
if urlErr != nil {
return urlErr
}
httpResponse.BodyParams, urlErr = GetFilteredURLVals(vals)
if urlErr != nil {
return urlErr
}
case textPlain:
payload := res.Request.Header.Get("X-Gemini-Payload")
j, dErr := common.Base64Decode(payload)
if dErr != nil {
return dErr
}
httpResponse.BodyParams = string(j)
default:
httpResponse.BodyParams = body
}
httpResponse.Headers, err = GetFilteredHeader(res)
if err != nil {
return err
}
httpResponse.QueryString, err = GetFilteredURLVals(res.Request.URL.Query())
if err != nil {
return err
}
_, ok := m.Routes[res.Request.URL.Path]
if !ok {
m.Routes[res.Request.URL.Path] = make(map[string][]HTTPResponse)
m.Routes[res.Request.URL.Path][res.Request.Method] = []HTTPResponse{httpResponse}
} else {
mockResponses, ok := m.Routes[res.Request.URL.Path][res.Request.Method]
if !ok {
m.Routes[res.Request.URL.Path][res.Request.Method] = []HTTPResponse{httpResponse}
} else {
switch res.Request.Method { // Based off method - check add or replace
case http.MethodGet:
for i := range mockResponses {
mockQuery, urlErr := url.ParseQuery(mockResponses[i].QueryString)
if urlErr != nil {
return urlErr
}
if MatchURLVals(mockQuery, res.Request.URL.Query()) {
mockResponses = append(mockResponses[:i], mockResponses[i+1:]...) // Delete Old
break
}
}
case http.MethodPost:
for i := range mockResponses {
cType, ok := mockResponses[i].Headers[contentType]
if !ok {
return errors.New("cannot find content type within mock responses")
}
jCType := strings.Join(cType, "")
var found bool
switch jCType {
case applicationURLEncoded:
respQueryVals, urlErr := url.ParseQuery(body)
if urlErr != nil {
return urlErr
}
mockRespVals, urlErr := url.ParseQuery(mockResponses[i].BodyParams)
if urlErr != nil {
log.Fatal(urlErr)
}
if MatchURLVals(respQueryVals, mockRespVals) {
// if found will delete instance and overwrite with new
// data
mockResponses = append(mockResponses[:i], mockResponses[i+1:]...)
found = true
}
case applicationJSON, textPlain:
reqVals, jErr := DeriveURLValsFromJSONMap([]byte(body))
if jErr != nil {
return jErr
}
mockVals, jErr := DeriveURLValsFromJSONMap([]byte(mockResponses[i].BodyParams))
if jErr != nil {
return jErr
}
if MatchURLVals(reqVals, mockVals) {
// if found will delete instance and overwrite with new
// data
mockResponses = append(mockResponses[:i], mockResponses[i+1:]...)
found = true
}
default:
return fmt.Errorf("unhandled content type %s", jCType)
}
if found {
break
}
}
default:
return fmt.Errorf("unhandled request method %s", res.Request.Method)
}
m.Routes[res.Request.URL.Path][res.Request.Method] = append(mockResponses, httpResponse)
}
}
payload, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return common.WriteFile(fileout, payload)
}
// GetFilteredHeader filters excluded http headers for insertion into a mock
// test file
func GetFilteredHeader(res *http.Response) (http.Header, error) {
items, err := GetExcludedItems()
if err != nil {
return res.Header, err
}
for i := range items.Headers {
if res.Request.Header.Get(items.Headers[i]) != "" {
res.Request.Header.Set(items.Headers[i], "")
}
}
return res.Request.Header, nil
}
// GetFilteredURLVals filters excluded url value variables for insertion into a
// mock test file
func GetFilteredURLVals(vals url.Values) (string, error) {
items, err := GetExcludedItems()
if err != nil {
return "", err
}
for key, val := range vals {
for i := range items.Variables {
if strings.EqualFold(items.Variables[i], val[0]) {
vals.Set(key, "")
}
}
}
return vals.Encode(), nil
}
// CheckResponsePayload checks to see if there are any response body variables
// that should not be there.
func CheckResponsePayload(data []byte) ([]byte, error) {
items, err := GetExcludedItems()
if err != nil {
return nil, err
}
var intermediary interface{}
err = json.Unmarshal(data, &intermediary)
if err != nil {
return nil, err
}
payload, err := CheckJSON(intermediary, &items)
if err != nil {
return nil, err
}
return json.MarshalIndent(payload, "", " ")
}
// Reflection consts
const (
Int64 = "int64"
Float64 = "float64"
Slice = "slice"
String = "string"
Bool = "bool"
Invalid = "invalid"
)
// CheckJSON recursively parses json data to retract keywords, quite intensive.
func CheckJSON(data interface{}, excluded *Exclusion) (interface{}, error) {
var context map[string]interface{}
if reflect.TypeOf(data).String() == "[]interface {}" {
var sData []interface{}
for i := range data.([]interface{}) {
checkedData, err := CheckJSON(data.([]interface{})[i], excluded)
if err != nil {
return nil, err
}
sData = append(sData, checkedData)
}
return sData, nil
}
conv, err := json.Marshal(data)
if err != nil {
return nil, err
}
err = json.Unmarshal(conv, &context)
if err != nil {
return nil, err
}
if len(context) == 0 {
// Nil for some reason, should error out before in json.Unmarshal
return nil, nil
}
for key, val := range context {
switch reflect.ValueOf(val).Kind().String() {
case String:
if IsExcluded(key, excluded.Variables) {
context[key] = "" // Zero val string
}
case Int64:
if IsExcluded(key, excluded.Variables) {
context[key] = 0 // Zero val int
}
case Float64:
if IsExcluded(key, excluded.Variables) {
context[key] = 0.0 // Zero val float
}
case Slice:
slice := val.([]interface{})
if len(slice) < 1 {
// Empty slice found
context[key] = slice
} else {
if _, ok := slice[0].(map[string]interface{}); ok {
var cleanSlice []interface{}
for i := range slice {
cleanMap, sErr := CheckJSON(slice[i], excluded)
if sErr != nil {
return nil, sErr
}
cleanSlice = append(cleanSlice, cleanMap)
}
context[key] = cleanSlice
} else if IsExcluded(key, excluded.Variables) {
context[key] = nil // Zero val slice
}
}
case Bool, Invalid: // Skip these bad boys for now
default:
// Recursively check map data
contextValue, err := CheckJSON(val, excluded)
if err != nil {
return nil, err
}
context[key] = contextValue
}
}
return context, nil
}
// IsExcluded cross references the key with the excluded variables
func IsExcluded(key string, excludedVars []string) bool {
for i := range excludedVars {
if strings.EqualFold(key, excludedVars[i]) {
return true
}
}
return false
}
var excludedList Exclusion
var m sync.Mutex
var set bool
var exclusionFile = DefaultDirectory + "exclusion.json"
var defaultExcludedHeaders = []string{"Key",
"X-Mbx-Apikey",
"Rest-Key",
"Apiauth-Key"}
var defaultExcludedVariables = []string{"bsb",
"user",
"name",
"real_name",
"receiver_name",
"account_number",
"username"}
// Exclusion defines a list of items to be excluded from the main mock output
// this attempts a catch all approach and needs to be updated per exchange basis
type Exclusion struct {
Headers []string `json:"headers"`
Variables []string `json:"variables"`
}
// GetExcludedItems checks to see if the variable is in the exclusion list as to
// not display secure items in mock file generator output
func GetExcludedItems() (Exclusion, error) {
m.Lock()
defer m.Unlock()
if !set {
file, err := ioutil.ReadFile(exclusionFile)
if err != nil {
if !strings.Contains(err.Error(), "no such file or directory") {
return excludedList, err
}
excludedList.Headers = defaultExcludedHeaders
excludedList.Variables = defaultExcludedVariables
data, mErr := json.MarshalIndent(excludedList, "", " ")
if mErr != nil {
return excludedList, mErr
}
mErr = ioutil.WriteFile(exclusionFile, data, os.ModePerm)
if mErr != nil {
return excludedList, mErr
}
} else {
err = json.Unmarshal(file, &excludedList)
if err != nil {
return excludedList, err
}
if len(excludedList.Headers) == 0 || len(excludedList.Variables) == 0 {
return excludedList, errors.New("exclusion list does not have names")
}
}
set = true
}
return excludedList, nil
}