From 00a335ae8e322406cf05dccda4d56581ce6335f0 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Wed, 3 Jun 2026 23:30:48 -0300 Subject: [PATCH 01/16] feat(jsonrpc): clearer errors for malformed JSON and invalid params --- .golangci_diff.yaml | 3 +++ jsonrpc/pretty_error.go | 60 +++++++++++++++++++++++++++++++++++++++++ jsonrpc/server.go | 22 ++++++++------- jsonrpc/server_test.go | 16 ++++++----- 4 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 jsonrpc/pretty_error.go diff --git a/.golangci_diff.yaml b/.golangci_diff.yaml index 95b0c920eb..e7a3853102 100644 --- a/.golangci_diff.yaml +++ b/.golangci_diff.yaml @@ -19,5 +19,8 @@ linters: - linters: - lll source: '^//go:generate |https?://' + - linters: + - lll + path: _test\.go run: timeout: 10m diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go new file mode 100644 index 0000000000..016997ed41 --- /dev/null +++ b/jsonrpc/pretty_error.go @@ -0,0 +1,60 @@ +package jsonrpc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +const unexpectedEOFMsg = "unexpected end of input (missing closing '}' or ']'?)" + +func clamp(v, lo, hi int) int { + return min(max(v, lo), hi) +} + +func parseErrorOffset(input []byte, err error) (offset int, ok bool) { + var syntaxErr *json.SyntaxError + switch { + case errors.As(err, &syntaxErr): + return clamp(int(syntaxErr.Offset)-1, 0, len(input)), true + case errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): + return len(input), true + default: + // TODO(granza): add a case for SONIC's *decoder.SyntaxError (.Pos) when it lands. + return 0, false + } +} + +func lineAndColumn(input []byte, offset int) (line, col int) { + before := input[:offset] + line = bytes.Count(before, []byte{'\n'}) + 1 + col = offset - (bytes.LastIndexByte(before, '\n') + 1) + 1 + return line, col +} + +func errorMessage(err error) string { + if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { + return unexpectedEOFMsg + } + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + if strings.Contains(syntaxErr.Error(), "unexpected end of JSON input") { + return unexpectedEOFMsg + } + return syntaxErr.Error() + } + return err.Error() +} + +func prettyParseError(input []byte, err error) string { + offset, ok := parseErrorOffset(input, err) + if !ok { + return err.Error() + } + + line, col := lineAndColumn(input, offset) + return fmt.Sprintf("%s at line %d column %d", errorMessage(err), line, col) +} diff --git a/jsonrpc/server.go b/jsonrpc/server.go index babe545cc4..fdc21cfbed 100644 --- a/jsonrpc/server.go +++ b/jsonrpc/server.go @@ -331,13 +331,14 @@ func (s *Server) HandleReader(ctx context.Context, reader io.Reader) ([]byte, ht header := http.Header{} - dec := json.NewDecoder(bufferedReader) + var captured bytes.Buffer + dec := json.NewDecoder(io.TeeReader(bufferedReader, &captured)) dec.UseNumber() if !requestIsBatch { req := new(Request) if jsonErr := dec.Decode(req); jsonErr != nil { - resp.Error = Err(InvalidJSON, jsonErr.Error()) + resp.Error = Err(InvalidJSON, prettyParseError(captured.Bytes(), 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(captured.Bytes(), batchJSONErr)) } else if len(batchReq) == 0 { resp.Error = Err(InvalidRequest, "empty batch") } else { @@ -565,12 +566,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 +588,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..3cd7a271fc 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -209,11 +209,15 @@ func TestHandle(t *testing.T) { }{ "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}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string at line 1 column 2"},"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}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string at line 1 column 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":"unexpected end of input (missing closing '}' or ']'?) at line 1 column 47"},"id":null}`, }, "wrong version": { req: `{"jsonrpc" : "1.0", "id" : 1}`, @@ -229,11 +233,11 @@ func TestHandle(t *testing.T) { }, "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}`, @@ -464,14 +468,14 @@ func TestHandle(t *testing.T) { }, "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}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character 'p' after object key:value pair at line 1 column 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":"invalid character ']' after object key"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' after object key at line 4 column 1"},"id":null}`, }, "rpc call with an invalid Batch (but not empty)": { req: `[1]`, From c03938dfc5630beede09df9e4e0d4746ac0eace1 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 01:33:02 -0300 Subject: [PATCH 02/16] refactor(jsonrpc): accurate parse positions and benchmark --- jsonrpc/pretty_error.go | 12 +++--------- jsonrpc/server.go | 28 ++++++++++++++++------------ jsonrpc/server_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index 016997ed41..343ef2aae7 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" "io" - "strings" + "unicode/utf8" ) const unexpectedEOFMsg = "unexpected end of input (missing closing '}' or ']'?)" @@ -31,7 +31,8 @@ func parseErrorOffset(input []byte, err error) (offset int, ok bool) { func lineAndColumn(input []byte, offset int) (line, col int) { before := input[:offset] line = bytes.Count(before, []byte{'\n'}) + 1 - col = offset - (bytes.LastIndexByte(before, '\n') + 1) + 1 + lineStart := bytes.LastIndexByte(before, '\n') + 1 + col = utf8.RuneCount(before[lineStart:]) + 1 return line, col } @@ -39,13 +40,6 @@ func errorMessage(err error) string { if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { return unexpectedEOFMsg } - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { - if strings.Contains(syntaxErr.Error(), "unexpected end of JSON input") { - return unexpectedEOFMsg - } - return syntaxErr.Error() - } return err.Error() } diff --git a/jsonrpc/server.go b/jsonrpc/server.go index fdc21cfbed..53636b941c 100644 --- a/jsonrpc/server.go +++ b/jsonrpc/server.go @@ -323,7 +323,11 @@ 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) + // Tee the decoder's input into captured so a parse error can show its + // position. Every body is copied, not just bad ones, but the copy is small, + // capped by the upstream body-size limit, and only read on error. + var captured bytes.Buffer + bufferedReader := bufio.NewReaderSize(io.TeeReader(reader, &captured), bufferSize) requestIsBatch := isBatch(bufferedReader) resp := &response{ Version: "2.0", @@ -331,8 +335,7 @@ func (s *Server) HandleReader(ctx context.Context, reader io.Reader) ([]byte, ht header := http.Header{} - var captured bytes.Buffer - dec := json.NewDecoder(io.TeeReader(bufferedReader, &captured)) + dec := json.NewDecoder(bufferedReader) dec.UseNumber() if !requestIsBatch { @@ -449,21 +452,22 @@ func (s *Server) handleBatchRequest(ctx context.Context, batchReq []json.RawMess return result, finalHeaders, err // todo: fix batch request aggregate header } +// isBatch reports whether the first non-whitespace byte is '['. It only peeks +// and never consumes, so the decoder still sees the input from byte 0 and its +// error offsets stay aligned with the bytes captured in HandleReader. 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) { diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 3cd7a271fc..15252f3b96 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -219,6 +219,10 @@ func TestHandle(t *testing.T) { req: `{"jsonrpc": "2.0", "method": "method", "id": 1`, res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"unexpected end of input (missing closing '}' or ']'?) at line 1 column 47"},"id":null}`, }, + "leading blank line keeps line number": { + req: "\n{]", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string at line 2 column 2"},"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}`, @@ -628,6 +632,33 @@ func BenchmarkHandle(b *testing.B) { benchHandleR = header } +// BenchmarkHandleBatch covers the hot path on a multi-call batch, where the +// TeeReader in HandleReader copies the whole body on the success path. +func BenchmarkHandleBatch(b *testing.B) { + server := jsonrpc.NewServer(1, log.NewNopZapLogger()).WithValidator(validator.New()) + require.NoError(b, server.RegisterMethods(jsonrpc.Method{ + Name: "bench", + Handler: func() (int, *jsonrpc.Error) { return 0, nil }, + })) + + var sb strings.Builder + sb.WriteByte('[') + for i := range 20 { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteString(`{"jsonrpc":"2.0","id":1,"method":"bench"}`) + } + sb.WriteByte(']') + request := sb.String() + + 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{ From 779610bec39bcacb4eabcd3e6b55162f22107aad Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 01:35:41 -0300 Subject: [PATCH 03/16] docs(jsonrpc): drop redundant isBatch comment --- jsonrpc/server.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/jsonrpc/server.go b/jsonrpc/server.go index 53636b941c..288a88513a 100644 --- a/jsonrpc/server.go +++ b/jsonrpc/server.go @@ -452,9 +452,6 @@ func (s *Server) handleBatchRequest(ctx context.Context, batchReq []json.RawMess return result, finalHeaders, err // todo: fix batch request aggregate header } -// isBatch reports whether the first non-whitespace byte is '['. It only peeks -// and never consumes, so the decoder still sees the input from byte 0 and its -// error offsets stay aligned with the bytes captured in HandleReader. func isBatch(reader *bufio.Reader) bool { for n := 1; ; n++ { buf, err := reader.Peek(n) From 71849eabbc1642a2ea3c3fdce0563cb5e0e84f08 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 01:45:16 -0300 Subject: [PATCH 04/16] chore(jsonrpc): only skip lll on server_test.go --- .golangci_diff.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.golangci_diff.yaml b/.golangci_diff.yaml index e7a3853102..a02bade4a5 100644 --- a/.golangci_diff.yaml +++ b/.golangci_diff.yaml @@ -21,6 +21,6 @@ linters: source: '^//go:generate |https?://' - linters: - lll - path: _test\.go + path: jsonrpc/server_test\.go run: timeout: 10m From eb5b4fc79f4c83c0f7802f9d2461b83c7a3078ce Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 18:28:00 -0300 Subject: [PATCH 05/16] feat(jsonrpc): mark errors --- jsonrpc/pretty_error.go | 100 +++++++++++++++++++++++++++++++++++----- jsonrpc/server.go | 14 +++--- jsonrpc/server_test.go | 48 ++++++++++++++++--- 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index 343ef2aae7..17e180ef81 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -6,24 +6,24 @@ import ( "errors" "fmt" "io" + "strings" "unicode/utf8" ) -const unexpectedEOFMsg = "unexpected end of input (missing closing '}' or ']'?)" - -func clamp(v, lo, hi int) int { - return min(max(v, lo), hi) -} - func parseErrorOffset(input []byte, err error) (offset int, ok bool) { - var syntaxErr *json.SyntaxError + var ( + syntaxErr *json.SyntaxError + typeErr *json.UnmarshalTypeError + ) switch { case errors.As(err, &syntaxErr): - return clamp(int(syntaxErr.Offset)-1, 0, len(input)), true + return min(int(syntaxErr.Offset)-1, len(input)), true + case errors.As(err, &typeErr): + return min(int(typeErr.Offset)-1, len(input)), true case errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): return len(input), true default: - // TODO(granza): add a case for SONIC's *decoder.SyntaxError (.Pos) when it lands. + // TODO(granza): when we add SONIC, the errors will be already pretty. return 0, false } } @@ -36,13 +36,88 @@ func lineAndColumn(input []byte, offset int) (line, col int) { return line, col } -func errorMessage(err error) string { +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 describeError(input []byte, offset int, err error) string { + if offset < len(input) { + if expected, ok := expectedToken(err.Error()); ok { + 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) + } + } + if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { - return unexpectedEOFMsg + return "unexpected end of input" } + 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 int, maxLineWidth int) (string, int) { + runes := []rune(line) + if len(runes) <= maxLineWidth { + return line, pivot + } + + const ellipsis = "..." + maxSize := maxLineWidth - 2*len(ellipsis) + idx := min(pivot-1, len(runes)) + start := max(0, idx-maxSize/2) + end := min(start+maxSize, len(runes)) + start = max(0, end-maxSize) + + left, right := "", "" + if start > 0 { + left = ellipsis + } + if end < len(runes) { + right = ellipsis + } + return left + string(runes[start:end]) + right, idx - start + len(left) + 1 +} + +func drawMarker(input []byte, offset, col int, msg string) string { + const mazSizeOfMessage = 80 + + line, markerCol := truncateAround(offendingLine(input, offset), col, mazSizeOfMessage) + gap := strings.Repeat(" ", markerCol-1) + return fmt.Sprintf("%s\n%s^\n%s|_ %s", line, gap, gap, msg) +} + func prettyParseError(input []byte, err error) string { offset, ok := parseErrorOffset(input, err) if !ok { @@ -50,5 +125,6 @@ func prettyParseError(input []byte, err error) string { } line, col := lineAndColumn(input, offset) - return fmt.Sprintf("%s at line %d column %d", errorMessage(err), line, col) + msg := fmt.Sprintf("%s at line %d column %d", describeError(input, offset, err), line, col) + return drawMarker(input, offset, col, msg) } diff --git a/jsonrpc/server.go b/jsonrpc/server.go index 288a88513a..26ba996753 100644 --- a/jsonrpc/server.go +++ b/jsonrpc/server.go @@ -323,11 +323,11 @@ 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) { - // Tee the decoder's input into captured so a parse error can show its - // position. Every body is copied, not just bad ones, but the copy is small, - // capped by the upstream body-size limit, and only read on error. - var captured bytes.Buffer - bufferedReader := bufio.NewReaderSize(io.TeeReader(reader, &captured), bufferSize) + // We cannot avoid a copy right now. We don't know where a json error could be + // Once we hit one, we would need to rewind the reader, which streams do not allow + // TODO(granza): SONIC already returns the error position, so it doesn't need a copy. + var errorRecoverBuffer bytes.Buffer + bufferedReader := bufio.NewReaderSize(io.TeeReader(reader, &errorRecoverBuffer), bufferSize) requestIsBatch := isBatch(bufferedReader) resp := &response{ Version: "2.0", @@ -341,7 +341,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, prettyParseError(captured.Bytes(), jsonErr)) + resp.Error = Err(InvalidJSON, prettyParseError(errorRecoverBuffer.Bytes(), jsonErr)) } else if resObject, httpHeader, handleErr := s.handleRequest(ctx, req); handleErr != nil { if !errors.Is(handleErr, ErrInvalidID) { resp.ID = req.ID @@ -356,7 +356,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, prettyParseError(captured.Bytes(), batchJSONErr)) + resp.Error = Err(InvalidJSON, prettyParseError(errorRecoverBuffer.Bytes(), batchJSONErr)) } else if len(batchReq) == 0 { resp.Error = Err(InvalidRequest, "empty batch") } else { diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 15252f3b96..8525b94f37 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -209,19 +209,55 @@ func TestHandle(t *testing.T) { }{ "invalid json": { req: `{]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string at line 1 column 2"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\n |_ unexpected ']', expected a string key or '}' at line 1 column 2"},"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 at line 1 column 3"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[{]\n ^\n |_ unexpected ']', expected a string key or '}' at line 1 column 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":"unexpected end of input (missing closing '}' or ']'?) at line 1 column 47"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"jsonrpc\": \"2.0\", \"method\": \"method\", \"id\": 1\n ^\n |_ unexpected end of input at line 1 column 47"},"id":null}`, }, "leading blank line keeps line number": { req: "\n{]", - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"invalid character ']' looking for beginning of object key string at line 2 column 2"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\n |_ unexpected ']', expected a string key or '}' at line 2 column 2"},"id":null}`, + }, + "trailing comma in object": { + req: `{"a":1,}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1,}\n ^\n |_ unexpected trailing comma before '}' at line 1 column 8"},"id":null}`, + }, + "trailing comma with whitespace": { + req: `{"a":1, }`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1, }\n ^\n |_ unexpected trailing comma before '}' at line 1 column 9"},"id":null}`, + }, + "empty input": { + req: ``, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\n^\n|_ unexpected end of input at line 1 column 1"},"id":null}`, + }, + "trailing comma in array": { + req: `[1,2,]`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1,2,]\n ^\n |_ unexpected trailing comma before ']' at line 1 column 6"},"id":null}`, + }, + "unexpected token expecting value": { + req: `{"id":@}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"id\":@}\n ^\n |_ unexpected '@', expected a value at line 1 column 7"},"id":null}`, + }, + "missing comma between array elements": { + req: `[1 2]`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1 2]\n ^\n |_ unexpected '2', expected ',' or ']' at line 1 column 4"},"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 ^\n |_ json: cannot unmarshal number into Go struct field Request.jsonrpc of type string at line 1 column 13"},"id":null}`, + }, + "long line is windowed": { + req: `{"jsonrpc":"2.0","method":"` + strings.Repeat("x", 200) + `" z}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" z}\n ^\n |_ unexpected 'z', expected ',' or '}' at line 1 column 230"},"id":null}`, + }, + "error at start of long line": { + req: `{@` + strings.Repeat("x", 300) + `}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\n ^\n |_ unexpected '@', expected a string key or '}' at line 1 column 2"},"id":null}`, }, "wrong version": { req: `{"jsonrpc" : "1.0", "id" : 1}`, @@ -472,14 +508,14 @@ func TestHandle(t *testing.T) { }, "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 at line 1 column 40"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"jsonrpc\": \"2.0\", \"method\": \"foobar, \"params\": \"bar\", \"baz]\n ^\n |_ unexpected 'p', expected ',' or '}' at line 1 column 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":"invalid character ']' after object key at line 4 column 1"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"]\n^\n|_ unexpected ']', expected ':' at line 4 column 1"},"id":null}`, }, "rpc call with an invalid Batch (but not empty)": { req: `[1]`, From fd1a11a7a9ff64347606cf8b8848190b65fa3091 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 22:32:10 -0300 Subject: [PATCH 06/16] feat(jsonrpc): Pyhton like error --- jsonrpc/pretty_error.go | 50 ++++++++++++++++++++++++++++------------- jsonrpc/server_test.go | 30 ++++++++++++------------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index 17e180ef81..03f3f9f84c 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -60,22 +60,40 @@ func precededByComma(input []byte, offset int) bool { return len(trimmed) > 0 && trimmed[len(trimmed)-1] == ',' } -func describeError(input []byte, offset int, err error) string { - if offset < len(input) { - if expected, ok := expectedToken(err.Error()); ok { - 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 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 %s, got %s", e.Type, e.Value) +} - if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { - return "unexpected end of input" +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) +} - return err.Error() +func describeError(input []byte, offset int, err error) string { + var ( + typeErr *json.UnmarshalTypeError + syntaxErr *json.SyntaxError + ) + switch { + case errors.As(err, &typeErr): + return describeTypeError(typeErr) + case errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): + return "unexpected end of input" + case errors.As(err, &syntaxErr): + return describeSyntaxError(input, offset, err) + default: + return err.Error() + } } func offendingLine(input []byte, offset int) string { @@ -111,11 +129,11 @@ func truncateAround(line string, pivot int, maxLineWidth int) (string, int) { } func drawMarker(input []byte, offset, col int, msg string) string { - const mazSizeOfMessage = 80 + const maxLineWidth = 80 - line, markerCol := truncateAround(offendingLine(input, offset), col, mazSizeOfMessage) + line, markerCol := truncateAround(offendingLine(input, offset), col, maxLineWidth) gap := strings.Repeat(" ", markerCol-1) - return fmt.Sprintf("%s\n%s^\n%s|_ %s", line, gap, gap, msg) + return fmt.Sprintf("%s\n%s^\n%s", line, gap, msg) } func prettyParseError(input []byte, err error) string { @@ -125,6 +143,6 @@ func prettyParseError(input []byte, err error) string { } line, col := lineAndColumn(input, offset) - msg := fmt.Sprintf("%s at line %d column %d", describeError(input, offset, err), line, col) + msg := fmt.Sprintf("%s [line %d, column %d]", describeError(input, offset, err), line, col) return drawMarker(input, offset, col, msg) } diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 8525b94f37..60ced326e9 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -209,55 +209,55 @@ func TestHandle(t *testing.T) { }{ "invalid json": { req: `{]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\n |_ unexpected ']', expected a string key or '}' at line 1 column 2"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\nunexpected ']', expected a string key or '}' [line 1, column 2]"},"id":null}`, }, "invalid json batch path": { req: `[{]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[{]\n ^\n |_ unexpected ']', expected a string key or '}' at line 1 column 3"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[{]\n ^\nunexpected ']', expected a string key or '}' [line 1, column 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 ^\n |_ unexpected end of input at line 1 column 47"},"id":null}`, + 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, column 47]"},"id":null}`, }, "leading blank line keeps line number": { req: "\n{]", - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\n |_ unexpected ']', expected a string key or '}' at line 2 column 2"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\nunexpected ']', expected a string key or '}' [line 2, column 2]"},"id":null}`, }, "trailing comma in object": { req: `{"a":1,}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1,}\n ^\n |_ unexpected trailing comma before '}' at line 1 column 8"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1,}\n ^\nunexpected trailing comma before '}' [line 1, column 8]"},"id":null}`, }, "trailing comma with whitespace": { req: `{"a":1, }`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1, }\n ^\n |_ unexpected trailing comma before '}' at line 1 column 9"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1, }\n ^\nunexpected trailing comma before '}' [line 1, column 9]"},"id":null}`, }, "empty input": { req: ``, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\n^\n|_ unexpected end of input at line 1 column 1"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\n^\nunexpected end of input [line 1, column 1]"},"id":null}`, }, "trailing comma in array": { req: `[1,2,]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1,2,]\n ^\n |_ unexpected trailing comma before ']' at line 1 column 6"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1,2,]\n ^\nunexpected trailing comma before ']' [line 1, column 6]"},"id":null}`, }, "unexpected token expecting value": { req: `{"id":@}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"id\":@}\n ^\n |_ unexpected '@', expected a value at line 1 column 7"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"id\":@}\n ^\nunexpected '@', expected a value [line 1, column 7]"},"id":null}`, }, "missing comma between array elements": { req: `[1 2]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1 2]\n ^\n |_ unexpected '2', expected ',' or ']' at line 1 column 4"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1 2]\n ^\nunexpected '2', expected ',' or ']' [line 1, column 4]"},"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 ^\n |_ json: cannot unmarshal number into Go struct field Request.jsonrpc of type string at line 1 column 13"},"id":null}`, + 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, column 13]"},"id":null}`, }, "long line is windowed": { req: `{"jsonrpc":"2.0","method":"` + strings.Repeat("x", 200) + `" z}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" z}\n ^\n |_ unexpected 'z', expected ',' or '}' at line 1 column 230"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" z}\n ^\nunexpected 'z', expected ',' or '}' [line 1, column 230]"},"id":null}`, }, "error at start of long line": { req: `{@` + strings.Repeat("x", 300) + `}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\n ^\n |_ unexpected '@', expected a string key or '}' at line 1 column 2"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\n ^\nunexpected '@', expected a string key or '}' [line 1, column 2]"},"id":null}`, }, "wrong version": { req: `{"jsonrpc" : "1.0", "id" : 1}`, @@ -508,14 +508,14 @@ func TestHandle(t *testing.T) { }, "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 ^\n |_ unexpected 'p', expected ',' or '}' at line 1 column 40"},"id":null}`, + 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, column 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^\n|_ unexpected ']', expected ':' at line 4 column 1"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"]\n^\nunexpected ']', expected ':' [line 4, column 1]"},"id":null}`, }, "rpc call with an invalid Batch (but not empty)": { req: `[1]`, From 0f0263611ab92497f0417a184af988ef94311b41 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 22:54:11 -0300 Subject: [PATCH 07/16] style(jsonrpc): address lint suggestion --- jsonrpc/pretty_error.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index 03f3f9f84c..3aece7ae3a 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -105,7 +105,7 @@ func offendingLine(input []byte, offset int) string { } // Returns the truncated string around a pivot and the new index of it -func truncateAround(line string, pivot int, maxLineWidth int) (string, int) { +func truncateAround(line string, pivot, maxLineWidth int) (string, int) { runes := []rune(line) if len(runes) <= maxLineWidth { return line, pivot From 5f8931fd4eeb540d7f5715ca08368d849d4e339e Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 23:03:03 -0300 Subject: [PATCH 08/16] test(jsonrpc): Extra tests --- jsonrpc/server_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 60ced326e9..5ee8db8765 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -259,6 +259,18 @@ func TestHandle(t *testing.T) { req: `{@` + strings.Repeat("x", 300) + `}`, res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\n ^\nunexpected '@', expected a string key or '}' [line 1, column 2]"},"id":null}`, }, + "top-level type mismatch": { + req: `5`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"5\n^\nexpected jsonrpc.Request, got number [line 1, column 1]"},"id":null}`, + }, + "untranslatable syntax error": { + req: `{"a":truX}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":truX}\n ^\ninvalid character 'X' in literal true (expecting 'e') [line 1, column 9]"},"id":null}`, + }, + "error on a middle line": { + req: "{\n\"a\" 1\n}", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\"a\" 1\n ^\nunexpected '1', expected ':' [line 2, column 5]"},"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}`, From 4f4b8170403d8dd84c022f65a113e74becdeec53 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Thu, 4 Jun 2026 23:22:16 -0300 Subject: [PATCH 09/16] refactor(jsonrpc): polish parse error messages --- jsonrpc/pretty_error.go | 2 +- jsonrpc/server_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index 3aece7ae3a..e31b4426ca 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -64,7 +64,7 @@ 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 %s, got %s", e.Type, e.Value) + return fmt.Sprintf("expected a JSON object, got %s", e.Value) } func describeSyntaxError(input []byte, offset int, err error) string { diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 5ee8db8765..83f1710879 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -261,7 +261,7 @@ func TestHandle(t *testing.T) { }, "top-level type mismatch": { req: `5`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"5\n^\nexpected jsonrpc.Request, got number [line 1, column 1]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"5\n^\nexpected a JSON object, got number [line 1, column 1]"},"id":null}`, }, "untranslatable syntax error": { req: `{"a":truX}`, From 03affb8af3a3254066225a5ca1d9c2d928ded73c Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Fri, 5 Jun 2026 14:48:53 -0300 Subject: [PATCH 10/16] feat(jsonrpc): horizontal trim and sliding-window error buffer --- jsonrpc/pretty_error.go | 108 +++++++++++++++++++++++++++++++--------- jsonrpc/server.go | 9 ++-- jsonrpc/server_test.go | 91 +++++++++++++++++++-------------- 3 files changed, 141 insertions(+), 67 deletions(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index e31b4426ca..e3b9b67371 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -6,33 +6,64 @@ import ( "errors" "fmt" "io" + "slices" "strings" "unicode/utf8" ) -func parseErrorOffset(input []byte, err error) (offset int, ok bool) { +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 + } + + // temporarily increases in size + c.window = append(c.window, p...) + if len(c.window) > maxWindowSize { + c.window = c.window[:copy(c.window, c.window[len(c.window)-maxWindowSize:])] + } + + 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, len(input)), true + return min(int(syntaxErr.Offset)-1, inputLength), true case errors.As(err, &typeErr): - return min(int(typeErr.Offset)-1, len(input)), true + return min(int(typeErr.Offset)-1, inputLength), true case errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): - return len(input), true + return inputLength, true default: // TODO(granza): when we add SONIC, the errors will be already pretty. return 0, false } } -func lineAndColumn(input []byte, offset int) (line, col int) { - before := input[:offset] - line = bytes.Count(before, []byte{'\n'}) + 1 - lineStart := bytes.LastIndexByte(before, '\n') + 1 - col = utf8.RuneCount(before[lineStart:]) + 1 +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 } @@ -112,11 +143,11 @@ func truncateAround(line string, pivot, maxLineWidth int) (string, int) { } const ellipsis = "..." - maxSize := maxLineWidth - 2*len(ellipsis) - idx := min(pivot-1, len(runes)) - start := max(0, idx-maxSize/2) - end := min(start+maxSize, len(runes)) - start = max(0, end-maxSize) + 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 { @@ -125,24 +156,53 @@ func truncateAround(line string, pivot, maxLineWidth int) (string, int) { if end < len(runes) { right = ellipsis } - return left + string(runes[start:end]) + right, idx - start + len(left) + 1 + 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(input []byte, offset, col int, msg string) string { - const maxLineWidth = 80 +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(input, offset), col, maxLineWidth) + line, markerCol := truncateAround(offendingLine(window, markerPos), col, maxLineWidth) gap := strings.Repeat(" ", markerCol-1) - return fmt.Sprintf("%s\n%s^\n%s", line, gap, msg) + + fmt.Fprintf(&fullMsg, "%s\n%s^\n%s", line, gap, msg) + return fullMsg.String() } -func prettyParseError(input []byte, err error) string { - offset, ok := parseErrorOffset(input, err) +func prettyParseError(c *windowBuffer, err error) string { + absOffset, ok := errorOffset(c.consumedBytes, err) if !ok { return err.Error() } - line, col := lineAndColumn(input, offset) - msg := fmt.Sprintf("%s [line %d, column %d]", describeError(input, offset, err), line, col) - return drawMarker(input, offset, col, msg) + 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/server.go b/jsonrpc/server.go index 26ba996753..6459d6af85 100644 --- a/jsonrpc/server.go +++ b/jsonrpc/server.go @@ -323,10 +323,7 @@ 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) { - // We cannot avoid a copy right now. We don't know where a json error could be - // Once we hit one, we would need to rewind the reader, which streams do not allow - // TODO(granza): SONIC already returns the error position, so it doesn't need a copy. - var errorRecoverBuffer bytes.Buffer + var errorRecoverBuffer windowBuffer bufferedReader := bufio.NewReaderSize(io.TeeReader(reader, &errorRecoverBuffer), bufferSize) requestIsBatch := isBatch(bufferedReader) resp := &response{ @@ -341,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, prettyParseError(errorRecoverBuffer.Bytes(), jsonErr)) + 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 @@ -356,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, prettyParseError(errorRecoverBuffer.Bytes(), batchJSONErr)) + resp.Error = Err(InvalidJSON, prettyParseError(&errorRecoverBuffer, batchJSONErr)) } else if len(batchReq) == 0 { resp.Error = Err(InvalidRequest, "empty batch") } else { diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 83f1710879..8e14b3a899 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -209,67 +209,80 @@ func TestHandle(t *testing.T) { }{ "invalid json": { req: `{]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{]\n ^\nunexpected ']', expected a string key or '}' [line 1, column 2]"},"id":null}`, + 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, column 3]"},"id":null}`, + 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, column 47]"},"id":null}`, + 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 ^\nunexpected ']', expected a string key or '}' [line 2, column 2]"},"id":null}`, + 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: `{"a":1,}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1,}\n ^\nunexpected trailing comma before '}' [line 1, column 8]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1,}\n ^\nunexpected trailing comma before '}' [line 1, position 8]"},"id":null}`, }, "trailing comma with whitespace": { req: `{"a":1, }`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1, }\n ^\nunexpected trailing comma before '}' [line 1, column 9]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1, }\n ^\nunexpected trailing comma before '}' [line 1, position 9]"},"id":null}`, }, "empty input": { req: ``, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\n^\nunexpected end of input [line 1, column 1]"},"id":null}`, + 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: `[1,2,]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1,2,]\n ^\nunexpected trailing comma before ']' [line 1, column 6]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1,2,]\n ^\nunexpected trailing comma before ']' [line 1, position 6]"},"id":null}`, }, "unexpected token expecting value": { req: `{"id":@}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"id\":@}\n ^\nunexpected '@', expected a value [line 1, column 7]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"id\":@}\n ^\nunexpected '@', expected a value [line 1, position 7]"},"id":null}`, }, "missing comma between array elements": { req: `[1 2]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1 2]\n ^\nunexpected '2', expected ',' or ']' [line 1, column 4]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1 2]\n ^\nunexpected '2', expected ',' or ']' [line 1, position 4]"},"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, column 13]"},"id":null}`, + 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":"` + strings.Repeat("x", 200) + `" z}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" z}\n ^\nunexpected 'z', expected ',' or '}' [line 1, column 230]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" z}\n ^\nunexpected 'z', expected ',' or '}' [line 1, position 230]"},"id":null}`, }, "error at start of long line": { req: `{@` + strings.Repeat("x", 300) + `}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\n ^\nunexpected '@', expected a string key or '}' [line 1, column 2]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\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, column 1]"},"id":null}`, + 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: `{"a":truX}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":truX}\n ^\ninvalid character 'X' in literal true (expecting 'e') [line 1, column 9]"},"id":null}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":truX}\n ^\ninvalid character 'X' in literal true (expecting 'e') [line 1, position 9]"},"id":null}`, }, "error on a middle line": { req: "{\n\"a\" 1\n}", - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"\"a\" 1\n ^\nunexpected '1', expected ':' [line 2, column 5]"},"id":null}`, + 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("1,\n", 200) + "2 3\n]", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"1,\n1,\n1,\n2 3\n ^\nunexpected '3', expected ',' or ']' [line 202, position 3]"},"id":null}`, }, "wrong version": { req: `{"jsonrpc" : "1.0", "id" : 1}`, @@ -520,14 +533,14 @@ func TestHandle(t *testing.T) { }, "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, column 40]"},"id":null}`, + 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^\nunexpected ']', expected ':' [line 4, column 1]"},"id":null}`, + 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}`, }, "rpc call with an invalid Batch (but not empty)": { req: `[1]`, @@ -680,30 +693,34 @@ func BenchmarkHandle(b *testing.B) { benchHandleR = header } -// BenchmarkHandleBatch covers the hot path on a multi-call batch, where the -// TeeReader in HandleReader copies the whole body on the success path. -func BenchmarkHandleBatch(b *testing.B) { +// 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: "bench", - Handler: func() (int, *jsonrpc.Error) { return 0, nil }, + Name: "echo", + Params: []jsonrpc.Parameter{{Name: "data"}}, + Handler: func(data string) (int, *jsonrpc.Error) { return len(data), nil }, })) - var sb strings.Builder - sb.WriteByte('[') - for i := range 20 { - if i > 0 { - sb.WriteByte(',') - } - sb.WriteString(`{"jsonrpc":"2.0","id":1,"method":"bench"}`) + sizes := []struct { + name string + bytes int + }{ + {"1MB", 1 << 20}, + {"10MB", 10 << 20}, } - sb.WriteByte(']') - request := sb.String() - - var err error - for b.Loop() { - _, _, err = server.HandleReader(b.Context(), strings.NewReader(request)) - require.NoError(b, err) + 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) + } + }) } } From 8d424c38083925c75af2eb97fedefed63b85fda0 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Sat, 6 Jun 2026 00:28:03 -0300 Subject: [PATCH 11/16] test(jsonrpc): Add extra tests --- jsonrpc/server_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 8e14b3a899..42758e9916 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -284,6 +284,14 @@ func TestHandle(t *testing.T) { req: "[\n" + strings.Repeat("1,\n", 200) + "2 3\n]", res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"1,\n1,\n1,\n2 3\n ^\nunexpected '3', expected ',' or ']' [line 202, position 3]"},"id":null}`, }, + "context is capped at three lines within the window": { + req: "[\n1,\n2,\n3,\n4,\n5,\n6 7\n]", + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"3,\n4,\n5,\n6 7\n ^\nunexpected '7', expected ',' or ']' [line 7, position 3]"},"id":null}`, + }, + "column counts runes not bytes": { + req: `{"é":@}`, + res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"é\":@}\n ^\nunexpected '@', expected a value [line 1, position 6]"},"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}`, From 9794edffa2f6e7ea848adaef9c4df28af2841890 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Mon, 8 Jun 2026 09:10:52 -0300 Subject: [PATCH 12/16] test(jsonrpc): realistic JSON in parse errors --- jsonrpc/server_test.go | 105 ++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 42758e9916..8a03490e5a 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -224,48 +224,74 @@ func TestHandle(t *testing.T) { 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: `{"a":1,}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1,}\n ^\nunexpected trailing comma before '}' [line 1, position 8]"},"id":null}`, + 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: `{"a":1, }`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":1, }\n ^\nunexpected trailing comma before '}' [line 1, position 9]"},"id":null}`, + 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: `[1,2,]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1,2,]\n ^\nunexpected trailing comma before ']' [line 1, position 6]"},"id":null}`, + 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: `{"id":@}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"id\":@}\n ^\nunexpected '@', expected a value [line 1, position 7]"},"id":null}`, + 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: `[1 2]`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"[1 2]\n ^\nunexpected '2', expected ',' or ']' [line 1, position 4]"},"id":null}`, + 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":"` + strings.Repeat("x", 200) + `" z}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" z}\n ^\nunexpected 'z', expected ',' or '}' [line 1, position 230]"},"id":null}`, + 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: `{@` + strings.Repeat("x", 300) + `}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\n ^\nunexpected '@', expected a string key or '}' [line 1, position 2]"},"id":null}`, + 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: `{"a":truX}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"a\":truX}\n ^\ninvalid character 'X' in literal true (expecting 'e') [line 1, position 9]"},"id":null}`, + 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}", @@ -281,16 +307,51 @@ func TestHandle(t *testing.T) { 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("1,\n", 200) + "2 3\n]", - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"1,\n1,\n1,\n2 3\n ^\nunexpected '3', expected ',' or ']' [line 202, position 3]"},"id":null}`, + 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}`, }, "context is capped at three lines within the window": { - req: "[\n1,\n2,\n3,\n4,\n5,\n6 7\n]", - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"3,\n4,\n5,\n6 7\n ^\nunexpected '7', expected ',' or ']' [line 7, position 3]"},"id":null}`, + 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: `{"é":@}`, - res: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":"{\"é\":@}\n ^\nunexpected '@', expected a value [line 1, position 6]"},"id":null}`, + 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}`, }, "wrong version": { req: `{"jsonrpc" : "1.0", "id" : 1}`, From ad165d0c17e63c5b3ad3829461b2fa1b9e125242 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Mon, 8 Jun 2026 10:55:39 -0300 Subject: [PATCH 13/16] test: Address requests --- jsonrpc/server_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jsonrpc/server_test.go b/jsonrpc/server_test.go index 8a03490e5a..0e44e60f07 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -310,6 +310,10 @@ func TestHandle(t *testing.T) { 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", From c48abb45b09e2f85105432b3c1e7486964bca1ca Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Mon, 8 Jun 2026 15:25:41 -0300 Subject: [PATCH 14/16] refactor(jsonrpc): tidy windowBuffer write --- jsonrpc/pretty_error.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/jsonrpc/pretty_error.go b/jsonrpc/pretty_error.go index e3b9b67371..9bab48b088 100644 --- a/jsonrpc/pretty_error.go +++ b/jsonrpc/pretty_error.go @@ -33,11 +33,10 @@ func (c *windowBuffer) Write(p []byte) (int, error) { return len(p), nil } - // temporarily increases in size - c.window = append(c.window, p...) - if len(c.window) > maxWindowSize { - c.window = c.window[:copy(c.window, c.window[len(c.window)-maxWindowSize:])] + 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 } @@ -112,16 +111,16 @@ func describeSyntaxError(input []byte, offset int, err error) string { func describeError(input []byte, offset int, err error) string { var ( - typeErr *json.UnmarshalTypeError 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" - case errors.As(err, &syntaxErr): - return describeSyntaxError(input, offset, err) default: return err.Error() } From a903f345661436abb92640e9abe52e03838ed56f Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Mon, 8 Jun 2026 15:25:41 -0300 Subject: [PATCH 15/16] test(jsonrpc): split out parse error tests --- jsonrpc/pretty_error_test.go | 216 +++++++++++++++++++++++++++++++++++ jsonrpc/server_test.go | 215 +++++++++------------------------- 2 files changed, 270 insertions(+), 161 deletions(-) create mode 100644 jsonrpc/pretty_error_test.go 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_test.go b/jsonrpc/server_test.go index 0e44e60f07..cc6fb0815f 100644 --- a/jsonrpc/server_test.go +++ b/jsonrpc/server_test.go @@ -207,213 +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":"{]\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}`, - }, "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 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":"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}, @@ -421,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}, @@ -430,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 }}, @@ -443,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 }}, @@ -450,6 +317,7 @@ func TestHandle(t *testing.T) { "params" : { "num" : 44 }}]`, res: ``, }, + "nested batch": { req: `[[{"jsonrpc" : "2.0", "method" : "method", "params" : { "num" : 5 }}], @@ -457,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: ` { @@ -472,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: ` { @@ -481,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: ` { @@ -491,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: ` { @@ -501,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: ` { @@ -511,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}, @@ -522,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":"{\"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}`, - }, + "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}`, From 59c0b0b06e24490b13df5ef8e861830c4b1be808 Mon Sep 17 00:00:00 2001 From: Rafael Granza Date: Mon, 8 Jun 2026 16:03:16 -0300 Subject: [PATCH 16/16] ci: extend lll exemption to pretty_error_test --- .golangci_diff.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.golangci_diff.yaml b/.golangci_diff.yaml index a02bade4a5..6ffe048318 100644 --- a/.golangci_diff.yaml +++ b/.golangci_diff.yaml @@ -21,6 +21,6 @@ linters: source: '^//go:generate |https?://' - linters: - lll - path: jsonrpc/server_test\.go + path: jsonrpc/(server|pretty_error)_test\.go run: timeout: 10m