Files
gocryptotrader/exchanges/mock/recording.go
Samuael A. fc0f262c42 exchanges: Limit mock test JSON data size by truncating slices and maps (#1968)
* set limiter to first level mock data list and updated unit tests

* address nested slices length limit

* minor fix recording file and update unit tests

* minor updates on unit tests

* re-record mock files and minor fix on the unit tests ti adapt the mock data change

* improve http recording limit value and fix issues with mock data in binance

* added MockDataSliceLimit in request items and resolve minor unit test issues

* resolve missed conflict

* rename mock variables, resolve unit test issues, and other updates

* minor fix to CheckJSON and update unit tests

* minor unit test fix

* further optimization on mock CheckJSON method, unit tests, and re-record poloniex

* common and recording unit tests fix

* minor linter issues fix

* unit tests format fix

* fix miscellaneous error

* unit tests fix and minor docs update

* re-record and reduce mock file size

* indentation fix

* minor assertion test fix

* reverted log.Printf line in live testing

* rename variables

* update NewVCRServer unit test

* replace string comparison with *net.OpError check

* restructur net error test

* exchanges/mock: Remove redundant error assertion message in TestNewVCRServer

---------

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>
2025-08-26 10:27:07 +10:00

457 lines
11 KiB
Go

package mock
import (
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"sync"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/file"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
)
// defaultDataSliceLimit the mock slice data size limit to a default of 5
const defaultDataSliceLimit = 5
// 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
// mockDataSliceLimit defaults to 5
func HTTPRecord(res *http.Response, service string, respContents []byte, mockDataSliceLimit int) 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)
if mockDataSliceLimit == 0 {
mockDataSliceLimit = defaultDataSliceLimit
}
outputFilePath := filepath.Join(DefaultDirectory, service, service+".json")
_, err := os.Stat(outputFilePath)
if err != nil {
if os.IsExist(err) {
return err
}
// check alternative path to add compatibility with /internal/testing/exchange/exchange.go MockHTTPInstance
outputFilePath = filepath.Join("..", service, "testdata", "http.json")
_, err = os.Stat(outputFilePath)
if err != nil {
return err
}
}
contents, err := os.ReadFile(outputFilePath)
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)
}
items, err := getExcludedItems()
if err != nil {
return err
}
var httpResponse HTTPResponse
cleanedContents, err := CheckResponsePayload(respContents, items, mockDataSliceLimit)
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 := io.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 = GetFilteredURLVals(vals, items)
case textPlain:
payload := res.Request.Header.Get("X-Gemini-Payload")
j, dErr := base64.StdEncoding.DecodeString(payload)
if dErr != nil {
return dErr
}
httpResponse.BodyParams = string(j)
default:
httpResponse.BodyParams = body
}
httpResponse.Headers = GetFilteredHeader(res, items)
httpResponse.QueryString = GetFilteredURLVals(res.Request.URL.Query(), items)
_, 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 = slices.Delete(mockResponses, i, i+1)
break
}
}
case http.MethodPost:
for i := range mockResponses {
cType, ok := mockResponses[i].Headers[contentType]
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 {
return urlErr
}
if MatchURLVals(respQueryVals, mockRespVals) {
// if found will delete instance and overwrite with new
// data
mockResponses = slices.Delete(mockResponses, i, 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 = slices.Delete(mockResponses, i, i+1)
found = true
}
case "":
if !ok {
// Assume query params are used
mockQuery, urlErr := url.ParseQuery(mockResponses[i].QueryString)
if urlErr != nil {
return urlErr
}
if MatchURLVals(mockQuery, res.Request.URL.Query()) {
// if found will delete instance and overwrite with new data
mockResponses = slices.Delete(mockResponses, i, i+1)
found = true
}
break
}
fallthrough
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 file.Write(outputFilePath, payload)
}
// GetFilteredHeader filters excluded http headers for insertion into a mock
// test file
func GetFilteredHeader(res *http.Response, items Exclusion) http.Header {
for i := range items.Headers {
if res.Request.Header.Get(items.Headers[i]) != "" {
res.Request.Header.Set(items.Headers[i], "")
}
}
return res.Request.Header
}
// GetFilteredURLVals filters excluded url value variables for insertion into a
// mock test file
func GetFilteredURLVals(vals url.Values, items Exclusion) string {
for key := range vals {
for i := range items.Variables {
if strings.EqualFold(items.Variables[i], key) {
vals.Set(key, "")
}
}
}
return vals.Encode()
}
// CheckResponsePayload checks to see if there are any response body variables
// that should not be there.
func CheckResponsePayload(data []byte, items Exclusion, mockDataSliceLimit int) ([]byte, error) {
var intermediary any
if err := json.Unmarshal(data, &intermediary); err != nil {
return nil, err
}
payload, err := CheckJSON(intermediary, &items, mockDataSliceLimit)
if err != nil {
return nil, err
}
return json.MarshalIndent(payload, "", " ")
}
// Reflection consts
const (
Float64 = "float64"
Slice = "slice"
String = "string"
Bool = "bool"
Invalid = "invalid"
)
// CheckJSON recursively parses json data to retract keywords, quite intensive.
func CheckJSON(data any, excluded *Exclusion, limit int) (any, error) {
if value, ok := data.([]any); ok {
var sData []any
for i := range value {
switch subvalue := value[i].(type) {
case []any, map[string]any:
checkedData, err := CheckJSON(subvalue, excluded, limit)
if err != nil {
return nil, err
}
sData = append(sData, checkedData)
if limit > 0 && len(sData) >= limit {
return sData, nil
}
default:
// Primitive value doesn't need exclusions applied, e.g. float64 or string
sData = append(sData, subvalue)
}
}
return sData, nil
}
conv, err := json.Marshal(data)
if err != nil {
return nil, err
}
var contextValue map[string]any
err = json.Unmarshal(conv, &contextValue)
if err != nil {
return nil, err
}
if len(contextValue) == 0 {
// Nil for some reason, should error out before in json.Unmarshal
return contextValue, nil
}
for key, val := range contextValue {
switch reflect.ValueOf(val).Kind().String() {
case String:
if IsExcluded(key, excluded.Variables) {
contextValue[key] = "" // Zero val string
}
case Float64:
if IsExcluded(key, excluded.Variables) {
contextValue[key] = 0.0 // Zero val float
}
case Slice:
slice, ok := val.([]any)
if !ok {
return nil, common.GetTypeAssertError("[]any", val)
}
switch {
case len(slice) == 0:
// Empty slice found
contextValue[key] = slice
case IsExcluded(key, excluded.Variables):
contextValue[key] = nil // Zero val slice
default:
contextValue[key], err = CheckJSON(slice, excluded, limit)
if err != nil {
return nil, err
}
}
case Bool, Invalid: // Skip these bad boys for now
default:
// Recursively check map data
contextValue[key], err = CheckJSON(val, excluded, limit)
if err != nil {
return nil, err
}
}
}
return contextValue, 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
m sync.Mutex
set bool
exclusionFile = DefaultDirectory + "exclusion.json"
)
var defaultExcludedHeaders = []string{
"Key",
"X-Mbx-Apikey",
"Rest-Key",
"Apiauth-Key",
"X-Bapi-Api-Key",
}
var defaultExcludedVariables = []string{
"bsb",
"user",
"name",
"real_name",
"receiver_name",
"account_number",
"username",
"apiKey",
}
// 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 {
f, err := os.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 = os.WriteFile(exclusionFile, data, os.ModePerm)
if mErr != nil {
return excludedList, mErr
}
} else {
err = json.Unmarshal(f, &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
}