commit 440f3f4604f3489113f6705b8da67d839e52360e Author: Alexander Kiryukhin Date: Mon Jan 31 02:31:43 2022 +0300 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f7c963 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# JSON-RPC 2.0 + +Golang implementation of JSON-RPC 2.0 server with generics. + +Go 1.18+ required + +## Features: + +- [x] Batch request and responses +- [ ] WebSockets + +## Usage + +1. Create JSON-RPC 2.0 server: + ```go + s := jsonrpc2.New() + ``` +2. Write handler: + ```go + func Multiply(ctx context.Context, args *Args) (int, error) { + return args.A * args.B, nil + } + ``` + Handler must have exact two arguments (context and input of any json serializable type) and exact two return values (output of any json serializable type and error) +3. Wrap handler with `jsonrpc2.Wrap` method and register it in server: + ```go + s.Register("multiply", jsonrpc2.Wrap(Multiply)) + ``` +4. Use server as common http handler: + ```go + http.ListenAndServe(":8000", s) + ``` + +## Complete example + +[Full code](/examples/http) + +```go +package main + +import ( + "context" + "net/http" + + "github.com/neonxp/rpc" +) + +func main() { + s := jsonrpc2.New() + s.Register("multiply", jsonrpc2.Wrap(Multiply)) // Register handlers + s.Register("divide", jsonrpc2.Wrap(Divide)) + + http.ListenAndServe(":8000", s) +} + +func Multiply(ctx context.Context, args *Args) (int, error) { + //... +} + +func Divide(ctx context.Context, args *Args) (*Quotient, error) { + //... +} + +type Args struct { + A int `json:"a"` + B int `json:"b"` +} + +type Quotient struct { + Quo int `json:"quo"` + Rem int `json:"rem"` +} + +``` \ No newline at end of file diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..cd87fbb --- /dev/null +++ b/errors.go @@ -0,0 +1,42 @@ +package jsonrpc2 + +import "fmt" + +const ( + ErrCodeParseError = -32700 + ErrCodeInvalidRequest = -32600 + ErrCodeMethodNotFound = -32601 + ErrCodeInvalidParams = -32602 + ErrCodeInternalError = -32603 + ErrUser = -32000 +) + +var errorMap = map[int]string{ + -32700: "Parse error", // Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. + -32600: "Invalid Request", // The JSON sent is not a valid Request object. + -32601: "Method not found", // The method does not exist / is not available. + -32602: "Invalid params", // Invalid method parameter(s). + -32603: "Internal error", // Internal JSON-RPC error. + -32000: "Other error", +} + +//-32000 to -32099 Server error Reserved for implementation-defined server-errors. + +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e Error) Error() string { + return fmt.Sprintf("jsonrpc2 error: code: %d message: %s", e.Code, e.Message) +} + +func NewError(code int) Error { + if _, ok := errorMap[code]; ok { + return Error{ + Code: code, + Message: errorMap[code], + } + } + return Error{Code: code} +} diff --git a/examples/http/main.go b/examples/http/main.go new file mode 100644 index 0000000..fdef509 --- /dev/null +++ b/examples/http/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "errors" + "net/http" + + "github.com/neonxp/rpc" +) + +func main() { + s := jsonrpc2.New() + s.Register("multiply", jsonrpc2.Wrap(Multiply)) + s.Register("divide", jsonrpc2.Wrap(Divide)) + + http.ListenAndServe(":8000", s) +} + +func Multiply(ctx context.Context, args *Args) (int, error) { + return args.A * args.B, nil +} + +func Divide(ctx context.Context, args *Args) (*Quotient, error) { + if args.B == 0 { + return nil, errors.New("divide by zero") + } + quo := new(Quotient) + quo.Quo = args.A / args.B + quo.Rem = args.A % args.B + return quo, nil +} + +type Args struct { + A int `json:"a"` + B int `json:"b"` +} + +type Quotient struct { + Quo int `json:"quo"` + Rem int `json:"rem"` +} diff --git a/examples/http/test.http b/examples/http/test.http new file mode 100644 index 0000000..d4e68b3 --- /dev/null +++ b/examples/http/test.http @@ -0,0 +1,42 @@ +POST http://localhost:8000/ +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "method": "multiply", + "params": { + "a": 2, + "b": 3 + }, + "id": 1 +} + +### + +POST http://localhost:8000/ +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "method": "divide", + "params": { + "a": 10, + "b": 3 + }, + "id": 2 +} + +### + +POST http://localhost:8000/ +Content-Type: application/json + +[ + { "jsonrpc": "2.0", "method": "multiply", "params": { "a": 2, "b": 3 }, "id": 10 }, + {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, + {"foo": "boo"}, + {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, + {"jsonrpc": "2.0", "method": "get_data", "id": "9"} +] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2beacc4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/neonxp/rpc + +go 1.18 diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..7907e4f --- /dev/null +++ b/logger.go @@ -0,0 +1,20 @@ +package jsonrpc2 + +import "log" + +type Logger interface { + Logf(format string, args ...interface{}) +} + +type nopLogger struct{} + +func (n nopLogger) Logf(format string, args ...interface{}) { +} + +type stdLogger struct{} + +func (n stdLogger) Logf(format string, args ...interface{}) { + log.Printf(format, args...) +} + +var StdLogger = stdLogger{} diff --git a/server.go b/server.go new file mode 100644 index 0000000..5625f78 --- /dev/null +++ b/server.go @@ -0,0 +1,146 @@ +package jsonrpc2 + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "sync" +) + +const version = "2.0" + +type Server struct { + Logger Logger + IgnoreNotifications bool + handlers map[string]Handler + mu sync.RWMutex +} + +func (r *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + buf := bufio.NewReader(request.Body) + defer request.Body.Close() + firstByte, err := buf.Peek(1) + if err != nil { + r.Logger.Logf("Can't read body: %v", err) + writeError(ErrCodeParseError, writer) + return + } + if string(firstByte) == "[" { + r.batchRequest(writer, request, buf) + return + } + r.singleRequest(writer, request, buf) +} + +func New() *Server { + return &Server{ + Logger: nopLogger{}, + IgnoreNotifications: true, + handlers: map[string]Handler{}, + mu: sync.RWMutex{}, + } +} + +func (r *Server) Register(method string, handler Handler) { + r.mu.Lock() + defer r.mu.Unlock() + r.handlers[method] = handler +} + +func (r *Server) singleRequest(writer http.ResponseWriter, request *http.Request, buf *bufio.Reader) { + req := new(rpcRequest) + if err := json.NewDecoder(buf).Decode(req); err != nil { + r.Logger.Logf("Can't read body: %v", err) + writeError(ErrCodeParseError, writer) + return + } + resp := r.callMethod(request.Context(), req) + if req.Id == nil && r.IgnoreNotifications { + // notification request + return + } + if err := json.NewEncoder(writer).Encode(resp); err != nil { + r.Logger.Logf("Can't write response: %v", err) + writeError(ErrCodeInternalError, writer) + return + } +} + +func (r *Server) batchRequest(writer http.ResponseWriter, request *http.Request, buf *bufio.Reader) { + var req []rpcRequest + if err := json.NewDecoder(buf).Decode(&req); err != nil { + r.Logger.Logf("Can't read body: %v", err) + writeError(ErrCodeParseError, writer) + return + } + var responses []*rpcResponse + wg := sync.WaitGroup{} + wg.Add(len(req)) + for _, j := range req { + go func(req rpcRequest) { + defer wg.Done() + resp := r.callMethod(request.Context(), &req) + if req.Id == nil && r.IgnoreNotifications { + // notification request + return + } + responses = append(responses, resp) + }(j) + } + wg.Wait() + if err := json.NewEncoder(writer).Encode(responses); err != nil { + r.Logger.Logf("Can't write response: %v", err) + writeError(ErrCodeInternalError, writer) + } +} + +func (r *Server) callMethod(ctx context.Context, req *rpcRequest) *rpcResponse { + r.mu.RLock() + h, ok := r.handlers[req.Method] + r.mu.RUnlock() + if !ok { + return &rpcResponse{ + Jsonrpc: version, + Error: NewError(ErrCodeMethodNotFound), + Id: req.Id, + } + } + resp, err := h(ctx, req.Params) + if err != nil { + r.Logger.Logf("User error %v", err) + return &rpcResponse{ + Jsonrpc: version, + Error: err, + Id: req.Id, + } + } + return &rpcResponse{ + Jsonrpc: version, + Result: resp, + Id: req.Id, + } +} + +func writeError(code int, w io.Writer) { + _ = json.NewEncoder(w).Encode(rpcResponse{ + Jsonrpc: version, + Error: NewError(code), + }) +} + +type rpcRequest struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Id any `json:"id"` +} + +type rpcResponse struct { + Jsonrpc string `json:"jsonrpc"` + Result json.RawMessage `json:"result,omitempty"` + Error error `json:"error,omitempty"` + Id any `json:"id,omitempty"` +} diff --git a/wrapper.go b/wrapper.go new file mode 100644 index 0000000..8058a27 --- /dev/null +++ b/wrapper.go @@ -0,0 +1,25 @@ +package jsonrpc2 + +import ( + "context" + "encoding/json" +) + +func Wrap[RQ any, RS any](handler func(context.Context, *RQ) (RS, error)) Handler { + return func(ctx context.Context, in json.RawMessage) (json.RawMessage, error) { + req := new(RQ) + if err := json.Unmarshal(in, req); err != nil { + return nil, NewError(ErrCodeParseError) + } + resp, err := handler(ctx, req) + if err != nil { + return nil, Error{ + Code: ErrUser, + Message: err.Error(), + } + } + return json.Marshal(resp) + } +} + +type Handler func(context.Context, json.RawMessage) (json.RawMessage, error)