From 3c2dbe136f49af758152f14a85a39fce2ef835bd Mon Sep 17 00:00:00 2001 From: tnasu Date: Sat, 22 Jul 2023 19:19:42 +0900 Subject: [PATCH] rpc: Add caching support (backport #9650) (#9666) * rpc: Add caching support (#9650) * Set cache control in the HTTP-RPC response header * Add a simply cache policy to the RPC routes * add a condition to check the RPC request has default height settings * fix cherry pick error * update pending log * use options struct intead of single parameter * refacor FuncOptions to functional options * add functional options in WebSocket RPC function * revert doc * replace deprecated function call * revise functional options * remove unuse comment * fix revised error * adjust cache-control settings * Update rpc/jsonrpc/server/http_json_handler.go Co-authored-by: Thane Thomson * linter: Fix false positive Signed-off-by: Thane Thomson * rpc: Separate cacheable and non-cacheable HTTP response writers Allows us to roll this change out in a non-API-breaking way, since this is an additive change. Signed-off-by: Thane Thomson * rpc: Ensure consistent caching strategy Ensure a consistent caching strategy across both JSONRPC- and URI-based requests. This requires a bit of a refactor of the previous caching logic, which is complicated a little by the complex reflection-based approach taken in the Tendermint RPC. Signed-off-by: Thane Thomson * rpc: Add more tests for caching Signed-off-by: Thane Thomson * Update CHANGELOG_PENDING Signed-off-by: Thane Thomson * light: Sync routes config with RPC core Signed-off-by: Thane Thomson * rpc: Update OpenAPI docs Signed-off-by: Thane Thomson Signed-off-by: Thane Thomson Co-authored-by: jayt106 Co-authored-by: jay tseng Co-authored-by: JayT106 (cherry picked from commit 816c6bac00c63a421a1bdaeccbc081c5346cb0d8) * Fix conflict in CHANGELOG_PENDING Signed-off-by: Thane Thomson * Resolve remaining conflicts Signed-off-by: Thane Thomson Signed-off-by: Thane Thomson Co-authored-by: Thane Thomson --- light/proxy/routes.go | 22 ++-- rpc/core/routes.go | 24 ++-- rpc/jsonrpc/jsonrpc_test.go | 118 +++++++++++++++++-- rpc/jsonrpc/server/http_json_handler.go | 22 +++- rpc/jsonrpc/server/http_json_handler_test.go | 52 +++++++- rpc/jsonrpc/server/http_server.go | 20 +++- rpc/jsonrpc/server/http_server_test.go | 3 +- rpc/jsonrpc/server/http_uri_handler.go | 9 +- rpc/jsonrpc/server/rpc_func.go | 82 +++++++++++-- rpc/openapi/openapi.yaml | 47 ++++++-- 10 files changed, 341 insertions(+), 58 deletions(-) diff --git a/light/proxy/routes.go b/light/proxy/routes.go index e954b2c69..a1135b2d1 100644 --- a/light/proxy/routes.go +++ b/light/proxy/routes.go @@ -21,20 +21,20 @@ func RPCRoutes(c *lrpc.Client) map[string]*rpcserver.RPCFunc { "health": rpcserver.NewRPCFunc(makeHealthFunc(c), ""), "status": rpcserver.NewRPCFunc(makeStatusFunc(c), ""), "net_info": rpcserver.NewRPCFunc(makeNetInfoFunc(c), ""), - "blockchain": rpcserver.NewRPCFunc(makeBlockchainInfoFunc(c), "minHeight,maxHeight"), - "genesis": rpcserver.NewRPCFunc(makeGenesisFunc(c), ""), - "genesis_chunked": rpcserver.NewRPCFunc(makeGenesisChunkedFunc(c), ""), - "block": rpcserver.NewRPCFunc(makeBlockFunc(c), "height"), - "block_by_hash": rpcserver.NewRPCFunc(makeBlockByHashFunc(c), "hash"), - "block_results": rpcserver.NewRPCFunc(makeBlockResultsFunc(c), "height"), - "commit": rpcserver.NewRPCFunc(makeCommitFunc(c), "height"), - "tx": rpcserver.NewRPCFunc(makeTxFunc(c), "hash,prove"), + "blockchain": rpcserver.NewRPCFunc(makeBlockchainInfoFunc(c), "minHeight,maxHeight", rpcserver.Cacheable()), + "genesis": rpcserver.NewRPCFunc(makeGenesisFunc(c), "", rpcserver.Cacheable()), + "genesis_chunked": rpcserver.NewRPCFunc(makeGenesisChunkedFunc(c), "", rpcserver.Cacheable()), + "block": rpcserver.NewRPCFunc(makeBlockFunc(c), "height", rpcserver.Cacheable("height")), + "block_by_hash": rpcserver.NewRPCFunc(makeBlockByHashFunc(c), "hash", rpcserver.Cacheable()), + "block_results": rpcserver.NewRPCFunc(makeBlockResultsFunc(c), "height", rpcserver.Cacheable("height")), + "commit": rpcserver.NewRPCFunc(makeCommitFunc(c), "height", rpcserver.Cacheable("height")), + "tx": rpcserver.NewRPCFunc(makeTxFunc(c), "hash,prove", rpcserver.Cacheable()), "tx_search": rpcserver.NewRPCFunc(makeTxSearchFunc(c), "query,prove,page,per_page,order_by"), "block_search": rpcserver.NewRPCFunc(makeBlockSearchFunc(c), "query,page,per_page,order_by"), - "validators": rpcserver.NewRPCFunc(makeValidatorsFunc(c), "height,page,per_page"), + "validators": rpcserver.NewRPCFunc(makeValidatorsFunc(c), "height,page,per_page", rpcserver.Cacheable("height")), "dump_consensus_state": rpcserver.NewRPCFunc(makeDumpConsensusStateFunc(c), ""), "consensus_state": rpcserver.NewRPCFunc(makeConsensusStateFunc(c), ""), - "consensus_params": rpcserver.NewRPCFunc(makeConsensusParamsFunc(c), "height"), + "consensus_params": rpcserver.NewRPCFunc(makeConsensusParamsFunc(c), "height", rpcserver.Cacheable("height")), "unconfirmed_txs": rpcserver.NewRPCFunc(makeUnconfirmedTxsFunc(c), "limit"), "num_unconfirmed_txs": rpcserver.NewRPCFunc(makeNumUnconfirmedTxsFunc(c), ""), @@ -45,7 +45,7 @@ func RPCRoutes(c *lrpc.Client) map[string]*rpcserver.RPCFunc { // abci API "abci_query": rpcserver.NewRPCFunc(makeABCIQueryFunc(c), "path,data,height,prove"), - "abci_info": rpcserver.NewRPCFunc(makeABCIInfoFunc(c), ""), + "abci_info": rpcserver.NewRPCFunc(makeABCIInfoFunc(c), "", rpcserver.Cacheable()), // evidence API "broadcast_evidence": rpcserver.NewRPCFunc(makeBroadcastEvidenceFunc(c), "evidence"), diff --git a/rpc/core/routes.go b/rpc/core/routes.go index 1ac90e8ae..4fd4581c5 100644 --- a/rpc/core/routes.go +++ b/rpc/core/routes.go @@ -17,21 +17,21 @@ var Routes = map[string]*rpc.RPCFunc{ "health": rpc.NewRPCFunc(Health, ""), "status": rpc.NewRPCFunc(Status, ""), "net_info": rpc.NewRPCFunc(NetInfo, ""), - "blockchain": rpc.NewRPCFunc(BlockchainInfo, "minHeight,maxHeight"), - "genesis": rpc.NewRPCFunc(Genesis, ""), - "genesis_chunked": rpc.NewRPCFunc(GenesisChunked, "chunk"), - "block": rpc.NewRPCFunc(Block, "height"), - "block_by_hash": rpc.NewRPCFunc(BlockByHash, "hash"), - "block_results": rpc.NewRPCFunc(BlockResults, "height"), - "commit": rpc.NewRPCFunc(Commit, "height"), - "check_tx": rpc.NewRPCFunc(CheckTx, "tx"), - "tx": rpc.NewRPCFunc(Tx, "hash,prove"), + "blockchain": rpc.NewRPCFunc(BlockchainInfo, "minHeight,maxHeight", rpc.Cacheable()), + "genesis": rpc.NewRPCFunc(Genesis, "", rpc.Cacheable()), + "genesis_chunked": rpc.NewRPCFunc(GenesisChunked, "chunk", rpc.Cacheable()), + "block": rpc.NewRPCFunc(Block, "height", rpc.Cacheable("height")), + "block_by_hash": rpc.NewRPCFunc(BlockByHash, "hash", rpc.Cacheable()), + "block_results": rpc.NewRPCFunc(BlockResults, "height", rpc.Cacheable("height")), + "commit": rpc.NewRPCFunc(Commit, "height", rpc.Cacheable("height")), + "check_tx": rpc.NewRPCFunc(CheckTx, "tx", rpc.Cacheable()), + "tx": rpc.NewRPCFunc(Tx, "hash,prove", rpc.Cacheable()), "tx_search": rpc.NewRPCFunc(TxSearch, "query,prove,page,per_page,order_by"), "block_search": rpc.NewRPCFunc(BlockSearch, "query,page,per_page,order_by"), - "validators": rpc.NewRPCFunc(Validators, "height,page,per_page"), + "validators": rpc.NewRPCFunc(Validators, "height,page,per_page", rpc.Cacheable("height")), "dump_consensus_state": rpc.NewRPCFunc(DumpConsensusState, ""), "consensus_state": rpc.NewRPCFunc(ConsensusState, ""), - "consensus_params": rpc.NewRPCFunc(ConsensusParams, "height"), + "consensus_params": rpc.NewRPCFunc(ConsensusParams, "height", rpc.Cacheable("height")), "unconfirmed_txs": rpc.NewRPCFunc(UnconfirmedTxs, "limit"), "num_unconfirmed_txs": rpc.NewRPCFunc(NumUnconfirmedTxs, ""), @@ -42,7 +42,7 @@ var Routes = map[string]*rpc.RPCFunc{ // abci API "abci_query": rpc.NewRPCFunc(ABCIQuery, "path,data,height,prove"), - "abci_info": rpc.NewRPCFunc(ABCIInfo, ""), + "abci_info": rpc.NewRPCFunc(ABCIInfo, "", rpc.Cacheable()), // evidence API "broadcast_evidence": rpc.NewRPCFunc(BroadcastEvidence, "evidence"), diff --git a/rpc/jsonrpc/jsonrpc_test.go b/rpc/jsonrpc/jsonrpc_test.go index 720ff8350..3123149a9 100644 --- a/rpc/jsonrpc/jsonrpc_test.go +++ b/rpc/jsonrpc/jsonrpc_test.go @@ -7,8 +7,10 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "os" "os/exec" + "strings" "testing" "time" @@ -37,9 +39,7 @@ const ( testVal = "acbd" ) -var ( - ctx = context.Background() -) +var ctx = context.Background() type ResultEcho struct { Value string `json:"value"` @@ -57,6 +57,10 @@ type ResultEchoDataBytes struct { Value tmbytes.HexBytes `json:"value"` } +type ResultEchoWithDefault struct { + Value int `json:"value"` +} + // Define some routes var Routes = map[string]*server.RPCFunc{ "echo": server.NewRPCFunc(EchoResult, "arg"), @@ -64,6 +68,7 @@ var Routes = map[string]*server.RPCFunc{ "echo_bytes": server.NewRPCFunc(EchoBytesResult, "arg"), "echo_data_bytes": server.NewRPCFunc(EchoDataBytesResult, "arg"), "echo_int": server.NewRPCFunc(EchoIntResult, "arg"), + "echo_default": server.NewRPCFunc(EchoWithDefault, "arg", server.Cacheable("arg")), } func EchoResult(ctx *types.Context, v string) (*ResultEcho, error) { @@ -86,6 +91,14 @@ func EchoDataBytesResult(ctx *types.Context, v tmbytes.HexBytes) (*ResultEchoDat return &ResultEchoDataBytes{v}, nil } +func EchoWithDefault(ctx *types.Context, v *int) (*ResultEchoWithDefault, error) { + val := -1 + if v != nil { + val = *v + } + return &ResultEchoWithDefault{val}, nil +} + func TestMain(m *testing.M) { setup() code := m.Run() @@ -199,26 +212,47 @@ func echoDataBytesViaHTTP(cl client.Caller, bytes tmbytes.HexBytes) (tmbytes.Hex return result.Value, nil } +func echoWithDefaultViaHTTP(cl client.Caller, v *int) (int, error) { + params := map[string]interface{}{} + if v != nil { + params["arg"] = *v + } + result := new(ResultEchoWithDefault) + if _, err := cl.Call(ctx, "echo_default", params, result); err != nil { + return 0, err + } + return result.Value, nil +} + func testWithHTTPClient(t *testing.T, cl client.HTTPClient) { val := testVal got, err := echoViaHTTP(cl, val) - require.Nil(t, err) + require.NoError(t, err) assert.Equal(t, got, val) val2 := randBytes(t) got2, err := echoBytesViaHTTP(cl, val2) - require.Nil(t, err) + require.NoError(t, err) assert.Equal(t, got2, val2) val3 := tmbytes.HexBytes(randBytes(t)) got3, err := echoDataBytesViaHTTP(cl, val3) - require.Nil(t, err) + require.NoError(t, err) assert.Equal(t, got3, val3) val4 := tmrand.Intn(10000) got4, err := echoIntViaHTTP(cl, val4) - require.Nil(t, err) + require.NoError(t, err) assert.Equal(t, got4, val4) + + got5, err := echoWithDefaultViaHTTP(cl, nil) + require.NoError(t, err) + assert.Equal(t, got5, -1) + + val6 := tmrand.Intn(10000) + got6, err := echoWithDefaultViaHTTP(cl, &val6) + require.NoError(t, err) + assert.Equal(t, got6, val6) } func echoViaWS(cl *client.WSClient, val string) (string, error) { @@ -233,7 +267,6 @@ func echoViaWS(cl *client.WSClient, val string) (string, error) { msg := <-cl.ResponsesCh if msg.Error != nil { return "", err - } result := new(ResultEcho) err = json.Unmarshal(msg.Result, result) @@ -255,7 +288,6 @@ func echoBytesViaWS(cl *client.WSClient, bytes []byte) ([]byte, error) { msg := <-cl.ResponsesCh if msg.Error != nil { return []byte{}, msg.Error - } result := new(ResultEchoBytes) err = json.Unmarshal(msg.Result, result) @@ -399,6 +431,74 @@ func TestWSClientPingPong(t *testing.T) { time.Sleep(6 * time.Second) } +func TestJSONRPCCaching(t *testing.T) { + httpAddr := strings.Replace(tcpAddr, "tcp://", "http://", 1) + cl, err := client.DefaultHTTPClient(httpAddr) + require.NoError(t, err) + + // Not supplying the arg should result in not caching + params := make(map[string]interface{}) + req, err := types.MapToRequest(types.JSONRPCIntID(1000), "echo_default", params) + require.NoError(t, err) + + res1, err := rawJSONRPCRequest(t, cl, httpAddr, req) + defer func() { _ = res1.Body.Close() }() + require.NoError(t, err) + assert.Equal(t, "", res1.Header.Get("Cache-control")) + + // Supplying the arg should result in caching + params["arg"] = tmrand.Intn(10000) + req, err = types.MapToRequest(types.JSONRPCIntID(1001), "echo_default", params) + require.NoError(t, err) + + res2, err := rawJSONRPCRequest(t, cl, httpAddr, req) + defer func() { _ = res2.Body.Close() }() + require.NoError(t, err) + assert.Equal(t, "public, max-age=86400", res2.Header.Get("Cache-control")) +} + +func rawJSONRPCRequest(t *testing.T, cl *http.Client, url string, req interface{}) (*http.Response, error) { + reqBytes, err := json.Marshal(req) + require.NoError(t, err) + + reqBuf := bytes.NewBuffer(reqBytes) + httpReq, err := http.NewRequest(http.MethodPost, url, reqBuf) + require.NoError(t, err) + + httpReq.Header.Set("Content-type", "application/json") + + return cl.Do(httpReq) +} + +func TestURICaching(t *testing.T) { + httpAddr := strings.Replace(tcpAddr, "tcp://", "http://", 1) + cl, err := client.DefaultHTTPClient(httpAddr) + require.NoError(t, err) + + // Not supplying the arg should result in not caching + args := url.Values{} + res1, err := rawURIRequest(t, cl, httpAddr+"/echo_default", args) + defer func() { _ = res1.Body.Close() }() + require.NoError(t, err) + assert.Equal(t, "", res1.Header.Get("Cache-control")) + + // Supplying the arg should result in caching + args.Set("arg", fmt.Sprintf("%d", tmrand.Intn(10000))) + res2, err := rawURIRequest(t, cl, httpAddr+"/echo_default", args) + defer func() { _ = res2.Body.Close() }() + require.NoError(t, err) + assert.Equal(t, "public, max-age=86400", res2.Header.Get("Cache-control")) +} + +func rawURIRequest(t *testing.T, cl *http.Client, url string, args url.Values) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(args.Encode())) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return cl.Do(req) +} + func randBytes(t *testing.T) []byte { n := tmrand.Intn(10) + 2 buf := make([]byte, n) diff --git a/rpc/jsonrpc/server/http_json_handler.go b/rpc/jsonrpc/server/http_json_handler.go index 5058845bb..5c32ab6d7 100644 --- a/rpc/jsonrpc/server/http_json_handler.go +++ b/rpc/jsonrpc/server/http_json_handler.go @@ -55,6 +55,11 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger log.Logger) http.Han requests = []types.RPCRequest{request} } + // Set the default response cache to true unless + // 1. Any RPC request error. + // 2. Any RPC request doesn't allow to be cached. + // 3. Any RPC request has the height argument and the value is 0 (the default). + cache := true for _, request := range requests { request := request @@ -72,11 +77,13 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger log.Logger) http.Han responses, types.RPCInvalidRequestError(request.ID, fmt.Errorf("path %s is invalid", r.URL.Path)), ) + cache = false continue } rpcFunc, ok := funcMap[request.Method] - if !ok || rpcFunc.ws { + if !ok || (rpcFunc.ws) { responses = append(responses, types.RPCMethodNotFoundError(request.ID)) + cache = false continue } ctx := &types.Context{JSONReq: &request, HTTPReq: r} @@ -88,11 +95,16 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger log.Logger) http.Han responses, types.RPCInvalidParamsError(request.ID, fmt.Errorf("error converting json params to arguments: %w", err)), ) + cache = false continue } args = append(args, fnArgs...) } + if cache && !rpcFunc.cacheableWithArgs(args) { + cache = false + } + returns := rpcFunc.f.Call(args) result, err := unreflectResult(returns) if err != nil { @@ -103,7 +115,13 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger log.Logger) http.Han } if len(responses) > 0 { - if wErr := WriteRPCResponseHTTP(w, responses...); wErr != nil { + var wErr error + if cache { + wErr = WriteCacheableRPCResponseHTTP(w, responses...) + } else { + wErr = WriteRPCResponseHTTP(w, responses...) + } + if wErr != nil { logger.Error("failed to write responses", "res", responses, "err", wErr) } } diff --git a/rpc/jsonrpc/server/http_json_handler_test.go b/rpc/jsonrpc/server/http_json_handler_test.go index e0a65c3b5..79b9ca5a4 100644 --- a/rpc/jsonrpc/server/http_json_handler_test.go +++ b/rpc/jsonrpc/server/http_json_handler_test.go @@ -19,7 +19,8 @@ import ( func testMux() *http.ServeMux { funcMap := map[string]*RPCFunc{ - "c": NewRPCFunc(func(ctx *types.Context, s string, i int) (string, error) { return "foo", nil }, "s,i"), + "c": NewRPCFunc(func(ctx *types.Context, s string, i int) (string, error) { return "foo", nil }, "s,i"), + "block": NewRPCFunc(func(ctx *types.Context, h int) (string, error) { return "block", nil }, "height", Cacheable("height")), } mux := http.NewServeMux() buf := new(bytes.Buffer) @@ -229,6 +230,55 @@ func TestUnknownRPCPath(t *testing.T) { res.Body.Close() } +func TestRPCResponseCache(t *testing.T) { + mux := testMux() + body := strings.NewReader(`{"jsonrpc": "2.0","method":"block","id": 0, "params": ["1"]}`) + req, _ := http.NewRequest("Get", "http://localhost/", body) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + res := rec.Result() + + // Always expecting back a JSONRPCResponse + require.True(t, statusOK(res.StatusCode), "should always return 2XX") + require.Equal(t, "public, max-age=86400", res.Header.Get("Cache-control")) + + _, err := io.ReadAll(res.Body) + res.Body.Close() + require.Nil(t, err, "reading from the body should not give back an error") + + // send a request with default height. + body = strings.NewReader(`{"jsonrpc": "2.0","method":"block","id": 0, "params": ["0"]}`) + req, _ = http.NewRequest("Get", "http://localhost/", body) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + res = rec.Result() + + // Always expecting back a JSONRPCResponse + require.True(t, statusOK(res.StatusCode), "should always return 2XX") + require.Equal(t, "", res.Header.Get("Cache-control")) + + _, err = io.ReadAll(res.Body) + + res.Body.Close() + require.Nil(t, err, "reading from the body should not give back an error") + + // send a request with default height, but as empty set of parameters. + body = strings.NewReader(`{"jsonrpc": "2.0","method":"block","id": 0, "params": []}`) + req, _ = http.NewRequest("Get", "http://localhost/", body) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + res = rec.Result() + + // Always expecting back a JSONRPCResponse + require.True(t, statusOK(res.StatusCode), "should always return 2XX") + require.Equal(t, "", res.Header.Get("Cache-control")) + + _, err = io.ReadAll(res.Body) + + res.Body.Close() + require.Nil(t, err, "reading from the body should not give back an error") +} + func TestMakeJSONRPCHandler_Unmarshal_WriteRPCResponseHTTPError_error(t *testing.T) { handlerFunc := makeJSONRPCHandler(nil, log.TestingLogger()) // json.Unmarshal error diff --git a/rpc/jsonrpc/server/http_server.go b/rpc/jsonrpc/server/http_server.go index f2f97ec44..5fad8e239 100644 --- a/rpc/jsonrpc/server/http_server.go +++ b/rpc/jsonrpc/server/http_server.go @@ -122,6 +122,22 @@ func WriteRPCResponseHTTPError( // WriteRPCResponseHTTP marshals res as JSON (with indent) and writes it to w. func WriteRPCResponseHTTP(w http.ResponseWriter, res ...types.RPCResponse) error { + return writeRPCResponseHTTP(w, []httpHeader{}, res...) +} + +// WriteCacheableRPCResponseHTTP marshals res as JSON (with indent) and writes +// it to w. Adds cache-control to the response header and sets the expiry to +// one day. +func WriteCacheableRPCResponseHTTP(w http.ResponseWriter, res ...types.RPCResponse) error { + return writeRPCResponseHTTP(w, []httpHeader{{"Cache-Control", "public, max-age=86400"}}, res...) +} + +type httpHeader struct { + name string + value string +} + +func writeRPCResponseHTTP(w http.ResponseWriter, headers []httpHeader, res ...types.RPCResponse) error { var v interface{} if len(res) == 1 { v = res[0] @@ -134,6 +150,9 @@ func WriteRPCResponseHTTP(w http.ResponseWriter, res ...types.RPCResponse) error return fmt.Errorf("json marshal: %w", err) } w.Header().Set("Content-Type", "application/json") + for _, header := range headers { + w.Header().Set(header.name, header.value) + } w.WriteHeader(200) _, err = w.Write(jsonBytes) return err @@ -171,7 +190,6 @@ func RecoverAndLogHandler(handler http.Handler, logger log.Logger) http.Handler // Without this, Chrome & Firefox were retrying aborted ajax requests, // at least to my localhost. if e := recover(); e != nil { - // If RPCResponse if res, ok := e.(types.RPCResponse); ok { if wErr := WriteRPCResponseHTTP(rww, res); wErr != nil { diff --git a/rpc/jsonrpc/server/http_server_test.go b/rpc/jsonrpc/server/http_server_test.go index 1a2141b74..6204c9ff4 100644 --- a/rpc/jsonrpc/server/http_server_test.go +++ b/rpc/jsonrpc/server/http_server_test.go @@ -114,7 +114,7 @@ func TestWriteRPCResponseHTTP(t *testing.T) { // one argument w := httptest.NewRecorder() - err := WriteRPCResponseHTTP(w, types.NewRPCSuccessResponse(id, &sampleResult{"hello"})) + err := WriteCacheableRPCResponseHTTP(w, types.NewRPCSuccessResponse(id, &sampleResult{"hello"})) require.NoError(t, err) resp := w.Result() body, err := io.ReadAll(resp.Body) @@ -122,6 +122,7 @@ func TestWriteRPCResponseHTTP(t *testing.T) { require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + assert.Equal(t, "public, max-age=86400", resp.Header.Get("Cache-control")) assert.Equal(t, `{ "jsonrpc": "2.0", "id": -1, diff --git a/rpc/jsonrpc/server/http_uri_handler.go b/rpc/jsonrpc/server/http_uri_handler.go index 2d9ed5e20..6d375d79a 100644 --- a/rpc/jsonrpc/server/http_uri_handler.go +++ b/rpc/jsonrpc/server/http_uri_handler.go @@ -63,7 +63,14 @@ func makeHTTPHandler(rpcFunc *RPCFunc, logger log.Logger) func(http.ResponseWrit } return } - if err := WriteRPCResponseHTTP(w, types.NewRPCSuccessResponse(dummyID, result)); err != nil { + + resp := types.NewRPCSuccessResponse(dummyID, result) + if rpcFunc.cacheableWithArgs(args) { + err = WriteCacheableRPCResponseHTTP(w, resp) + } else { + err = WriteRPCResponseHTTP(w, resp) + } + if err != nil { logger.Error("failed to write response", "res", result, "err", err) return } diff --git a/rpc/jsonrpc/server/rpc_func.go b/rpc/jsonrpc/server/rpc_func.go index 8f76f62f8..3510d861c 100644 --- a/rpc/jsonrpc/server/rpc_func.go +++ b/rpc/jsonrpc/server/rpc_func.go @@ -23,40 +23,96 @@ func RegisterRPCFuncs(mux *http.ServeMux, funcMap map[string]*RPCFunc, logger lo mux.HandleFunc("/", handleInvalidJSONRPCPaths(makeJSONRPCHandler(funcMap, logger))) } -// Function introspection +type Option func(*RPCFunc) + +// Cacheable enables returning a cache control header from RPC functions to +// which it is applied. +// +// `noCacheDefArgs` is a list of argument names that, if omitted or set to +// their defaults when calling the RPC function, will skip the response +// caching. +func Cacheable(noCacheDefArgs ...string) Option { + return func(r *RPCFunc) { + r.cacheable = true + r.noCacheDefArgs = make(map[string]interface{}) + for _, arg := range noCacheDefArgs { + r.noCacheDefArgs[arg] = nil + } + } +} + +// Ws enables WebSocket communication. +func Ws() Option { + return func(r *RPCFunc) { + r.ws = true + } +} // RPCFunc contains the introspected type information for a function type RPCFunc struct { - f reflect.Value // underlying rpc function - args []reflect.Type // type of each function arg - returns []reflect.Type // type of each return arg - argNames []string // name of each argument - ws bool // websocket only + f reflect.Value // underlying rpc function + args []reflect.Type // type of each function arg + returns []reflect.Type // type of each return arg + argNames []string // name of each argument + cacheable bool // enable cache control + ws bool // enable websocket communication + noCacheDefArgs map[string]interface{} // a lookup table of args that, if not supplied or are set to default values, cause us to not cache } // NewRPCFunc wraps a function for introspection. // f is the function, args are comma separated argument names -func NewRPCFunc(f interface{}, args string) *RPCFunc { - return newRPCFunc(f, args, false) +func NewRPCFunc(f interface{}, args string, options ...Option) *RPCFunc { + return newRPCFunc(f, args, options...) } // NewWSRPCFunc wraps a function for introspection and use in the websockets. -func NewWSRPCFunc(f interface{}, args string) *RPCFunc { - return newRPCFunc(f, args, true) +func NewWSRPCFunc(f interface{}, args string, options ...Option) *RPCFunc { + options = append(options, Ws()) + return newRPCFunc(f, args, options...) +} + +// cacheableWithArgs returns whether or not a call to this function is cacheable, +// given the specified arguments. +func (f *RPCFunc) cacheableWithArgs(args []reflect.Value) bool { + if !f.cacheable { + return false + } + // Skip the context variable common to all RPC functions + for i := 1; i < len(f.args); i++ { + // f.argNames does not include the context variable + argName := f.argNames[i-1] + if _, hasDefault := f.noCacheDefArgs[argName]; hasDefault { + // Argument with default value was not supplied + if i >= len(args) { + return false + } + // Argument with default value is set to its zero value + if args[i].IsZero() { + return false + } + } + } + return true } -func newRPCFunc(f interface{}, args string, ws bool) *RPCFunc { +func newRPCFunc(f interface{}, args string, options ...Option) *RPCFunc { var argNames []string if args != "" { argNames = strings.Split(args, ",") } - return &RPCFunc{ + + r := &RPCFunc{ f: reflect.ValueOf(f), args: funcArgTypes(f), returns: funcReturnTypes(f), argNames: argNames, - ws: ws, } + + for _, opt := range options { + opt(r) + } + + return r } // return a function's argument types diff --git a/rpc/openapi/openapi.yaml b/rpc/openapi/openapi.yaml index 44f77539d..4b467cac6 100644 --- a/rpc/openapi/openapi.yaml +++ b/rpc/openapi/openapi.yaml @@ -214,6 +214,9 @@ paths: Please refer to https://docs.tendermint.com/v0.34/tendermint-core/using-tendermint.html#formatting for formatting/encoding rules. + + Upon success, the `Cache-Control` header will be set with the default + maximum age. parameters: - in: query name: tx @@ -619,9 +622,12 @@ paths: tags: - Info description: | - Get block headers for minHeight <= height maxHeight. + Get block headers for minHeight <= height <= maxHeight. At most 20 items will be returned. + + Upon success, the `Cache-Control` header will be set with the default + maximum age. responses: "200": description: Block headers, returned in descending order (highest first). @@ -651,6 +657,9 @@ paths: - Info description: | Get Block. + + If the `height` field is set to a non-default value, upon success, the + `Cache-Control` header will be set with the default maximum age. responses: "200": description: Block informations. @@ -680,6 +689,9 @@ paths: - Info description: | Get Block By Hash. + + Upon success, the `Cache-Control` header will be set with the default + maximum age. responses: "200": description: Block informations. @@ -700,7 +712,7 @@ paths: parameters: - in: query name: height - description: height to return. If no height is provided, it will fetch informations regarding the latest block. + description: height to return. If no height is provided, it will fetch information regarding the latest block. schema: type: integer default: 0 @@ -710,6 +722,9 @@ paths: description: | Get block_results. When the `discard_abci_responses` storage flag is enabled, this endpoint will return an error. + + If the `height` field is set to a non-default value, upon success, the + `Cache-Control` header will be set with the default maximum age. responses: "200": description: Block results. @@ -739,6 +754,9 @@ paths: - Info description: | Get Commit. + + If the `height` field is set to a non-default value, upon success, the + `Cache-Control` header will be set with the default maximum age. responses: "200": description: | @@ -787,6 +805,9 @@ paths: - Info description: | Get Validators. Validators are sorted by voting power. + + If the `height` field is set to a non-default value, upon success, the + `Cache-Control` header will be set with the default maximum age. responses: "200": description: Commit results. @@ -808,6 +829,9 @@ paths: - Info description: | Get genesis. + + Upon success, the `Cache-Control` header will be set with the default + maximum age. responses: "200": description: Genesis results. @@ -886,6 +910,9 @@ paths: - Info description: | Get consensus parameters. + + If the `height` field is set to a non-default value, upon success, the + `Cache-Control` header will be set with the default maximum age. responses: "200": description: consensus parameters results. @@ -1076,14 +1103,14 @@ paths: parameters: - in: query name: hash - description: transaction Hash to retrive + description: hash of transaction to retrieve required: true schema: type: string example: "0xD70952032620CC4E2737EB8AC379806359D8E0B17B0488F627997A0B043ABDED" - in: query name: prove - description: Include proofs of the transactions inclusion in the block + description: Include proofs of the transaction's inclusion in the block required: false schema: type: boolean @@ -1092,7 +1119,10 @@ paths: tags: - Info description: | - Get a trasasction + Get a transaction + + Upon success, the `Cache-Control` header will be set with the default + maximum age. responses: "200": description: Get a transaction` @@ -1108,12 +1138,15 @@ paths: $ref: "#/components/schemas/ErrorResponse" /abci_info: get: - summary: Get some info about the application. + summary: Get info about the application. operationId: abci_info tags: - ABCI description: | - Get some info about the application. + Get info about the application. + + Upon success, the `Cache-Control` header will be set with the default + maximum age. responses: "200": description: Get some info about the application.