diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 5c57b26323..d7de0a3010 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -28,6 +28,7 @@ - [pubsub] \#7319 Performance improvements for the event query API (@creachadair) - [p2p/pex] \#6509 Improve addrBook.hash performance (@cuonglm) - [crypto/merkle] \#6443 & \#6513 Improve HashAlternatives performance (@cuonglm, @marbar3778) +- [rpc] \#9650 Enable caching of RPC responses (@JayT106) ### BUG FIXES @@ -97,4 +98,4 @@ Friendly reminder, we have a [bug bounty program](https://hackerone.com/tendermi - [consensus] \#9229 fix round number of `enterPropose` when handling `RoundStepNewRound` timeout. (@fatcat22) - [docker] \#9073 enable cross platform build using docker buildx -- [blocksync] \#9518 handle the case when the sending queue is full: retry block request after a timeout \ No newline at end of file +- [blocksync] \#9518 handle the case when the sending queue is full: retry block request after a timeout diff --git a/cmd/tendermint/commands/debug/util.go b/cmd/tendermint/commands/debug/util.go index a2eef53fc6..f29fd5a81e 100644 --- a/cmd/tendermint/commands/debug/util.go +++ b/cmd/tendermint/commands/debug/util.go @@ -67,7 +67,7 @@ func copyConfig(home, dir string) error { func dumpProfile(dir, addr, profile string, debug int) error { endpoint := fmt.Sprintf("%s/debug/pprof/%s?debug=%d", addr, profile, debug) - //nolint:all + //nolint:gosec,nolintlint resp, err := http.Get(endpoint) if err != nil { return fmt.Errorf("failed to query for %s profile: %w", profile, err) diff --git a/light/proxy/routes.go b/light/proxy/routes.go index c97a91dfdb..d7a427095f 100644 --- a/light/proxy/routes.go +++ b/light/proxy/routes.go @@ -21,22 +21,22 @@ 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"), - "header": rpcserver.NewRPCFunc(makeHeaderFunc(c), "height"), - "header_by_hash": rpcserver.NewRPCFunc(makeHeaderByHashFunc(c), "hash"), - "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")), + "header": rpcserver.NewRPCFunc(makeHeaderFunc(c), "height", rpcserver.Cacheable("height")), + "header_by_hash": rpcserver.NewRPCFunc(makeHeaderByHashFunc(c), "hash", rpcserver.Cacheable()), + "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), ""), @@ -47,7 +47,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 fe2d17e8b1..e6cf59aa7d 100644 --- a/rpc/core/routes.go +++ b/rpc/core/routes.go @@ -17,23 +17,23 @@ 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"), - "header": rpc.NewRPCFunc(Header, "height"), - "header_by_hash": rpc.NewRPCFunc(HeaderByHash, "hash"), - "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")), + "header": rpc.NewRPCFunc(Header, "height", rpc.Cacheable("height")), + "header_by_hash": rpc.NewRPCFunc(HeaderByHash, "hash", rpc.Cacheable()), + "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, ""), @@ -44,7 +44,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 84956bae95..c322dfcea9 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 c73694d6e8..db162f17ab 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) } } @@ -128,7 +146,6 @@ func mapParamsToArgs( params map[string]json.RawMessage, argsOffset int, ) ([]reflect.Value, error) { - values := make([]reflect.Value, len(rpcFunc.argNames)) for i, argName := range rpcFunc.argNames { argType := rpcFunc.args[i+argsOffset] @@ -153,7 +170,6 @@ func arrayParamsToArgs( params []json.RawMessage, argsOffset int, ) ([]reflect.Value, error) { - if len(rpcFunc.argNames) != len(params) { return nil, fmt.Errorf("expected %v parameters (%v), got %v (%v)", len(rpcFunc.argNames), rpcFunc.argNames, len(params), params) diff --git a/rpc/jsonrpc/server/http_json_handler_test.go b/rpc/jsonrpc/server/http_json_handler_test.go index fbcb470c09..44caedd3e1 100644 --- a/rpc/jsonrpc/server/http_json_handler_test.go +++ b/rpc/jsonrpc/server/http_json_handler_test.go @@ -18,7 +18,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) @@ -227,3 +228,52 @@ func TestUnknownRPCPath(t *testing.T) { require.Equal(t, http.StatusNotFound, res.StatusCode, "should always return 404") 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") +} diff --git a/rpc/jsonrpc/server/http_server.go b/rpc/jsonrpc/server/http_server.go index 6eaa0ab938..617e1bbdc6 100644 --- a/rpc/jsonrpc/server/http_server.go +++ b/rpc/jsonrpc/server/http_server.go @@ -117,6 +117,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] @@ -129,6 +145,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 @@ -166,7 +185,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 6e2024b8da..e1c499200f 100644 --- a/rpc/jsonrpc/server/http_server_test.go +++ b/rpc/jsonrpc/server/http_server_test.go @@ -112,7 +112,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) @@ -120,6 +120,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 6609cb8372..e99a1b0ac7 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 e5855c3140..8a5053666c 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 4d5feea448..510cf39d19 100644 --- a/rpc/openapi/openapi.yaml +++ b/rpc/openapi/openapi.yaml @@ -216,6 +216,9 @@ paths: Please refer to https://docs.tendermint.com/main/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 @@ -621,9 +624,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). @@ -653,6 +659,9 @@ paths: - Info description: | Get Header. + + 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: Header informations. @@ -682,6 +691,9 @@ paths: - Info description: | Get Header By Hash. + + Upon success, the `Cache-Control` header will be set with the default + maximum age. responses: "200": description: Header informations. @@ -711,6 +723,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. @@ -740,6 +755,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. @@ -760,7 +778,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 @@ -769,6 +787,9 @@ paths: - Info description: | Get block_results. + + 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. @@ -798,6 +819,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: | @@ -845,7 +869,11 @@ paths: tags: - Info description: | - Get Validators. Validators are sorted first by voting power (descending), then by address (ascending). + Get Validators. Validators are sorted first by voting power + (descending), then by address (ascending). + + 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. @@ -867,6 +895,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. @@ -945,6 +976,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. @@ -1135,14 +1169,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 @@ -1151,7 +1185,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` @@ -1167,12 +1204,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. diff --git a/test/fuzz/tests/rpc_jsonrpc_server_test.go b/test/fuzz/tests/rpc_jsonrpc_server_test.go index 5cfcbd7ce1..432a8250c0 100644 --- a/test/fuzz/tests/rpc_jsonrpc_server_test.go +++ b/test/fuzz/tests/rpc_jsonrpc_server_test.go @@ -21,7 +21,7 @@ func FuzzRPCJSONRPCServer(f *testing.F) { I int `json:"i"` } var rpcFuncMap = map[string]*rpcserver.RPCFunc{ - "c": rpcserver.NewRPCFunc(func(ctx *rpctypes.Context, args *args) (string, error) { + "c": rpcserver.NewRPCFunc(func(ctx *rpctypes.Context, args *args, options ...rpcserver.Option) (string, error) { return "foo", nil }, "args"), }