From b1407ecdcf188b125a54148e0af2d4c0d935b53d Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Oct 2023 12:13:18 -0400 Subject: [PATCH] Add response parser --- service/cachemdw/response.go | 99 +++++++++++++++++++++++++++ service/cachemdw/response_test.go | 109 ++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 service/cachemdw/response.go create mode 100644 service/cachemdw/response_test.go diff --git a/service/cachemdw/response.go b/service/cachemdw/response.go new file mode 100644 index 0000000..d4f9dcd --- /dev/null +++ b/service/cachemdw/response.go @@ -0,0 +1,99 @@ +package cachemdw + +import ( + "encoding/json" + "errors" + "fmt" +) + +type JsonRpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// String returns the string representation of the error +func (e *JsonRpcError) String() string { + return fmt.Sprintf("%s (code: %d)", e.Message, e.Code) +} + +// JsonRpcResponse is a EVM JSON-RPC response +type JsonRpcResponse struct { + Version string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + JsonRpcError *JsonRpcError `json:"error,omitempty"` +} + +// UnmarshalJsonRpcResponse unmarshals a JSON-RPC response +func UnmarshalJsonRpcResponse(data []byte) (*JsonRpcResponse, error) { + var msg JsonRpcResponse + err := json.Unmarshal(data, &msg) + return &msg, err +} + +// Marshal marshals a JSON-RPC response to JSON +func (resp *JsonRpcResponse) Marshal() ([]byte, error) { + return json.Marshal(resp) +} + +// Error returns the json-rpc error if any +func (resp *JsonRpcResponse) Error() error { + if resp.JsonRpcError == nil { + return nil + } + + return errors.New(resp.JsonRpcError.String()) +} + +// IsResultEmpty checks if the response's result is empty +func (resp *JsonRpcResponse) IsResultEmpty() bool { + if len(resp.Result) == 0 { + // empty response's result + return true + } + + var result interface{} + err := json.Unmarshal(resp.Result, &result) + if err != nil { + // consider result as empty if it's malformed + return true + } + + switch r := result.(type) { + case []interface{}: + // consider result as empty if it's empty slice + return len(r) == 0 + case string: + // Matches: + // - "" - Empty string + // - "0x0" - Represents zero in official json-rpc conventions. See: + // https://ethereum.org/en/developers/docs/apis/json-rpc/#conventions + // + // - "0x" - Empty response from some endpoints like getCode + + return r == "" || r == "0x0" || r == "0x" + case bool: + // consider result as empty if it's false + return !r + case nil: + // consider result as empty if it's null + return true + default: + return false + } +} + +// IsCacheable returns true in case of: +// - json-rpc response doesn't contain an error +// - json-rpc response's result isn't empty +func (resp *JsonRpcResponse) IsCacheable() bool { + if err := resp.Error(); err != nil { + return false + } + + if resp.IsResultEmpty() { + return false + } + + return true +} diff --git a/service/cachemdw/response_test.go b/service/cachemdw/response_test.go new file mode 100644 index 0000000..63d1b43 --- /dev/null +++ b/service/cachemdw/response_test.go @@ -0,0 +1,109 @@ +package cachemdw_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kava-labs/kava-proxy-service/service/cachemdw" +) + +func TestUnitTestJsonRpcResponse_IsEmpty(t *testing.T) { + toJSON := func(t *testing.T, result any) []byte { + resultInJSON, err := json.Marshal(result) + require.NoError(t, err) + + return resultInJSON + } + + mkResp := func(result []byte) *cachemdw.JsonRpcResponse { + return &cachemdw.JsonRpcResponse{ + Version: "2.0", + ID: []byte("1"), + Result: result, + } + } + + tests := []struct { + name string + resp *cachemdw.JsonRpcResponse + isEmpty bool + }{ + { + name: "empty result", + resp: mkResp([]byte("")), + isEmpty: true, + }, + { + name: "invalid json", + resp: mkResp([]byte("invalid json")), + isEmpty: true, + }, + { + name: "empty slice", + resp: mkResp(toJSON(t, []interface{}{})), + isEmpty: true, + }, + { + name: "empty string", + resp: mkResp(toJSON(t, "")), + isEmpty: true, + }, + { + name: "0x0 string", + resp: mkResp(toJSON(t, "0x0")), + isEmpty: true, + }, + { + name: "0x string", + resp: mkResp(toJSON(t, "0x")), + isEmpty: true, + }, + { + name: "empty bool", + resp: mkResp(toJSON(t, false)), + isEmpty: true, + }, + { + name: "nil", + resp: mkResp(nil), + isEmpty: true, + }, + { + name: "null", + resp: mkResp(toJSON(t, nil)), + isEmpty: true, + }, + { + name: "non-empty slice", + resp: mkResp(toJSON(t, []interface{}{1})), + isEmpty: false, + }, + { + name: "non-empty string", + resp: mkResp(toJSON(t, "0x1234")), + isEmpty: false, + }, + { + name: "non-empty bool", + resp: mkResp(toJSON(t, true)), + isEmpty: false, + }, + { + name: "unsupported empty object", + resp: mkResp(toJSON(t, map[string]interface{}{})), + isEmpty: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal( + t, + tc.isEmpty, + tc.resp.IsResultEmpty(), + ) + }) + } +}