diff --git a/README.md b/README.md index 444a6d0..03eff66 100644 --- a/README.md +++ b/README.md @@ -246,12 +246,25 @@ if err := client.Call(); err != nil { ## Options -### Using `WithMethodNameFormatter` +### Using `WithServerMethodNameFormatter` + +`WithServerMethodNameFormatter` allows you to customize a function that formats the JSON-RPC method name, given namespace and method name. + +There are four possible options: +- `jsonrpc.DefaultMethodNameFormatter` - default method name formatter, e.g. `SimpleServerHandler.AddGet` +- `jsonrpc.NewMethodNameFormatter(true, jsonrpc.LowerFirstCharCase)` - method name formatter with namespace, e.g. `SimpleServerHandler.addGet` +- `jsonrpc.NewMethodNameFormatter(false, jsonrpc.OriginalCase)` - method name formatter without namespace, e.g. `AddGet` +- `jsonrpc.NewMethodNameFormatter(false, jsonrpc.LowerFirstCharCase)` - method name formatter without namespace and with the first char lowercased, e.g. `addGet` + +> [!NOTE] +> The default method name formatter concatenates the namespace and method name with a dot. +> Go exported methods are capitalized, so, the method name will be capitalized as well. +> e.g. `SimpleServerHandler.AddGet` (capital "A" in "AddGet") ```go func main() { // create a new server instance with a custom separator - rpcServer := jsonrpc.NewServer(jsonrpc.WithMethodNameFormatter( + rpcServer := jsonrpc.NewServer(jsonrpc.WithServerMethodNameFormatter( func(namespace, method string) string { return namespace + "_" + method }), @@ -273,6 +286,23 @@ func main() { } ``` +### Using `WithMethodNameFormatter` + +`WithMethodNameFormatter` is the client-side counterpart to `WithServerMethodNameFormatter`. + +```go +func main() { + closer, err := NewMergeClient( + context.Background(), + "http://example.com", + "SimpleServerHandler", + []any{&client}, + nil, + WithMethodNameFormatter(jsonrpc.NewMethodNameFormatter(false, OriginalCase)), + ) + defer closer() +} +``` ## Contribute @@ -280,4 +310,4 @@ PRs are welcome! ## License -Dual-licensed under [MIT](https://github.com/filecoin-project/go-jsonrpc/blob/master/LICENSE-MIT) + [Apache 2.0](https://github.com/filecoin-project/go-jsonrpc/blob/master/LICENSE-APACHE) \ No newline at end of file +Dual-licensed under [MIT](https://github.com/filecoin-project/go-jsonrpc/blob/master/LICENSE-MIT) + [Apache 2.0](https://github.com/filecoin-project/go-jsonrpc/blob/master/LICENSE-APACHE) diff --git a/client.go b/client.go index bc3dac6..69a9cfe 100644 --- a/client.go +++ b/client.go @@ -104,6 +104,8 @@ type client struct { doRequest func(context.Context, clientRequest) (clientResponse, error) exiting <-chan struct{} idCtr int64 + + methodNameFormatter MethodNameFormatter } // NewMergeClient is like NewClient, but allows to specify multiple structs @@ -138,9 +140,10 @@ func NewCustomClient(namespace string, outs []interface{}, doRequest func(ctx co } c := client{ - namespace: namespace, - paramEncoders: config.paramEncoders, - errors: config.errors, + namespace: namespace, + paramEncoders: config.paramEncoders, + errors: config.errors, + methodNameFormatter: config.methodNamer, } stop := make(chan struct{}) @@ -192,9 +195,10 @@ func NewCustomClient(namespace string, outs []interface{}, doRequest func(ctx co func httpClient(ctx context.Context, addr string, namespace string, outs []interface{}, requestHeader http.Header, config Config) (ClientCloser, error) { c := client{ - namespace: namespace, - paramEncoders: config.paramEncoders, - errors: config.errors, + namespace: namespace, + paramEncoders: config.paramEncoders, + errors: config.errors, + methodNameFormatter: config.methodNamer, } stop := make(chan struct{}) @@ -287,9 +291,10 @@ func websocketClient(ctx context.Context, addr string, namespace string, outs [] } c := client{ - namespace: namespace, - paramEncoders: config.paramEncoders, - errors: config.errors, + namespace: namespace, + paramEncoders: config.paramEncoders, + errors: config.errors, + methodNameFormatter: config.methodNamer, } requests := c.setupRequestChan() @@ -710,7 +715,7 @@ func (c *client) makeRpcFunc(f reflect.StructField) (reflect.Value, error) { return reflect.Value{}, xerrors.New("handler field not a func") } - name := c.namespace + "." + f.Name + name := c.methodNameFormatter(c.namespace, f.Name) if tag, ok := f.Tag.Lookup(ProxyTagRPCMethod); ok { name = tag } diff --git a/method_formatter.go b/method_formatter.go new file mode 100644 index 0000000..c2b3616 --- /dev/null +++ b/method_formatter.go @@ -0,0 +1,32 @@ +package jsonrpc + +import "strings" + +// MethodNameFormatter is a function that takes a namespace and a method name and returns the full method name, sent via JSON-RPC. +// This is useful if you want to customize the default behaviour, e.g. send without the namespace or make it lowercase. +type MethodNameFormatter func(namespace, method string) string + +// CaseStyle represents the case style for method names. +type CaseStyle int + +const ( + OriginalCase CaseStyle = iota + LowerFirstCharCase +) + +// NewMethodNameFormatter creates a new method name formatter based on the provided options. +func NewMethodNameFormatter(includeNamespace bool, nameCase CaseStyle) MethodNameFormatter { + return func(namespace, method string) string { + formattedMethod := method + if nameCase == LowerFirstCharCase && len(method) > 0 { + formattedMethod = strings.ToLower(method[:1]) + method[1:] + } + if includeNamespace { + return namespace + "." + formattedMethod + } + return formattedMethod + } +} + +// DefaultMethodNameFormatter is a pass-through formatter with default options. +var DefaultMethodNameFormatter = NewMethodNameFormatter(true, OriginalCase) diff --git a/method_formatter_test.go b/method_formatter_test.go new file mode 100644 index 0000000..d003ddd --- /dev/null +++ b/method_formatter_test.go @@ -0,0 +1,125 @@ +package jsonrpc + +import ( + "context" + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDifferentMethodNamers(t *testing.T) { + tests := map[string]struct { + namer MethodNameFormatter + + requestedMethod string + }{ + "default namer": { + namer: DefaultMethodNameFormatter, + requestedMethod: "SimpleServerHandler.Inc", + }, + "lower fist char": { + namer: NewMethodNameFormatter(true, LowerFirstCharCase), + requestedMethod: "SimpleServerHandler.inc", + }, + "no namespace namer": { + namer: NewMethodNameFormatter(false, OriginalCase), + requestedMethod: "Inc", + }, + "no namespace & lower fist char": { + namer: NewMethodNameFormatter(false, LowerFirstCharCase), + requestedMethod: "inc", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + rpcServer := NewServer(WithServerMethodNameFormatter(test.namer)) + + serverHandler := &SimpleServerHandler{} + rpcServer.Register("SimpleServerHandler", serverHandler) + + testServ := httptest.NewServer(rpcServer) + defer testServ.Close() + + req := fmt.Sprintf(`{"jsonrpc": "2.0", "method": "%s", "params": [], "id": 1}`, test.requestedMethod) + + res, err := http.Post(testServ.URL, "application/json", strings.NewReader(req)) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, int32(1), serverHandler.n) + }) + } +} + +func TestDifferentMethodNamersWithClient(t *testing.T) { + tests := map[string]struct { + namer MethodNameFormatter + urlPrefix string + }{ + "default namer & http": { + namer: DefaultMethodNameFormatter, + urlPrefix: "http://", + }, + "default namer & ws": { + namer: DefaultMethodNameFormatter, + urlPrefix: "ws://", + }, + "lower first char namer & http": { + namer: NewMethodNameFormatter(true, LowerFirstCharCase), + urlPrefix: "http://", + }, + "lower first char namer & ws": { + namer: NewMethodNameFormatter(true, LowerFirstCharCase), + urlPrefix: "ws://", + }, + "no namespace namer & http": { + namer: NewMethodNameFormatter(false, OriginalCase), + urlPrefix: "http://", + }, + "no namespace namer & ws": { + namer: NewMethodNameFormatter(false, OriginalCase), + urlPrefix: "ws://", + }, + "no namespace & lower first char & http": { + namer: NewMethodNameFormatter(false, LowerFirstCharCase), + urlPrefix: "http://", + }, + "no namespace & lower first char & ws": { + namer: NewMethodNameFormatter(false, LowerFirstCharCase), + urlPrefix: "ws://", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + rpcServer := NewServer(WithServerMethodNameFormatter(test.namer)) + + serverHandler := &SimpleServerHandler{} + rpcServer.Register("SimpleServerHandler", serverHandler) + + testServ := httptest.NewServer(rpcServer) + defer testServ.Close() + + var client struct { + AddGet func(int) int + } + + closer, err := NewMergeClient( + context.Background(), + test.urlPrefix+testServ.Listener.Addr().String(), + "SimpleServerHandler", + []any{&client}, + nil, + WithHTTPClient(testServ.Client()), + WithMethodNameFormatter(test.namer), + ) + require.NoError(t, err) + defer closer() + + n := client.AddGet(123) + require.Equal(t, 123, n) + }) + } +} diff --git a/options.go b/options.go index 2172f50..8b4d0e9 100644 --- a/options.go +++ b/options.go @@ -30,6 +30,8 @@ type Config struct { noReconnect bool proxyConnFactory func(func() (*websocket.Conn, error)) func() (*websocket.Conn, error) // for testing + + methodNamer MethodNameFormatter } func defaultConfig() Config { @@ -46,6 +48,8 @@ func defaultConfig() Config { paramEncoders: map[reflect.Type]ParamEncoder{}, httpClient: _defaultHTTPClient, + + methodNamer: DefaultMethodNameFormatter, } } @@ -110,3 +114,9 @@ func WithHTTPClient(h *http.Client) func(c *Config) { c.httpClient = h } } + +func WithMethodNameFormatter(namer MethodNameFormatter) func(c *Config) { + return func(c *Config) { + c.methodNamer = namer + } +} diff --git a/options_server.go b/options_server.go index 7a3e2d3..c6897a4 100644 --- a/options_server.go +++ b/options_server.go @@ -13,8 +13,6 @@ type jsonrpcReverseClient struct{ reflect.Type } type ParamDecoder func(ctx context.Context, json []byte) (reflect.Value, error) -type MethodNameFormatter func(namespace, method string) string - type ServerConfig struct { maxRequestSize int64 pingInterval time.Duration @@ -34,10 +32,8 @@ func defaultServerConfig() ServerConfig { paramDecoders: map[reflect.Type]ParamDecoder{}, maxRequestSize: DEFAULT_MAX_REQUEST_SIZE, - pingInterval: 5 * time.Second, - methodNameFormatter: func(namespace, method string) string { - return namespace + "." + method - }, + pingInterval: 5 * time.Second, + methodNameFormatter: DefaultMethodNameFormatter, } } @@ -65,7 +61,7 @@ func WithServerPingInterval(d time.Duration) ServerOption { } } -func WithMethodNameFormatter(formatter MethodNameFormatter) ServerOption { +func WithServerMethodNameFormatter(formatter MethodNameFormatter) ServerOption { return func(c *ServerConfig) { c.methodNameFormatter = formatter } @@ -85,8 +81,9 @@ func WithReverseClient[RP any](namespace string) ServerOption { return func(c *ServerConfig) { c.reverseClientBuilder = func(ctx context.Context, conn *wsConn) (context.Context, error) { cl := client{ - namespace: namespace, - paramEncoders: map[reflect.Type]ParamEncoder{}, + namespace: namespace, + paramEncoders: map[reflect.Type]ParamEncoder{}, + methodNameFormatter: c.methodNameFormatter, } // todo test that everything is closing correctly diff --git a/rpc_test.go b/rpc_test.go index 832d0c1..f394708 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -1719,7 +1719,7 @@ func TestNewCustomClient(t *testing.T) { func TestReverseCallWithCustomMethodName(t *testing.T) { // setup server - rpcServer := NewServer(WithMethodNameFormatter(func(namespace, method string) string { return namespace + "_" + method })) + rpcServer := NewServer(WithServerMethodNameFormatter(func(namespace, method string) string { return namespace + "_" + method })) rpcServer.Register("Server", &RawParamHandler{}) // httptest stuff