From 09229de947a8459b72fffc8f1e7387503617a9f8 Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:00:23 +0800 Subject: [PATCH] transport/http/jsonrpc: preserve numeric request IDs without rounding --- .../http/jsonrpc/request_id_precision_test.go | 159 ++++++++++++++++++ .../http/jsonrpc/request_response_types.go | 8 + 2 files changed, 167 insertions(+) create mode 100644 transport/http/jsonrpc/request_id_precision_test.go diff --git a/transport/http/jsonrpc/request_id_precision_test.go b/transport/http/jsonrpc/request_id_precision_test.go new file mode 100644 index 000000000..a4dd6cdb3 --- /dev/null +++ b/transport/http/jsonrpc/request_id_precision_test.go @@ -0,0 +1,159 @@ +package jsonrpc_test + +import ( + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/go-kit/kit/transport/http/jsonrpc" +) + +func TestRequestIDNumberPrecision(t *testing.T) { + for _, input := range []string{ + `0`, `-42`, `9223372036854775807`, `18446744073709551615`, + `-9223372036854775809`, `9007199254740993e0`, `1.234567890123456789`, + `1e100`, `1e-100`, + } { + t.Run(input, func(t *testing.T) { + var request jsonrpc.Request + if err := json.Unmarshal([]byte(`{"id":`+input+`}`), &request); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(jsonrpc.Response{ID: request.ID}) + if err != nil { + t.Fatal(err) + } + var response struct{ ID json.RawMessage } + if err := json.Unmarshal(encoded, &response); err != nil { + t.Fatal(err) + } + if string(response.ID) != input { + t.Errorf("response ID = %s, want %s", response.ID, input) + } + wantInt, intErr := strconv.Atoi(input) + gotInt, err := request.ID.Int() + if (err == nil) != (intErr == nil) || (err == nil && gotInt != wantInt) { + t.Errorf("Int() = %d, %v; want %d, error present %t", gotInt, err, wantInt, intErr != nil) + } + wantFloat, floatErr := strconv.ParseFloat(input, 32) + gotFloat, err := request.ID.Float32() + if (err == nil) != (floatErr == nil) || (err == nil && gotFloat != float32(wantFloat)) { + t.Errorf("Float32() = %g, %v; want %g, error present %t", gotFloat, err, wantFloat, floatErr != nil) + } + if _, err := request.ID.String(); err == nil { + t.Error("numeric ID unexpectedly accepted by String()") + } + }) + } +} + +func TestRequestIDNumberReuse(t *testing.T) { + var id jsonrpc.RequestID + for _, input := range []string{`18446744073709551615`, `"text"`, `1e100`, `0`, `"123"`} { + data := []byte(input) + if err := json.Unmarshal(data, &id); err != nil { + t.Fatal(err) + } + for i := range data { + data[i] = 'x' + } + encoded, err := json.Marshal(&id) + if err != nil { + t.Fatal(err) + } + if string(encoded) != input { + t.Errorf("reused ID = %s, want %s after input buffer changed", encoded, input) + } + } + encoded, err := json.Marshal(&jsonrpc.RequestID{}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != "0" { + t.Errorf("zero value ID = %s, want 0", encoded) + } +} + +func TestRequestIDNumberHTTPResponses(t *testing.T) { + for _, stage := range []string{"success", "decode", "endpoint", "encode", "missing"} { + t.Run(stage, func(t *testing.T) { + failure := errors.New("endpoint codec failed") + handler := jsonrpc.NewServer(jsonrpc.EndpointCodecMap{ + "echo": { + Decode: func(context.Context, json.RawMessage) (interface{}, error) { + if stage == "decode" { + return nil, failure + } + return "ok", nil + }, + Endpoint: func(_ context.Context, request interface{}) (interface{}, error) { + if stage == "endpoint" { + return nil, failure + } + return request, nil + }, + Encode: func(_ context.Context, response interface{}) (json.RawMessage, error) { + if stage == "encode" { + return nil, failure + } + return json.Marshal(response) + }, + }, + }) + server := httptest.NewServer(handler) + defer server.Close() + method := "echo" + if stage == "missing" { + method = "missing" + } + for _, id := range []string{`18446744073709551615`, `9007199254740993e0`, `1.234567890123456789`, `1e100`, `0`, `"123"`, `null`} { + t.Run(id, func(t *testing.T) { + body := strings.NewReader(`{"jsonrpc":"2.0","method":"` + method + `","params":[],"id":` + id + `}`) + response, err := http.Post(server.URL, jsonrpc.ContentType, body) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, err := ioutil.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("HTTP status = %d, body = %s", response.StatusCode, data) + } + var result struct { + JSONRPC string + ID json.RawMessage + Result json.RawMessage + Error *jsonrpc.Error + } + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + if result.JSONRPC != jsonrpc.Version || string(result.ID) != id { + t.Errorf("response = %s, want version 2.0 and ID %s", data, id) + } + if stage == "success" { + if result.Error != nil || string(result.Result) != `"ok"` { + t.Errorf("success response = %s", data) + } + } else { + wantCode := jsonrpc.InternalError + if stage == "missing" { + wantCode = jsonrpc.MethodNotFoundError + } + if result.Error == nil || result.Error.Code != wantCode || len(result.Result) != 0 { + t.Errorf("error response = %s, want code %d and no result", data, wantCode) + } + } + }) + } + }) + } +} diff --git a/transport/http/jsonrpc/request_response_types.go b/transport/http/jsonrpc/request_response_types.go index ffae404e5..142a19aed 100644 --- a/transport/http/jsonrpc/request_response_types.go +++ b/transport/http/jsonrpc/request_response_types.go @@ -22,6 +22,7 @@ type Request struct { // The value SHOULD normally not be Null and // Numbers SHOULD NOT contain fractional parts. type RequestID struct { + number json.Number intValue int intError error floatValue float32 @@ -40,11 +41,18 @@ func (id *RequestID) UnmarshalJSON(b []byte) error { id.intError = json.Unmarshal(b, &id.intValue) id.floatError = json.Unmarshal(b, &id.floatValue) id.stringError = json.Unmarshal(b, &id.stringValue) + id.number = "" + if id.stringError != nil { + _ = json.Unmarshal(b, &id.number) + } return nil } func (id *RequestID) MarshalJSON() ([]byte, error) { + if id.number != "" { + return json.Marshal(id.number) + } if id.intError == nil { return json.Marshal(id.intValue) } else if id.floatError == nil {