Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions architecture/jsonrpc/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Package jsonrpc implements the "jsonrpc" network architecture: a
// chain-agnostic passthrough for generic JSON-RPC upstreams.
//
// Networks of this architecture get the generic pipeline (routing, failsafe
// policies, rate limiting, selection policies, multiplexing, metrics) but no
// chain-specific behavior: no chain-id or genesis detection, no state poller,
// no request normalization and no per-method hooks. Every method is forwarded
// to the selected upstream unchanged.
package jsonrpc

import (
"context"
"net/http"

"github.com/erpc/erpc/common"
)

func init() {
common.RegisterArchitecture(common.ArchitectureJsonRpc, &JsonRpcArchitectureHandler{})
}

// JsonRpcArchitectureHandler registers the passthrough architecture with the
// generic pipeline. All hooks are no-ops.
type JsonRpcArchitectureHandler struct{}

var _ common.ArchitectureHandler = (*JsonRpcArchitectureHandler)(nil)

func (h *JsonRpcArchitectureHandler) HandleProjectPreForward(ctx context.Context, network common.Network, req *common.NormalizedRequest) (bool, *common.NormalizedResponse, error) {
return false, nil, nil
}

func (h *JsonRpcArchitectureHandler) HandleNetworkPreForward(ctx context.Context, network common.Network, upstreams []common.Upstream, req *common.NormalizedRequest) (bool, *common.NormalizedResponse, error) {
return false, nil, nil
}

func (h *JsonRpcArchitectureHandler) HandleNetworkPostForward(ctx context.Context, network common.Network, req *common.NormalizedRequest, resp *common.NormalizedResponse, err error) (*common.NormalizedResponse, error) {
return resp, err
}

func (h *JsonRpcArchitectureHandler) HandleUpstreamPreForward(ctx context.Context, network common.Network, upstream common.Upstream, req *common.NormalizedRequest, skipCacheRead bool) (bool, *common.NormalizedResponse, error) {
return false, nil, nil
}

func (h *JsonRpcArchitectureHandler) HandleUpstreamPostForward(ctx context.Context, network common.Network, upstream common.Upstream, req *common.NormalizedRequest, resp *common.NormalizedResponse, err error, skipCacheRead bool) (*common.NormalizedResponse, error) {
return resp, err
}

// NewJsonRpcErrorExtractor returns an extractor that claims no errors. The
// composite extractor then falls through to the EVM extractor (which only
// skips svm upstreams), so jsonrpc upstreams get the default mapping of HTTP
// status codes and standard JSON-RPC error codes into eRPC's error taxonomy.
func (h *JsonRpcArchitectureHandler) NewJsonRpcErrorExtractor() common.JsonRpcErrorExtractor {
return passthroughErrorExtractor{}
}

type passthroughErrorExtractor struct{}

func (passthroughErrorExtractor) Extract(*http.Response, *common.NormalizedResponse, *common.JsonRpcResponse, common.Upstream) error {
return nil
}
48 changes: 48 additions & 0 deletions architecture/jsonrpc/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package jsonrpc

import (
"context"
"errors"
"testing"

"github.com/erpc/erpc/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestJsonRpcArchitectureHandler_RegisteredAsPassthrough(t *testing.T) {
h, err := common.GetArchitectureHandler(common.ArchitectureJsonRpc)
require.NoError(t, err)
require.IsType(t, &JsonRpcArchitectureHandler{}, h)

ctx := context.Background()
req := common.NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"getblockcount"}`))

handled, resp, err := h.HandleProjectPreForward(ctx, nil, req)
assert.False(t, handled)
assert.Nil(t, resp)
assert.NoError(t, err)

handled, resp, err = h.HandleNetworkPreForward(ctx, nil, nil, req)
assert.False(t, handled)
assert.Nil(t, resp)
assert.NoError(t, err)

handled, resp, err = h.HandleUpstreamPreForward(ctx, nil, nil, req, false)
assert.False(t, handled)
assert.Nil(t, resp)
assert.NoError(t, err)

// Post-forward hooks hand back exactly what they were given.
in := common.NewNormalizedResponse().WithRequest(req)
inErr := errors.New("upstream failed")
out, outErr := h.HandleNetworkPostForward(ctx, nil, req, in, inErr)
assert.Same(t, in, out)
assert.Same(t, inErr, outErr)
out, outErr = h.HandleUpstreamPostForward(ctx, nil, nil, req, in, inErr, false)
assert.Same(t, in, out)
assert.Same(t, inErr, outErr)

// The extractor claims nothing, deferring to the default extractor.
assert.NoError(t, h.NewJsonRpcErrorExtractor().Extract(nil, nil, nil, nil))
}
50 changes: 50 additions & 0 deletions clients/http_json_json_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,56 @@ func TestHttpJsonRpcClient_SingleRequests(t *testing.T) {
})
}

func TestHttpJsonRpcClient_OmitEmptyParams(t *testing.T) {
cases := []struct {
name string
omit *bool
wantParams bool
}{
{name: "DefaultSendsEmptyParams", omit: nil, wantParams: true},
{name: "DisabledSendsEmptyParams", omit: util.BoolPtr(false), wantParams: true},
{name: "EnabledOmitsEmptyParams", omit: util.BoolPtr(true), wantParams: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
util.ResetGock()
defer util.ResetGock()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logger := log.Logger

var sentBody string
gock.New("http://rpc1.localhost:8545").
Post("/").
Filter(func(req *http.Request) bool {
sentBody = util.SafeReadBody(req)
return true
}).
Reply(200).
BodyString(`{"jsonrpc":"2.0","id":1,"result":"ok"}`)

ups := common.NewFakeUpstream("rpc1")
ups.Config().Endpoint = "http://rpc1.localhost:8545"
jrCfg := &common.JsonRpcUpstreamConfig{OmitEmptyParams: tc.omit}
client, err := NewGenericHttpJsonRpcClient(ctx, &logger, "prj1", ups, &url.URL{Scheme: "http", Host: "rpc1.localhost:8545"}, jrCfg, nil, &noopErrorExtractor{})
require.NoError(t, err)

req := common.NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"getHealth","params":[]}`))
resp, err := client.SendRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)

assert.Contains(t, sentBody, `"method":"getHealth"`)
if tc.wantParams {
assert.Contains(t, sentBody, `"params":[]`)
} else {
assert.NotContains(t, sentBody, `"params"`)
}
})
}
}

func TestHttpJsonRpcClient_BatchRequests(t *testing.T) {
logger := log.Logger

Expand Down
29 changes: 12 additions & 17 deletions clients/http_json_rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ type GenericHttpJsonRpcClient struct {
httpClient *http.Client
isLogLevelTrace bool

enableGzip bool
supportsBatch bool
batchMaxSize int
batchMaxWait time.Duration
enableGzip bool
omitEmptyParams bool
supportsBatch bool
batchMaxSize int
batchMaxWait time.Duration

batchMu sync.Mutex
batchRequests map[interface{}]*batchRequest
Expand Down Expand Up @@ -132,6 +133,10 @@ func NewGenericHttpJsonRpcClient(
client.enableGzip = *jsonRpcCfg.EnableGzip
}

if jsonRpcCfg.OmitEmptyParams != nil {
client.omitEmptyParams = *jsonRpcCfg.OmitEmptyParams
}

if jsonRpcCfg.Headers != nil {
client.headers = jsonRpcCfg.Headers
}
Expand Down Expand Up @@ -371,7 +376,7 @@ func (c *GenericHttpJsonRpcClient) processBatch(alreadyLocked bool) {
return
}

batchReq := make([]common.JsonRpcRequest, 0, ln)
batchReq := make([]interface{}, 0, ln)
for _, req := range requests {
jrReq, err := req.request.JsonRpcRequest()
c.logger.Trace().Interface("id", req.request.ID()).Str("method", jrReq.Method).Msgf("preparing batch request")
Expand All @@ -387,12 +392,7 @@ func (c *GenericHttpJsonRpcClient) processBatch(alreadyLocked bool) {
}
req.request.RLock()
jrReq.RLock()
batchReq = append(batchReq, common.JsonRpcRequest{
JSONRPC: jrReq.JSONRPC,
Method: jrReq.Method,
Params: jrReq.Params,
ID: jrReq.ID,
})
batchReq = append(batchReq, jrReq.WireRequest(c.omitEmptyParams))
}

requestBody, err := common.SonicCfg.Marshal(batchReq)
Expand Down Expand Up @@ -699,12 +699,7 @@ func (c *GenericHttpJsonRpcClient) sendSingleRequest(ctx context.Context, req *c

jrReq.RLock()
span.SetAttributes(attribute.String("request.method", jrReq.Method))
requestBody, err := common.SonicCfg.Marshal(common.JsonRpcRequest{
JSONRPC: jrReq.JSONRPC,
Method: jrReq.Method,
Params: jrReq.Params,
ID: jrReq.ID,
})
requestBody, err := common.SonicCfg.Marshal(jrReq.WireRequest(c.omitEmptyParams))
jrReq.RUnlock()
if err != nil {
common.SetTraceSpanError(span, err)
Expand Down
6 changes: 5 additions & 1 deletion clients/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U
creation.once.Do(func() {
lg := manager.logger.With().Str("upstreamId", cfg.Id).Logger()
switch cfg.Type {
case common.UpstreamTypeEvm:
case common.UpstreamTypeEvm, common.UpstreamTypeJsonRpc:
// jsonrpc (passthrough) upstreams reuse the generic HTTP and
// WebSocket JSON-RPC clients; gRPC BDS stays EVM-only.
if parsedUrl.Scheme == "http" || parsedUrl.Scheme == "https" {
newClient, err = NewGenericHttpJsonRpcClient(
appCtx,
Expand Down Expand Up @@ -129,6 +131,8 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U
clientErr = fmt.Errorf("failed to create WebSocket client for upstream %v: %w", cfg.Id, err)
}
}
} else if cfg.Type == common.UpstreamTypeJsonRpc {
clientErr = fmt.Errorf("unsupported endpoint scheme for jsonrpc upstream %v: %v (only http/https/ws/wss supported)", cfg.Id, parsedUrl.Scheme)
} else if parsedUrl.Scheme == "grpc" || parsedUrl.Scheme == "grpc+bds" || parsedUrl.Scheme == "grpcs" {
grpcPoolSize, grpcHealthCheckService := 0, ""
if cfg.Grpc != nil {
Expand Down
32 changes: 32 additions & 0 deletions clients/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,35 @@ func TestClientRegistryRejectsProxyPoolForWebSocket(t *testing.T) {
_, err = registry.GetOrCreateClient(ctx, up)
require.ErrorContains(t, err, "proxyPool is not supported for WebSocket")
}

// jsonrpc (passthrough) upstreams reuse the generic HTTP and WebSocket
// JSON-RPC clients; gRPC endpoints are EVM-only and must be refused.
func TestClientRegistryJsonRpcUpstreams(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logger := zerolog.Nop()
registry := NewClientRegistry(&logger, "test-project", nil, nil)

newUps := func(id, endpoint string) common.Upstream {
up := common.NewFakeUpstream(id)
up.Config().Type = common.UpstreamTypeJsonRpc
up.Config().Endpoint = endpoint
up.Config().JsonRpc = &common.JsonRpcUpstreamConfig{NetworkId: "bitcoin-mainnet"}
return up
}

client, err := registry.GetOrCreateClient(ctx, newUps("http-upstream", "http://rpc1.localhost"))
require.NoError(t, err)
require.IsType(t, &GenericHttpJsonRpcClient{}, client)

// Nothing listens on port 1: the initial dial fails fast and the client
// keeps retrying in the background until ctx is cancelled.
client, err = registry.GetOrCreateClient(ctx, newUps("ws-upstream", "ws://127.0.0.1:1"))
require.NoError(t, err)
require.NotNil(t, client)

for _, scheme := range []string{"grpc", "grpc+bds", "grpcs"} {
_, err = registry.GetOrCreateClient(ctx, newUps(scheme+"-upstream", scheme+"://rpc1.localhost"))
require.ErrorContains(t, err, "unsupported endpoint scheme for jsonrpc upstream", scheme)
}
}
21 changes: 14 additions & 7 deletions clients/ws_json_rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ type WsJsonRpcClient struct {
Url *url.URL
headers http.Header

projectId string
upstream common.Upstream
appCtx context.Context
logger *zerolog.Logger
projectId string
upstream common.Upstream
appCtx context.Context
logger *zerolog.Logger
omitEmptyParams bool

pingInterval time.Duration
pongWait time.Duration
Expand Down Expand Up @@ -184,6 +185,9 @@ func NewWsJsonRpcClient(
onReconnectCbs: make(map[string]func()),
errorExtractor: extractor,
}
if jsonRpcCfg != nil && jsonRpcCfg.OmitEmptyParams != nil {
client.omitEmptyParams = *jsonRpcCfg.OmitEmptyParams
}

if err := client.connect(); err != nil {
// readLoop keeps re-dialing in the background.
Expand Down Expand Up @@ -242,12 +246,15 @@ func (c *WsJsonRpcClient) SendRequest(ctx context.Context, req *common.Normalize
jrReq.RLock()
originalID := jrReq.ID
method := jrReq.Method
requestBody, err := common.SonicCfg.Marshal(map[string]interface{}{
envelope := map[string]interface{}{
"jsonrpc": jrReq.JSONRPC,
"id": wireID,
"method": method,
"params": jrReq.Params,
})
}
if !c.omitEmptyParams || len(jrReq.Params) > 0 {
envelope["params"] = jrReq.Params
}
requestBody, err := common.SonicCfg.Marshal(envelope)
jrReq.RUnlock()
if err != nil {
common.SetTraceSpanError(span, err)
Expand Down
23 changes: 23 additions & 0 deletions common/architecture_jsonrpc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package common

const (
// UpstreamTypeJsonRpc marks an upstream that speaks plain JSON-RPC and is
// served through the "jsonrpc" passthrough architecture. Such upstreams
// get no chain-specific detection, state polling or method hooks.
UpstreamTypeJsonRpc UpstreamType = "jsonrpc"
)

// JsonRpcNetworkConfig identifies a generic JSON-RPC network served by the
// "jsonrpc" passthrough architecture. It is meant for non-EVM, non-SVM nodes
// (for example Bitcoin-family or Stellar RPC servers) where eRPC should only
// provide routing, failover, rate limiting and metrics, and forward every
// method unchanged.
//
// The resulting network id is "jsonrpc:<id>". Upstreams join the network by
// setting the same value in jsonRpc.networkId.
type JsonRpcNetworkConfig struct {
// Id is the network slug, e.g. "bitcoin-mainnet". It may contain only
// alphanumeric characters, dash and underscore. When omitted it defaults
// to the network alias.
Id string `yaml:"id,omitempty" json:"id,omitempty"`
}
Loading
Loading