From 346a4793219a0f83dd2e640b448a4b77561f9bf7 Mon Sep 17 00:00:00 2001 From: Evgeniy Scherbina Date: Thu, 2 Nov 2023 09:19:07 -0400 Subject: [PATCH] Cache response headers along with json-rpc response (#54) --- .env | 2 ++ config/config.go | 10 ++++++ main_test.go | 37 +++++++++++++++---- service/cachemdw/cache.go | 49 +++++++++++++++++++++----- service/cachemdw/cache_test.go | 5 +-- service/cachemdw/caching_middleware.go | 18 ++++++++++ service/cachemdw/middleware_test.go | 2 ++ service/middleware.go | 8 +++-- service/service.go | 1 + 9 files changed, 114 insertions(+), 18 deletions(-) diff --git a/.env b/.env index 20b9c55..88ac42b 100644 --- a/.env +++ b/.env @@ -131,6 +131,8 @@ CACHE_INDEFINITELY=false # Possible values are testnet, mainnet, etc... # CACHE_PREFIX must not contain colon symbol CACHE_PREFIX=local-chain +# WHITELISTED_HEADERS contains comma-separated list of headers which has to be cached along with EVM JSON-RPC response +WHITELISTED_HEADERS=access-control-expose-headers,server,vary ##### Database Config POSTGRES_PASSWORD=password diff --git a/config/config.go b/config/config.go index 385eccd..57ea0c9 100644 --- a/config/config.go +++ b/config/config.go @@ -50,6 +50,7 @@ type Config struct { RedisPassword string CacheTTL time.Duration CachePrefix string + WhitelistedHeaders []string } const ( @@ -109,6 +110,7 @@ const ( CACHE_INDEFINITELY_KEY = "CACHE_INDEFINITELY" CACHE_TTL_ENVIRONMENT_KEY = "CACHE_TTL_SECONDS" CACHE_PREFIX_ENVIRONMENT_KEY = "CACHE_PREFIX" + WHITELISTED_HEADERS_ENVIRONMENT_KEY = "WHITELISTED_HEADERS" ) var ErrEmptyHostMap = errors.New("backend host url map is empty") @@ -214,6 +216,13 @@ func ReadConfig() Config { parsedProxyBackendHostURLMap, _ := ParseRawProxyBackendHostURLMap(rawProxyBackendHostURLMap) parsedProxyPruningBackendHostURLMap, _ := ParseRawProxyBackendHostURLMap(rawProxyPruningBackendHostURLMap) + whitelistedHeaders := os.Getenv(WHITELISTED_HEADERS_ENVIRONMENT_KEY) + parsedWhitelistedHeaders := strings.Split(whitelistedHeaders, ",") + // strings.Split("", sep) returns []string{""} (slice with one empty string) which can be unexpected, so override it with more reasonable behaviour + if whitelistedHeaders == "" { + parsedWhitelistedHeaders = []string{} + } + return Config{ ProxyServicePort: os.Getenv(PROXY_SERVICE_PORT_ENVIRONMENT_KEY), LogLevel: EnvOrDefault(LOG_LEVEL_ENVIRONMENT_KEY, DEFAULT_LOG_LEVEL), @@ -252,5 +261,6 @@ func ReadConfig() Config { CacheTTL: time.Duration(EnvOrDefaultInt(CACHE_TTL_ENVIRONMENT_KEY, 0)) * time.Second, CacheIndefinitely: EnvOrDefaultBool(CACHE_INDEFINITELY_KEY, false), CachePrefix: os.Getenv(CACHE_PREFIX_ENVIRONMENT_KEY), + WhitelistedHeaders: parsedWhitelistedHeaders, } } diff --git a/main_test.go b/main_test.go index 6422fcc..74e8a6c 100644 --- a/main_test.go +++ b/main_test.go @@ -480,9 +480,9 @@ func TestE2ETestCachingMdwWithBlockNumberParam(t *testing.T) { // check that cached and non-cached responses are equal // eth_getBlockByNumber - cache MISS - resp1 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) - require.Equal(t, cachemdw.CacheMissHeaderValue, resp1.Header[cachemdw.CacheHeaderKey][0]) - body1, err := io.ReadAll(resp1.Body) + cacheMissResp := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheMissHeaderValue, cacheMissResp.Header[cachemdw.CacheHeaderKey][0]) + body1, err := io.ReadAll(cacheMissResp.Body) require.NoError(t, err) err = checkJsonRpcErr(body1) require.NoError(t, err) @@ -491,16 +491,20 @@ func TestE2ETestCachingMdwWithBlockNumberParam(t *testing.T) { containsKey(t, redisClient, expectedKey) // eth_getBlockByNumber - cache HIT - resp2 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) - require.Equal(t, cachemdw.CacheHitHeaderValue, resp2.Header[cachemdw.CacheHeaderKey][0]) - body2, err := io.ReadAll(resp2.Body) + cacheHitResp := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheHitHeaderValue, cacheHitResp.Header[cachemdw.CacheHeaderKey][0]) + body2, err := io.ReadAll(cacheHitResp.Body) require.NoError(t, err) err = checkJsonRpcErr(body2) require.NoError(t, err) expectKeysNum(t, redisClient, tc.keysNum) containsKey(t, redisClient, expectedKey) + // check that response bodies are the same require.JSONEq(t, string(body1), string(body2), "blocks should be the same") + + // check that response headers are the same + equalHeaders(t, cacheMissResp.Header, cacheHitResp.Header) }) } @@ -526,6 +530,27 @@ func TestE2ETestCachingMdwWithBlockNumberParam(t *testing.T) { cleanUpRedis(t, redisClient) } +// equalHeaders checks that headers of headersMap1 and headersMap2 are equal +// NOTE: it completely ignores presence/absence of cachemdw.CacheHeaderKey, +// it's done in that way to allow comparison of headers for cache miss and cache hit cases +func equalHeaders(t *testing.T, headersMap1, headersMap2 http.Header) { + containsHeaders(t, headersMap1, headersMap2) + containsHeaders(t, headersMap2, headersMap1) +} + +// containsHeaders checks that headersMap1 contains all headers from headersMap2 and that values for headers are the same +// NOTE: it completely ignores presence/absence of cachemdw.CacheHeaderKey, +// it's done in that way to allow comparison of headers for cache miss and cache hit cases +func containsHeaders(t *testing.T, headersMap1, headersMap2 http.Header) { + for name, value := range headersMap1 { + if name == cachemdw.CacheHeaderKey { + continue + } + + require.Equal(t, value, headersMap2[name]) + } +} + func TestE2ETestCachingMdwWithBlockNumberParam_Metrics(t *testing.T) { client, err := ethclient.Dial(proxyServiceURL) require.NoError(t, err) diff --git a/service/cachemdw/cache.go b/service/cachemdw/cache.go index a2202fa..6023116 100644 --- a/service/cachemdw/cache.go +++ b/service/cachemdw/cache.go @@ -24,8 +24,9 @@ type ServiceCache struct { cacheIndefinitely bool decodedRequestContextKey any // cachePrefix is used as prefix for any key in the cache - cachePrefix string - cacheEnabled bool + cachePrefix string + cacheEnabled bool + whitelistedHeaders []string *logging.ServiceLogger } @@ -38,6 +39,7 @@ func NewServiceCache( decodedRequestContextKey any, cachePrefix string, cacheEnabled bool, + whitelistedHeaders []string, logger *logging.ServiceLogger, ) *ServiceCache { return &ServiceCache{ @@ -48,10 +50,19 @@ func NewServiceCache( decodedRequestContextKey: decodedRequestContextKey, cachePrefix: cachePrefix, cacheEnabled: cacheEnabled, + whitelistedHeaders: whitelistedHeaders, ServiceLogger: logger, } } +// QueryResponse represents the structure which stored in the cache for every cacheable request +type QueryResponse struct { + // JsonRpcResponseResult is an EVM JSON-RPC response's result + JsonRpcResponseResult []byte `json:"json_rpc_response_result"` + // HeaderMap is a map of HTTP headers which is cached along with the EVM JSON-RPC response + HeaderMap map[string]string `json:"header_map"` +} + // IsCacheable checks if EVM request is cacheable. // In current implementation we consider request is cacheable if it has specific block height func IsCacheable( @@ -89,7 +100,7 @@ func IsCacheable( func (c *ServiceCache) GetCachedQueryResponse( ctx context.Context, req *decode.EVMRPCRequestEnvelope, -) ([]byte, error) { +) (*QueryResponse, error) { // if request isn't cacheable - there is no point to try to get it from cache so exit early with an error cacheable := IsCacheable(c.ServiceLogger, req) if !cacheable { @@ -101,25 +112,36 @@ func (c *ServiceCache) GetCachedQueryResponse( return nil, err } - // get JSON-RPC response's result from the cache - result, err := c.cacheClient.Get(ctx, key) + // get Query Response from the cache + queryResponseInJSON, err := c.cacheClient.Get(ctx, key) if err != nil { return nil, err } + // Query Response consists of JSON-RPC response's result and headers map. + // Unmarshal it and later update JSON-RPC response's result to match JSON-RPC request. + var queryResponse QueryResponse + if err := json.Unmarshal(queryResponseInJSON, &queryResponse); err != nil { + return nil, err + } + // JSON-RPC response's ID and Version should match JSON-RPC request id := strconv.Itoa(int(req.ID)) response := JsonRpcResponse{ Version: req.JSONRPCVersion, ID: []byte(id), - Result: result, + Result: queryResponse.JsonRpcResponseResult, } responseInJSON, err := json.Marshal(response) if err != nil { return nil, err } + responseInJSON = append(responseInJSON, '\n') + + // update JSON-RPC response's result before returning Query Response + queryResponse.JsonRpcResponseResult = responseInJSON - return responseInJSON, nil + return &queryResponse, nil } // CacheQueryResponse calculates cache key for request and then saves response to the cache. @@ -130,6 +152,7 @@ func (c *ServiceCache) CacheQueryResponse( ctx context.Context, req *decode.EVMRPCRequestEnvelope, responseInBytes []byte, + headerMap map[string]string, ) error { // don't cache uncacheable requests if !IsCacheable(c.ServiceLogger, req) { @@ -150,7 +173,17 @@ func (c *ServiceCache) CacheQueryResponse( return err } - return c.cacheClient.Set(ctx, key, response.Result, c.cacheTTL, c.cacheIndefinitely) + // cache JSON-RPC response's result and HTTP Header Map + queryResponse := &QueryResponse{ + JsonRpcResponseResult: response.Result, + HeaderMap: headerMap, + } + queryResponseInJSON, err := json.Marshal(queryResponse) + if err != nil { + return err + } + + return c.cacheClient.Set(ctx, key, queryResponseInJSON, c.cacheTTL, c.cacheIndefinitely) } func (c *ServiceCache) Healthcheck(ctx context.Context) error { diff --git a/service/cachemdw/cache_test.go b/service/cachemdw/cache_test.go index ae6e328..aae0807 100644 --- a/service/cachemdw/cache_test.go +++ b/service/cachemdw/cache_test.go @@ -82,6 +82,7 @@ func TestUnitTestCacheQueryResponse(t *testing.T) { service.DecodedRequestContextKey, defaultCachePrefixString, true, + []string{}, &logger, ) @@ -90,12 +91,12 @@ func TestUnitTestCacheQueryResponse(t *testing.T) { require.Equal(t, cache.ErrNotFound, err) require.Empty(t, resp) - err = serviceCache.CacheQueryResponse(ctxb, req, defaultQueryResp) + err = serviceCache.CacheQueryResponse(ctxb, req, defaultQueryResp, map[string]string{}) require.NoError(t, err) resp, err = serviceCache.GetCachedQueryResponse(ctxb, req) require.NoError(t, err) - require.JSONEq(t, string(defaultQueryResp), string(resp)) + require.JSONEq(t, string(defaultQueryResp), string(resp.JsonRpcResponseResult)) } func mkEVMRPCRequestEnvelope(blockNumber string) *decode.EVMRPCRequestEnvelope { diff --git a/service/cachemdw/caching_middleware.go b/service/cachemdw/caching_middleware.go index 63f237c..177bb6b 100644 --- a/service/cachemdw/caching_middleware.go +++ b/service/cachemdw/caching_middleware.go @@ -52,10 +52,12 @@ func (c *ServiceCache) CachingMiddleware( // if request isn't already cached, request is cacheable and response is present in context - cache the response if !isCached && cacheable && ok { + headersToCache := getHeadersToCache(w, c.whitelistedHeaders) if err := c.CacheQueryResponse( r.Context(), decodedReq, typedResponse, + headersToCache, ); err != nil { c.Logger.Error().Msgf("can't validate and cache response: %v", err) } @@ -64,3 +66,19 @@ func (c *ServiceCache) CachingMiddleware( next.ServeHTTP(w, r) } } + +// getHeadersToCache gets header map which has to be cached along with EVM JSON-RPC response +func getHeadersToCache(w http.ResponseWriter, whitelistedHeaders []string) map[string]string { + headersToCache := make(map[string]string, 0) + + for _, headerName := range whitelistedHeaders { + headerValue := w.Header().Get(headerName) + if headerValue == "" { + continue + } + + headersToCache[headerName] = headerValue + } + + return headersToCache +} diff --git a/service/cachemdw/middleware_test.go b/service/cachemdw/middleware_test.go index 10eaf07..572c461 100644 --- a/service/cachemdw/middleware_test.go +++ b/service/cachemdw/middleware_test.go @@ -33,6 +33,7 @@ func TestUnitTestServiceCacheMiddleware(t *testing.T) { service.DecodedRequestContextKey, defaultCachePrefixString, true, + []string{}, &logger, ) @@ -118,6 +119,7 @@ func TestUnitTestServiceCacheMiddleware_CacheIsDisabled(t *testing.T) { service.DecodedRequestContextKey, defaultCachePrefixString, false, + []string{}, &logger, ) diff --git a/service/middleware.go b/service/middleware.go index a91d364..e24a496 100644 --- a/service/middleware.go +++ b/service/middleware.go @@ -236,7 +236,7 @@ func createProxyRequestMiddleware(next http.Handler, config config.Config, servi isCached := cachemdw.IsRequestCached(r.Context()) cachedResponse := r.Context().Value(cachemdw.ResponseContextKey) - typedCachedResponse, ok := cachedResponse.([]byte) + typedCachedResponse, ok := cachedResponse.(*cachemdw.QueryResponse) // if cache is enabled, request is cached and response is present in context - serve the request from the cache // otherwise proxy to the actual backend @@ -250,7 +250,11 @@ func createProxyRequestMiddleware(next http.Handler, config config.Config, servi w.Header().Add(cachemdw.CacheHeaderKey, cachemdw.CacheHitHeaderValue) w.Header().Add("Content-Type", "application/json") - _, err := w.Write(typedCachedResponse) + // add cached headers + for headerName, headerValue := range typedCachedResponse.HeaderMap { + w.Header().Add(headerName, headerValue) + } + _, err := w.Write(typedCachedResponse.JsonRpcResponseResult) if err != nil { serviceLogger.Logger.Error().Msg(fmt.Sprintf("can't write cached response: %v", err)) } diff --git a/service/service.go b/service/service.go index 6a0bff0..0e280c5 100644 --- a/service/service.go +++ b/service/service.go @@ -215,6 +215,7 @@ func createServiceCache( DecodedRequestContextKey, config.CachePrefix, config.CacheEnabled, + config.WhitelistedHeaders, logger, )