diff --git a/.golangci_diff.yaml b/.golangci_diff.yaml index 95b0c920eb..6ffe048318 100644 --- a/.golangci_diff.yaml +++ b/.golangci_diff.yaml @@ -19,5 +19,8 @@ linters: - linters: - lll source: '^//go:generate |https?://' + - linters: + - lll + path: jsonrpc/(server|pretty_error)_test\.go run: timeout: 10m diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go new file mode 100644 index 0000000000..9bab48b088 --- /dev/null +++ b/jsonrpc/pretty_error.go @@ -0,0 +1,207 @@ +package jsonrpc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + "unicode/utf8" +) + +const ( + maxWindowSize = 512 + maxLineWidth = 80 + maxContextRows = 3 +) + +// windowBuffer keeps the last maxWindowSize bytes read +type windowBuffer struct { + window []byte + consumedBytes int + newlinesSeen int +} + +func (c *windowBuffer) Write(p []byte) (int, error) { + c.consumedBytes += len(p) + c.newlinesSeen += bytes.Count(p, []byte{'\n'}) + + if len(p) >= maxWindowSize { + c.window = append(c.window[:0], p[len(p)-maxWindowSize:]...) + return len(p), nil + } + + if overflow := len(c.window) + len(p) - maxWindowSize; overflow > 0 { + c.window = c.window[:copy(c.window, c.window[overflow:])] + } + c.window = append(c.window, p...) + + return len(p), nil +} + +func errorOffset(inputLength int, err error) (offset int, ok bool) { + var ( + syntaxErr *json.SyntaxError + typeErr *json.UnmarshalTypeError + ) + switch { + case errors.As(err, &syntaxErr): + return min(int(syntaxErr.Offset)-1, inputLength), true + case errors.As(err, &typeErr): + return min(int(typeErr.Offset)-1, inputLength), true + case errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): + return inputLength, true + default: + // TODO(granza): when we add SONIC, the errors will be already pretty. + return 0, false + } +} + +func lineAndColumn(c *windowBuffer, markerPos int) (line, col int) { + line = c.newlinesSeen - bytes.Count(c.window[markerPos:], []byte{'\n'}) + 1 + lineStart := bytes.LastIndexByte(c.window[:markerPos], '\n') + 1 + col = utf8.RuneCount(c.window[lineStart:markerPos]) + 1 + return line, col +} + +func expectedToken(reason string) (string, bool) { + // The stdlib reason uses parser jargon + // This maps it to user-friendly messages + switch { + case strings.Contains(reason, "beginning of object key string"): + return "a string key or '}'", true + case strings.Contains(reason, "after object key:value pair"): + return "',' or '}'", true + case strings.Contains(reason, "after object key"): + return "':'", true + case strings.Contains(reason, "after array element"): + return "',' or ']'", true + case strings.Contains(reason, "beginning of value"): + return "a value", true + default: + return "", false + } +} + +func precededByComma(input []byte, offset int) bool { + trimmed := bytes.TrimRight(input[:offset], " \t\r\n") + return len(trimmed) > 0 && trimmed[len(trimmed)-1] == ',' +} + +func describeTypeError(e *json.UnmarshalTypeError) string { + if e.Field != "" { + return fmt.Sprintf("field %q should be %s, got %s", e.Field, e.Type, e.Value) + } + return fmt.Sprintf("expected a JSON object, got %s", e.Value) +} + +func describeSyntaxError(input []byte, offset int, err error) string { + expected, ok := expectedToken(err.Error()) + if !ok || offset >= len(input) { + return err.Error() + } + symbol, _ := utf8.DecodeRune(input[offset:]) + if (symbol == '}' || symbol == ']') && precededByComma(input, offset) { + return fmt.Sprintf("unexpected trailing comma before %q", symbol) + } + return fmt.Sprintf("unexpected %q, expected %s", symbol, expected) +} + +func describeError(input []byte, offset int, err error) string { + var ( + syntaxErr *json.SyntaxError + typeErr *json.UnmarshalTypeError + ) + switch { + case errors.As(err, &syntaxErr): + return describeSyntaxError(input, offset, err) + case errors.As(err, &typeErr): + return describeTypeError(typeErr) + case errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): + return "unexpected end of input" + default: + return err.Error() + } +} + +func offendingLine(input []byte, offset int) string { + start := bytes.LastIndexByte(input[:offset], '\n') + 1 + if end := bytes.IndexByte(input[offset:], '\n'); end >= 0 { + return string(input[start : offset+end]) + } + return string(input[start:]) +} + +// Returns the truncated string around a pivot and the new index of it +func truncateAround(line string, pivot, maxLineWidth int) (string, int) { + runes := []rune(line) + if len(runes) <= maxLineWidth { + return line, pivot + } + + const ellipsis = "..." + maxContextSize := maxLineWidth - 2*len(ellipsis) + pivotIdx := min(pivot-1, len(runes)) + start := max(0, pivotIdx-maxContextSize/2) + end := min(start+maxContextSize, len(runes)) + start = max(0, end-maxContextSize) + + left, right := "", "" + if start > 0 { + left = ellipsis + } + if end < len(runes) { + right = ellipsis + } + return left + string(runes[start:end]) + right, pivotIdx - start + len(left) + 1 +} + +func precedingLines(window []byte, windowStart, markerPos int) []string { + lineStart := bytes.LastIndexByte(window[:markerPos], '\n') + 1 + + var rows []string + for len(rows) < maxContextRows && lineStart > 0 { + prevEnd := lineStart - 1 + prevStart := bytes.LastIndexByte(window[:prevEnd], '\n') + 1 + if prevStart == 0 && windowStart > 0 { + break // the topmost line was cut off by the window + } + + row, _ := truncateAround(string(window[prevStart:prevEnd]), 1, maxLineWidth) + rows = append(rows, row) + lineStart = prevStart + } + + slices.Reverse(rows) + return rows +} + +func drawMarker(window []byte, windowStart, markerPos, col int, msg string) string { + var fullMsg strings.Builder + for _, row := range precedingLines(window, windowStart, markerPos) { + fullMsg.WriteString(row) + fullMsg.WriteByte('\n') + } + + line, markerCol := truncateAround(offendingLine(window, markerPos), col, maxLineWidth) + gap := strings.Repeat(" ", markerCol-1) + + fmt.Fprintf(&fullMsg, "%s\n%s^\n%s", line, gap, msg) + return fullMsg.String() +} + +func prettyParseError(c *windowBuffer, err error) string { + absOffset, ok := errorOffset(c.consumedBytes, err) + if !ok { + return err.Error() + } + + windowStart := c.consumedBytes - len(c.window) + markerPos := max(0, absOffset-windowStart) + line, col := lineAndColumn(c, markerPos) + msg := fmt.Sprintf("%s [line %d, position %d]", describeError(c.window, markerPos, err), line, col) + + return drawMarker(c.window, windowStart, markerPos, col, msg) +} diff --git a/jsonrpc/pretty_error_test.go b/jsonrpc/pretty_error_test.go new file mode 100644 index 0000000000..af29888244 --- /dev/null +++ b/jsonrpc/pretty_error_test.go @@ -0,0 +1,216 @@ +package jsonrpc_test + +import ( + "strings" + "testing" + + "github.com/NethermindEth/juno/jsonrpc" + "github.com/NethermindEth/juno/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var parseErrorTests = map[string]struct { + req string + res string +}{ + "invalid json": { + req: `{]`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\nunexpected ']', expected a string key or '}' [line 1, position 2]"},"id":null}`, + }, + + "invalid json batch path": { + req: `[{]`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[{]\n ^\nunexpected ']', expected a string key or '}' [line 1, position 3]"},"id":null}`, + }, + + "missing closing brace": { + req: `{"jsonrpc": "2.0", "method": "method", "id": 1`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"jsonrpc\": \"2.0\", \"method\": \"method\", \"id\": 1\n ^\nunexpected end of input [line 1, position 47]"},"id":null}`, + }, + + "leading blank line keeps line number": { + req: "\n{]", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\n{]\n ^\nunexpected ']', expected a string key or '}' [line 2, position 2]"},"id":null}`, + }, + + "trailing comma in object": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_blockNumber", +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_blockNumber\",\n}\n^\nunexpected trailing comma before '}' [line 4, position 1]"},"id":null}`, + }, + + "trailing comma with whitespace": { + req: `{"jsonrpc": "2.0", "method": "starknet_blockNumber", }`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"jsonrpc\": \"2.0\", \"method\": \"starknet_blockNumber\", }\n ^\nunexpected trailing comma before '}' [line 1, position 54]"},"id":null}`, + }, + + "empty input": { + req: ``, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\n^\nunexpected end of input [line 1, position 1]"},"id":null}`, + }, + + "trailing comma in array": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_call", + "params": ["0x1", "0x2",], + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_call\",\n \"params\": [\"0x1\", \"0x2\",],\n ^\nunexpected trailing comma before ']' [line 4, position 27]"},"id":null}`, + }, + + "unexpected token expecting value": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_chainId", + "id": @ +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_chainId\",\n \"id\": @\n ^\nunexpected '@', expected a value [line 4, position 9]"},"id":null}`, + }, + + "missing comma between array elements": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_call", + "params": ["0x1" "0x2"], + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_call\",\n \"params\": [\"0x1\" \"0x2\"],\n ^\nunexpected '\"', expected ',' or ']' [line 4, position 20]"},"id":null}`, + }, + + "param type mismatch": { + req: `{"jsonrpc": 5, "method": "x", "id": 1}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"jsonrpc\": 5, \"method\": \"x\", \"id\": 1}\n ^\nfield \"jsonrpc\" should be string, got number [line 1, position 13]"},"id":null}`, + }, + + "long line is windowed": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_call", + "params": {"calldata": [` + strings.Repeat(`"0x1", `, 20) + `"0x2"] z}, + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_call\",\n... \"0x1\", \"0x1\", \"0x1\", \"0x1\", \"0x1\", \"0x1\", \"0x1\", \"0x1\", \"0x1\", \"0x2\"] z},\n ^\nunexpected 'z', expected ',' or '}' [line 4, position 174]"},"id":null}`, + }, + + "error at start of long line": { + req: `{@"jsonrpc": "2.0", "method": "starknet_estimateFee", "params": [` + strings.Repeat(`"0xdeadbeef", `, 20) + `"0x0"]}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@\"jsonrpc\": \"2.0\", \"method\": \"starknet_estimateFee\", \"params\": [\"0xdeadbe...\n ^\nunexpected '@', expected a string key or '}' [line 1, position 2]"},"id":null}`, + }, + + "top-level type mismatch": { + req: `5`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"5\n^\nexpected a JSON object, got number [line 1, position 1]"},"id":null}`, + }, + + "untranslatable syntax error": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_syncing", + "params": truX +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_syncing\",\n \"params\": truX\n ^\ninvalid character 'X' in literal true (expecting 'e') [line 4, position 16]"},"id":null}`, + }, + + "error on a middle line": { + req: "{\n\"a\" 1\n}", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n\"a\" 1\n ^\nunexpected '1', expected ':' [line 2, position 5]"},"id":null}`, + }, + + "multiline starknet request with missing comma": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_getStorageAt", + "params": ["0x4c5772d", "0x206f38f" "latest"], + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_getStorageAt\",\n \"params\": [\"0x4c5772d\", \"0x206f38f\" \"latest\"],\n ^\nunexpected '\"', expected ',' or ']' [line 4, position 39]"},"id":null}`, + }, + + "error past the captured window": { + req: "[\n" + strings.Repeat("\"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7\",\n", 40) + "\"0xbad\" \"0x1\"\n]", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7\",\n\"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7\",\n\"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7\",\n\"0xbad\" \"0x1\"\n ^\nunexpected '\"', expected ',' or ']' [line 42, position 9]"},"id":null}`, + }, + + "oversized single-line input keeps only the trailing window": { + req: `{"jsonrpc": "2.0", "method": "starknet_call", "params": [` + strings.Repeat(`"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", `, 10) + `"0xbad" @]}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7\", \"0xbad\" @]}\n ^\nunexpected '@', expected ',' or ']' [line 1, position 510]"},"id":null}`, + }, + + "context is capped at three lines within the window": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_call", + "params": { + "contract_address": "0x04c5772d", + "entry_point_selector": "0x0206f38f" + "calldata": [] + }, + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":" \"params\": {\n \"contract_address\": \"0x04c5772d\",\n \"entry_point_selector\": \"0x0206f38f\"\n \"calldata\": []\n ^\nunexpected '\"', expected ',' or '}' [line 7, position 5]"},"id":null}`, + }, + + "long line is windowed on both sides": { + req: `{"jsonrpc": "2.0", "method": "starknet_getStorageAt", "params": {"contract_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" @ "key": "0x02f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354"}, "id": 1}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...84644ddd6b96f7c741b1562b82f9e004dc7\" @ \"key\": \"0x02f0b3c5710379609eb5495f1...\n ^\nunexpected '@', expected ',' or '}' [line 1, position 155]"},"id":null}`, + }, + + "long preceding context line is windowed": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_call", + "params": ["0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", "0x02f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", "latest"] + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":" \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_call\",\n \"params\": [\"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e...\n \"id\": 1\n ^\nunexpected '\"', expected ',' or '}' [line 5, position 3]"},"id":null}`, + }, + + "oversized line drops all preceding context": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_estimateFee", + "params": {"request": [{"calldata": [` + strings.Repeat(`"0xdeadbeef", `, 40) + `"0x0"]}]} + "id": 1 +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":" \"id\": 1\n ^\nunexpected '\"', expected ',' or '}' [line 5, position 3]"},"id":null}`, + }, + + "column counts runes not bytes": { + req: `{ + "jsonrpc": "2.0", + "method": "starknet_call", + "👍": @ +}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\n \"jsonrpc\": \"2.0\",\n \"method\": \"starknet_call\",\n \"👍\": @\n ^\nunexpected '@', expected a value [line 4, position 8]"},"id":null}`, + }, + + "rpc call with invalid JSON": { + req: `{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"jsonrpc\": \"2.0\", \"method\": \"foobar, \"params\": \"bar\", \"baz]\n ^\nunexpected 'p', expected ',' or '}' [line 1, position 40]"},"id":null}`, + }, + + "rpc call Batch, invalid JSON:": { + req: `[ + {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, + {"jsonrpc": "2.0", "method" +]`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[\n {\"jsonrpc\": \"2.0\", \"method\": \"sum\", \"params\": [1,2,4], \"id\": \"1\"},\n {\"jsonrpc\": \"2.0\", \"method\"\n]\n^\nunexpected ']', expected ':' [line 4, position 1]"},"id":null}`, + }, +} + +func TestHandleParseError(t *testing.T) { + server := jsonrpc.NewServer(1, log.NewNopZapLogger()) + + for desc, test := range parseErrorTests { + t.Run(desc, func(t *testing.T) { + res, httpHeader, err := server.HandleReader(t.Context(), strings.NewReader(test.req)) + require.NoError(t, err) + assert.NotNil(t, httpHeader) + assert.JSONEq(t, test.res, string(res)) + }) + } +} diff --git a/jsonrpc/server.go b/jsonrpc/server.go index babe545cc4..6459d6af85 100644 --- a/jsonrpc/server.go +++ b/jsonrpc/server.go @@ -323,7 +323,8 @@ func (s *Server) HandleReadWriter(ctx context.Context, rw io.ReadWriter) error { // It returns the response in a byte array, only returns an // error if it can not create the response byte array func (s *Server) HandleReader(ctx context.Context, reader io.Reader) ([]byte, http.Header, error) { - bufferedReader := bufio.NewReaderSize(reader, bufferSize) + var errorRecoverBuffer windowBuffer + bufferedReader := bufio.NewReaderSize(io.TeeReader(reader, &errorRecoverBuffer), bufferSize) requestIsBatch := isBatch(bufferedReader) resp := &response{ Version: "2.0", @@ -337,7 +338,7 @@ func (s *Server) HandleReader(ctx context.Context, reader io.Reader) ([]byte, ht if !requestIsBatch { req := new(Request) if jsonErr := dec.Decode(req); jsonErr != nil { - resp.Error = Err(InvalidJSON, jsonErr.Error()) + resp.Error = Err(InvalidJSON, prettyParseError(&errorRecoverBuffer, jsonErr)) } else if resObject, httpHeader, handleErr := s.handleRequest(ctx, req); handleErr != nil { if !errors.Is(handleErr, ErrInvalidID) { resp.ID = req.ID @@ -352,7 +353,7 @@ func (s *Server) HandleReader(ctx context.Context, reader io.Reader) ([]byte, ht var batchReq []json.RawMessage if batchJSONErr := dec.Decode(&batchReq); batchJSONErr != nil { - resp.Error = Err(InvalidJSON, batchJSONErr.Error()) + resp.Error = Err(InvalidJSON, prettyParseError(&errorRecoverBuffer, batchJSONErr)) } else if len(batchReq) == 0 { resp.Error = Err(InvalidRequest, "empty batch") } else { @@ -449,20 +450,18 @@ func (s *Server) handleBatchRequest(ctx context.Context, batchReq []json.RawMess } func isBatch(reader *bufio.Reader) bool { - for { - char, err := reader.Peek(1) + for n := 1; ; n++ { + buf, err := reader.Peek(n) if err != nil { - break + return false } - if char[0] == ' ' || char[0] == '\t' || char[0] == '\r' || char[0] == '\n' { - if discarded, err := reader.Discard(1); discarded != 1 || err != nil { - break - } + switch buf[n-1] { + case ' ', '\t', '\r', '\n': continue + default: + return buf[n-1] == '[' } - return char[0] == '[' } - return false } func isNilOrEmpty(i any) (bool, error) { @@ -565,12 +564,12 @@ func (s *Server) buildArguments(ctx context.Context, params any, method Method) } if isNilOrEmpty { - allParamsAreOptional := utils.All(method.Params, func(p Parameter) bool { - return p.Optional - }) - - if len(method.Params) > 0 && !allParamsAreOptional { - return nil, errors.New("missing non-optional param field") + required := utils.Map( + utils.Filter(method.Params, func(p Parameter) bool { return !p.Optional }), + func(p Parameter) string { return p.Name }, + ) + if len(required) > 0 { + return nil, fmt.Errorf("missing required params: %s", strings.Join(required, ", ")) } for i := addContext; i < numArgs; i++ { @@ -587,7 +586,8 @@ func (s *Server) buildArguments(ctx context.Context, params any, method Method) // Ensure that the number of provided parameters is between required and total parameters if len(paramsList) < method.requiredParamCount || len(paramsList) > len(method.Params) { - return nil, errors.New("missing/unexpected params in list") + return nil, fmt.Errorf("expected between %d and %d params, got %d", + method.requiredParamCount, len(method.Params), len(paramsList)) } for i, param := range paramsList { diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index b7c6efaab0..cc6fb0815f 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -207,71 +207,76 @@ func TestHandle(t *testing.T) { checkNewRequestEvent bool checkFailedEvent bool }{ - "invalid json": { - req: `{]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string"},"id":null}`, - }, - "invalid json batch path": { - req: `[{]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string"},"id":null}`, - }, "wrong version": { req: `{"jsonrpc" : "1.0", "id" : 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"unsupported RPC request version"},"id":1}`, }, + "wrong version with null id": { req: `{"jsonrpc" : "1.0", "id" : null}`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"unsupported RPC request version"},"id":null}`, }, + "non existent method": { req: `{"jsonrpc" : "2.0", "method" : "doesnotexits" , "id" : 2}`, res: `{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method Not Found"},"id":2}`, }, + "no params": { req: `{"jsonrpc" : "2.0", "method" : "method", "id" : 5}`, - res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"missing non-optional param field"},"id":5}`, + res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"missing required params: num"},"id":5}`, }, + "too many params": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : [3, false, "error message", "too many"] , "id" : 3}`, - res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"missing/unexpected params in list"},"id":3}`, + res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"expected between 1 and 3 params, got 4"},"id":3}`, }, + "list params": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : [3, false, "error message"] , "id" : 3}`, res: `{"jsonrpc":"2.0","result":{"doubled":6},"id":3}`, }, + "list params, should soft error": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : [3, true, "error message"] , "id" : 4}`, res: `{"jsonrpc":"2.0","error":{"code":44,"message":"Expected Error","data":"error message"},"id":4}`, }, + "named params": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5, "shouldError" : false, "msg": "error message" } , "id" : 5}`, res: `{"jsonrpc":"2.0","result":{"doubled":10},"id":5}`, }, + "named params with defaults": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 } , "id" : 5}`, res: `{"jsonrpc":"2.0","result":{"doubled":10},"id":5}`, }, + "named params, should soft error": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5, "shouldError" : true } , "id" : 22}`, res: `{"jsonrpc":"2.0","error":{"code":44,"message":"Expected Error"},"id":22}`, }, + "missing nonoptional param": { req: " \r\t\n" + `{"jsonrpc" : "2.0", "method" : "method", "params" : { "shouldError" : true } , "id" : 22}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"missing non-optional param: num"},"id":22}`, }, + "empty batch": { req: `[]`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"empty batch"},"id":null}`, }, + "single request in batch": { req: " \r\t\n" + `[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 } , "id" : 5}]`, res: `[{"jsonrpc":"2.0","result":{"doubled":10},"id":5}]`, }, + "multiple requests in batch": { req: `[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 } , "id" : 5}, @@ -279,6 +284,7 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 } , "id" : 6}]`, res: `[{"jsonrpc":"2.0","result":{"doubled":10},"id":5},{"jsonrpc":"2.0","result":{"doubled":88},"id":6}]`, }, + "failing and successful requests mixed in a batch": { req: `[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 } , "id" : 5}, @@ -288,10 +294,12 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 } , "id" : 6}]`, res: `[{"jsonrpc":"2.0","result":{"doubled":10},"id":5},{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method Not Found"},"id":7},{"jsonrpc":"2.0","result":{"doubled":88},"id":6}]`, }, + "notification": { req: `{"jsonrpc" : "2.0", "method" : "method","params" : { "num" : 5, "shouldError" : false, "msg": "error message" }}`, res: ``, }, + "batch with notif and string id": { req: `[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 }}, @@ -301,6 +309,7 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 } , "id" : 6}]`, res: `[{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method Not Found"},"id":"7"},{"jsonrpc":"2.0","result":{"doubled":88},"id":6}]`, }, + "batch with all notifs": { req: `[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 }}, @@ -308,6 +317,7 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 }}]`, res: ``, }, + "nested batch": { req: `[[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 }}], @@ -315,12 +325,14 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 }}]]`, res: `[{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"json: cannot unmarshal array into Go value of type jsonrpc.Request"},"id":null},{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"json: cannot unmarshal array into Go value of type jsonrpc.Request"},"id":null}]`, }, + "no method": { req: `{ "jsonrpc" : "2.0" }`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"no method specified"},"id":null}`, }, + "number param": { req: ` { @@ -330,6 +342,7 @@ func TestHandle(t *testing.T) { }`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"params should be an array or an object"},"id":null}`, }, + "string param": { req: ` { @@ -339,6 +352,7 @@ func TestHandle(t *testing.T) { }`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"params should be an array or an object"},"id":null}`, }, + "array id": { req: ` { @@ -349,6 +363,7 @@ func TestHandle(t *testing.T) { }`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"id should be a string or an integer"},"id":null}`, }, + "map id": { req: ` { @@ -359,6 +374,7 @@ func TestHandle(t *testing.T) { }`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"id should be a string or an integer"},"id":null}`, }, + "float id": { req: ` { @@ -369,10 +385,12 @@ func TestHandle(t *testing.T) { }`, res: `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"id should be a string or an integer"},"id":null}`, }, + "wrong param type": { req: `{"jsonrpc" : "2.0", "method" : "method", "params" : ["3", false, "error message"] , "id" : 3}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"json: cannot unmarshal string into Go value of type int"},"id":3}`, }, + "multiple versions in batch": { req: `[{"jsonrpc" : "1.0", "method" : "method", "params" : { "num" : 5 } , "id" : 5}, @@ -380,128 +398,145 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 } , "id" : 6}]`, res: `[{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"unsupported RPC request version"},"id":5},{"jsonrpc":"2.0","result":{"doubled":88},"id":6}]`, }, + "invalid value in struct": { req: `{"jsonrpc" : "2.0", "method" : "validation", "params" : [ {"A": 0} ], "id" : 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"Key: 'validationStruct.A' Error:Field validation for 'A' failed on the 'min' tag"},"id":1}`, }, + "valid value in struct": { req: `{"jsonrpc" : "2.0", "method" : "validation", "params" : [{"A": 1}], "id" : 1}`, res: `{"jsonrpc":"2.0","result":1,"id":1}`, checkNewRequestEvent: true, }, + "invalid value in struct pointer": { req: `{"jsonrpc" : "2.0", "method" : "validationPointer", "params" : [ {"A": 0} ], "id" : 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"Key: 'validationStruct.A' Error:Field validation for 'A' failed on the 'min' tag"},"id":1}`, }, + "valid value in struct pointer": { req: `{"jsonrpc" : "2.0", "method" : "validationPointer", "params" : [ {"A": 1} ], "id" : 1}`, res: `{"jsonrpc":"2.0","result":1,"id":1}`, }, + "invalid value in slice struct": { req: `{"jsonrpc" : "2.0", "method" : "validationSlice", "params" : [ [{"A": 0}] ], "id" : 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"Key: 'validationStruct.A' Error:Field validation for 'A' failed on the 'min' tag"},"id":1}`, }, + "valid value in slice of struct": { req: `{"jsonrpc" : "2.0", "method" : "validationSlice", "params" : [[{"A": 1}]], "id" : 1}`, res: `{"jsonrpc":"2.0","result":1,"id":1}`, }, + "invalid value in map of pointer": { req: `{"jsonrpc" : "2.0", "method" : "validationMapPointer", "params" : [ { "notthexpectedkey" : {"A": 0}} ], "id" : 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"Key: 'validationStruct.A' Error:Field validation for 'A' failed on the 'min' tag"},"id":1}`, }, + "valid value in map of pointer": { req: `{"jsonrpc" : "2.0", "method" : "validationMapPointer", "params" : [ { "expectedkey" : {"A": 1}} ], "id" : 1}`, res: `{"jsonrpc":"2.0","result":1,"id":1}`, }, + "handler accepts context with array params": { req: `{"jsonrpc": "2.0", "method": "acceptsContext", "params": [], "id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "handler accepts context without params": { req: `{"jsonrpc": "2.0", "method": "acceptsContext","id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "handler accepts context and two params with array params": { req: `{"jsonrpc": "2.0", "method": "acceptsContextAndTwoParams", "params": [1, 3], "id": 1}`, res: `{"jsonrpc":"2.0","result":2,"id":1}`, }, + "handler accepts context with named params": { req: `{"jsonrpc": "2.0", "method": "acceptsContext", "params": {}, "id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "handler accepts context and two params with named params": { req: `{"jsonrpc": "2.0", "method": "acceptsContextAndTwoParams", "params": {"b": 3, "a": 1}, "id": 1}`, res: `{"jsonrpc":"2.0","result":2,"id":1}`, }, // spec tests + "rpc call with positional parameters 1": { req: `{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}`, res: `{"jsonrpc":"2.0","result":19,"id":1}`, }, + "rpc call with positional parameters 2": { req: `{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}`, res: `{"jsonrpc":"2.0","result":-19,"id":2}`, }, + "rpc call with named parameters 1": { req: `{"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}`, res: `{"jsonrpc":"2.0","result":19,"id":3}`, }, + "rpc call with named parameters 2": { req: `{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}`, res: `{"jsonrpc":"2.0","result":19,"id":4}`, }, + "notif 1": { req: `{"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}`, res: ``, }, + "notif 2": { req: `{"jsonrpc": "2.0", "method": "foobar"}`, res: ``, }, + "method not found": { req: `{"jsonrpc": "2.0", "method": "notfound", "id": "1"}`, res: `{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method Not Found"},"id":"1"}`, }, - "rpc call with invalid JSON": { - req: `{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character 'p' after object key:value pair"},"id":null}`, - }, - "rpc call Batch, invalid JSON:": { - req: `[ - {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, - {"jsonrpc": "2.0", "method" -]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' after object key"},"id":null}`, - }, + "rpc call with an invalid Batch (but not empty)": { req: `[1]`, res: `[{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"json: cannot unmarshal number into Go value of type jsonrpc.Request"},"id":null}]`, }, + "rpc call with invalid Batch": { req: `[1,2,3]`, res: `[{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"json: cannot unmarshal number into Go value of type jsonrpc.Request"},"id":null},{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"json: cannot unmarshal number into Go value of type jsonrpc.Request"},"id":null},{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"json: cannot unmarshal number into Go value of type jsonrpc.Request"},"id":null}]`, }, + "fails internally": { req: `{"jsonrpc": "2.0", "method": "errorsInternally", "params": {}, "id": 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error"},"id":1}`, checkFailedEvent: true, }, + "empty optional param": { req: `{"jsonrpc": "2.0", "method": "singleOptionalParam", "params": {}, "id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "null optional param": { req: `{"jsonrpc": "2.0", "method": "singleOptionalParam", "id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "empty multiple optional params": { req: `{"jsonrpc": "2.0", "method": "multipleOptionalParams", "params": {"param1": 1, "param2": [2, 3]}, "id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "empty multiple optional positional params": { req: `{"jsonrpc": "2.0", "method": "multipleOptionalParams", "params": [1, [2, 3]], "id": 1}`, res: `{"jsonrpc":"2.0","result":0,"id":1}`, }, + "junk + valid params": { req: `{"jsonrpc": "2.0", "method": "multipleOptionalParams", "params": {"param1": 1, "param2": [2, 3], "junk": "junk"}, "id": 1}`, res: `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid Params","data":"unexpected params: junk"},"id":1}`, @@ -624,6 +659,37 @@ func BenchmarkHandle(b *testing.B) { benchHandleR = header } +// BenchmarkHandleLargeRequest measures large bodies (up to the 10MB request +// limit), where the TeeReader copy scales with the input size. +func BenchmarkHandleLargeRequest(b *testing.B) { + server := jsonrpc.NewServer(1, log.NewNopZapLogger()).WithValidator(validator.New()) + require.NoError(b, server.RegisterMethods(jsonrpc.Method{ + Name: "echo", + Params: []jsonrpc.Parameter{{Name: "data"}}, + Handler: func(data string) (int, *jsonrpc.Error) { return len(data), nil }, + })) + + sizes := []struct { + name string + bytes int + }{ + {"1MB", 1 << 20}, + {"10MB", 10 << 20}, + } + for _, size := range sizes { + b.Run(size.name, func(b *testing.B) { + request := `{"jsonrpc":"2.0","method":"echo","params":["` + strings.Repeat("a", size.bytes) + `"],"id":1}` + b.SetBytes(int64(len(request))) + b.ResetTimer() + var err error + for b.Loop() { + _, _, err = server.HandleReader(b.Context(), strings.NewReader(request)) + require.NoError(b, err) + } + }) + } +} + func TestCannotWriteToConnInHandler(t *testing.T) { server := jsonrpc.NewServer(1, log.NewNopZapLogger()) require.NoError(t, server.RegisterMethods(jsonrpc.Method{