diff --git a/README.md b/README.md index 8819880..6aa66ca 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ import ( "context" "go.neonxp.dev/jsonrpc2/rpc" + "go.neonxp.dev/jsonrpc2/rpc/middleware" "go.neonxp.dev/jsonrpc2/transport" ) @@ -90,7 +91,7 @@ func main() { // Set options after constructor s.Use( rpc.WithTransport(&transport.TCP{Bind: ":3000"}), // TCP transport - rpc.WithMiddleware(rpc.LoggerMiddleware(rpc.StdLogger)), // Logger middleware + rpc.WithMiddleware(middleware.Logger(rpc.StdLogger)), // Logger middleware ) s.Register("multiply", rpc.H(Multiply)) diff --git a/example/main.go b/example/main.go index d5d034e..11e1fb0 100644 --- a/example/main.go +++ b/example/main.go @@ -7,7 +7,9 @@ import ( "os" "os/signal" + "github.com/qri-io/jsonschema" "go.neonxp.dev/jsonrpc2/rpc" + "go.neonxp.dev/jsonrpc2/rpc/middleware" "go.neonxp.dev/jsonrpc2/transport" ) @@ -17,9 +19,42 @@ func main() { rpc.WithTransport(&transport.HTTP{Bind: ":8000", CORSOrigin: "*"}), ) // Set options after constructor + validation, err := middleware.Validation(map[string]middleware.MethodSchema{ + "divide": { + Request: *jsonschema.Must(`{ + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer", + "not":{"const":0} + } + }, + "required": ["a", "b"] + }`), + Response: *jsonschema.Must(`{ + "type": "object", + "properties": { + "quo": { + "type": "integer" + }, + "rem": { + "type": "integer" + } + }, + "required": ["quo", "rem"] + }`), + }, + }) + if err != nil { + log.Fatal(err) + } s.Use( rpc.WithTransport(&transport.TCP{Bind: ":3000"}), - rpc.WithMiddleware(rpc.LoggerMiddleware(rpc.StdLogger)), + rpc.WithMiddleware(middleware.Logger(rpc.StdLogger)), + rpc.WithMiddleware(validation), ) s.Register("multiply", rpc.H(Multiply)) diff --git a/example/test.http b/example/test.http index e126917..9e71f9e 100644 --- a/example/test.http +++ b/example/test.http @@ -19,7 +19,7 @@ Content-Type: application/json "method": "divide", "params": { "a": 10, - "b": 3 + "b": 10 }, "id": 2 } @@ -44,3 +44,18 @@ Content-Type: application/json {"foo": "boo"} {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"} {"jsonrpc": "2.0", "method": "get_data", "id": "9"} + { + "jsonrpc": "2.0", + "method": "divide", + "params": { + "a": 10, + "b": 0 + }, + "id": "divide" + } + { + "jsonrpc": "2.0", + "method": "divide", + "params": {}, + "id": "divide" + } diff --git a/go.mod b/go.mod index f5af9b1..468a16f 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module go.neonxp.dev/jsonrpc2 go 1.18 -require golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 +require ( + github.com/qri-io/jsonschema v0.2.1 + golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 +) + +require github.com/qri-io/jsonpointer v0.1.1 // indirect diff --git a/go.sum b/go.sum index 7569778..a886b78 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,11 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJiBA= +github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64= +github.com/qri-io/jsonschema v0.2.1 h1:NNFoKms+kut6ABPf6xiKNM5214jzxAhDBrPHCJ97Wg0= +github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4= golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/rpc/contract.go b/rpc/contract.go new file mode 100644 index 0000000..aa1f194 --- /dev/null +++ b/rpc/contract.go @@ -0,0 +1,46 @@ +//Package rpc provides abstract rpc server +// +//Copyright (C) 2022 Alexander Kiryukhin +// +//This file is part of go.neonxp.dev/jsonrpc2 project. +// +//This program is free software: you can redistribute it and/or modify +//it under the terms of the GNU General Public License as published by +//the Free Software Foundation, either version 3 of the License, or +//(at your option) any later version. +// +//This program is distributed in the hope that it will be useful, +//but WITHOUT ANY WARRANTY; without even the implied warranty of +//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +//GNU General Public License for more details. +// +//You should have received a copy of the GNU General Public License +//along with this program. If not, see . + +package rpc + +import ( + "context" + "encoding/json" +) + +type RpcHandler func(ctx context.Context, req *RpcRequest) *RpcResponse + +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"` +} + +type Flusher interface { + // Flush sends any buffered data to the client. + Flush() +} diff --git a/rpc/errors.go b/rpc/errors.go index 71a7168..f6d2f49 100644 --- a/rpc/errors.go +++ b/rpc/errors.go @@ -31,12 +31,12 @@ const ( ) 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", + ErrCodeParseError: "Parse error", // Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. + ErrCodeInvalidRequest: "Invalid Request", // The JSON sent is not a valid Request object. + ErrCodeMethodNotFound: "Method not found", // The method does not exist / is not available. + ErrCodeInvalidParams: "Invalid params", // Invalid method parameter(s). + ErrCodeInternalError: "Internal error", // Internal JSON-RPC error. + ErrUser: "Other error", } //-32000 to -32099 RpcServer error Reserved for implementation-defined server-errors. diff --git a/rpc/middleware.go b/rpc/middleware.go index cd99823..3887109 100644 --- a/rpc/middleware.go +++ b/rpc/middleware.go @@ -19,25 +19,4 @@ package rpc -import ( - "context" - "strings" - "time" -) - type Middleware func(handler RpcHandler) RpcHandler - -type RpcHandler func(ctx context.Context, req *RpcRequest) *RpcResponse - -func LoggerMiddleware(logger Logger) Middleware { - return func(handler RpcHandler) RpcHandler { - return func(ctx context.Context, req *RpcRequest) *RpcResponse { - t1 := time.Now().UnixMicro() - resp := handler(ctx, req) - t2 := time.Now().UnixMicro() - args := strings.ReplaceAll(string(req.Params), "\n", "") - logger.Logf("rpc call=%s, args=%s, take=%dμs", req.Method, args, (t2 - t1)) - return resp - } - } -} diff --git a/rpc/middleware/logger.go b/rpc/middleware/logger.go new file mode 100644 index 0000000..dbf5a4d --- /dev/null +++ b/rpc/middleware/logger.go @@ -0,0 +1,41 @@ +//Package middleware provides middlewares for rpc server +// +//Copyright (C) 2022 Alexander Kiryukhin +// +//This file is part of go.neonxp.dev/jsonrpc2 project. +// +//This program is free software: you can redistribute it and/or modify +//it under the terms of the GNU General Public License as published by +//the Free Software Foundation, either version 3 of the License, or +//(at your option) any later version. +// +//This program is distributed in the hope that it will be useful, +//but WITHOUT ANY WARRANTY; without even the implied warranty of +//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +//GNU General Public License for more details. +// +//You should have received a copy of the GNU General Public License +//along with this program. If not, see . + +package middleware + +import ( + "context" + "strings" + "time" + + "go.neonxp.dev/jsonrpc2/rpc" +) + +func Logger(logger rpc.Logger) rpc.Middleware { + return func(handler rpc.RpcHandler) rpc.RpcHandler { + return func(ctx context.Context, req *rpc.RpcRequest) *rpc.RpcResponse { + t1 := time.Now().UnixMicro() + resp := handler(ctx, req) + t2 := time.Now().UnixMicro() + args := strings.ReplaceAll(string(req.Params), "\n", "") + logger.Logf("rpc call=%s, args=%s, take=%dμs", req.Method, args, (t2 - t1)) + return resp + } + } +} diff --git a/rpc/middleware/validation.go b/rpc/middleware/validation.go new file mode 100644 index 0000000..e994383 --- /dev/null +++ b/rpc/middleware/validation.go @@ -0,0 +1,72 @@ +//Package middleware provides middlewares for rpc server +// +//Copyright (C) 2022 Alexander Kiryukhin +// +//This file is part of go.neonxp.dev/jsonrpc2 project. +// +//This program is free software: you can redistribute it and/or modify +//it under the terms of the GNU General Public License as published by +//the Free Software Foundation, either version 3 of the License, or +//(at your option) any later version. +// +//This program is distributed in the hope that it will be useful, +//but WITHOUT ANY WARRANTY; without even the implied warranty of +//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +//GNU General Public License for more details. +// +//You should have received a copy of the GNU General Public License +//along with this program. If not, see . + +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/qri-io/jsonschema" + + "go.neonxp.dev/jsonrpc2/rpc" +) + +type MethodSchema struct { + Request jsonschema.Schema + Response jsonschema.Schema +} + +func Validation(serviceSchema map[string]MethodSchema) (rpc.Middleware, error) { + return func(handler rpc.RpcHandler) rpc.RpcHandler { + return func(ctx context.Context, req *rpc.RpcRequest) *rpc.RpcResponse { + if rs, ok := serviceSchema[strings.ToLower(req.Method)]; ok { + if errResp := formatError(ctx, req.Id, rs.Request, req.Params); errResp != nil { + return errResp + } + resp := handler(ctx, req) + if errResp := formatError(ctx, req.Id, rs.Response, resp.Result); errResp != nil { + return errResp + } + return resp + } + return handler(ctx, req) + } + }, nil +} + +func formatError(ctx context.Context, requestId any, schema jsonschema.Schema, data json.RawMessage) *rpc.RpcResponse { + errs, err := schema.ValidateBytes(ctx, data) + if err != nil { + return rpc.ErrorResponse(requestId, err) + } + if errs != nil && len(errs) > 0 { + messages := []string{} + for _, msg := range errs { + messages = append(messages, fmt.Sprintf("%s: %s", msg.PropertyPath, msg.Message)) + } + return rpc.ErrorResponse(requestId, rpc.Error{ + Code: rpc.ErrCodeInvalidParams, + Message: strings.Join(messages, "\n"), + }) + } + return nil +} diff --git a/rpc/options.go b/rpc/options.go index 825dbca..683df66 100644 --- a/rpc/options.go +++ b/rpc/options.go @@ -19,7 +19,9 @@ package rpc -import "go.neonxp.dev/jsonrpc2/transport" +import ( + "go.neonxp.dev/jsonrpc2/transport" +) type Option func(s *RpcServer) diff --git a/rpc/server.go b/rpc/server.go index f39bdaa..3c9410a 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "io" + "strings" "sync" "golang.org/x/sync/errgroup" @@ -34,7 +35,7 @@ const version = "2.0" type RpcServer struct { logger Logger - handlers map[string]Handler + handlers map[string]HandlerFunc middlewares []Middleware transports []transport.Transport mu sync.RWMutex @@ -43,7 +44,7 @@ type RpcServer struct { func New(opts ...Option) *RpcServer { s := &RpcServer{ logger: nopLogger{}, - handlers: map[string]Handler{}, + handlers: map[string]HandlerFunc{}, transports: []transport.Transport{}, mu: sync.RWMutex{}, } @@ -59,10 +60,11 @@ func (r *RpcServer) Use(opts ...Option) { } } -func (r *RpcServer) Register(method string, handler Handler) { +func (r *RpcServer) Register(method string, handler HandlerFunc) { r.mu.Lock() defer r.mu.Unlock() - r.handlers[method] = handler + r.logger.Logf("Register method %s", method) + r.handlers[strings.ToLower(method)] = handler } func (r *RpcServer) Run(ctx context.Context) error { @@ -99,7 +101,7 @@ func (r *RpcServer) Resolve(ctx context.Context, rd io.Reader, w io.Writer, para defer mu.Unlock() if err := enc.Encode(resp); err != nil { r.logger.Logf("Can't write response: %v", err) - WriteError(ErrCodeInternalError, enc) + enc.Encode(ErrorResponse(req.Id, ErrorFromCode(ErrCodeInternalError))) } if w, canFlush := w.(Flusher); canFlush { w.Flush() @@ -122,53 +124,32 @@ func (r *RpcServer) Resolve(ctx context.Context, rd io.Reader, w io.Writer, para func (r *RpcServer) callMethod(ctx context.Context, req *RpcRequest) *RpcResponse { r.mu.RLock() - h, ok := r.handlers[req.Method] + h, ok := r.handlers[strings.ToLower(req.Method)] r.mu.RUnlock() if !ok { - return &RpcResponse{ - Jsonrpc: version, - Error: ErrorFromCode(ErrCodeMethodNotFound), - Id: req.Id, - } + return ErrorResponse(req.Id, ErrorFromCode(ErrCodeMethodNotFound)) } 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 ErrorResponse(req.Id, err) } + + return ResultResponse(req.Id, resp) +} + +func ResultResponse(id any, resp json.RawMessage) *RpcResponse { return &RpcResponse{ Jsonrpc: version, Result: resp, - Id: req.Id, + Id: id, } } -func WriteError(code int, enc *json.Encoder) { - enc.Encode(RpcResponse{ +func ErrorResponse(id any, err error) *RpcResponse { + return &RpcResponse{ Jsonrpc: version, - Error: ErrorFromCode(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"` -} - -type Flusher interface { - // Flush sends any buffered data to the client. - Flush() + Error: err, + Id: id, + } } diff --git a/rpc/wrapper.go b/rpc/wrapper.go index 1d6361c..8aa9556 100644 --- a/rpc/wrapper.go +++ b/rpc/wrapper.go @@ -24,7 +24,7 @@ import ( "encoding/json" ) -func H[RQ any, RS any](handler func(context.Context, *RQ) (RS, error)) Handler { +func H[RQ any, RS any](handler func(context.Context, *RQ) (RS, error)) HandlerFunc { return func(ctx context.Context, in json.RawMessage) (json.RawMessage, error) { req := new(RQ) if err := json.Unmarshal(in, req); err != nil { @@ -41,4 +41,4 @@ func H[RQ any, RS any](handler func(context.Context, *RQ) (RS, error)) Handler { } } -type Handler func(context.Context, json.RawMessage) (json.RawMessage, error) +type HandlerFunc func(context.Context, json.RawMessage) (json.RawMessage, error)