From d150248c52a081bc94c93b47376d36e60f29cfe7 Mon Sep 17 00:00:00 2001 From: Kevin Wan Date: Fri, 1 Aug 2025 22:05:13 +0800 Subject: [PATCH] chore: refactor jsonx.Marshal (#5045) Signed-off-by: Kevin Wan --- core/jsonx/json.go | 20 ++++++- core/jsonx/json_test.go | 103 +++++++++++++++++++++++++++++++++++ rest/httpx/responses.go | 25 +-------- rest/httpx/responses_test.go | 102 ---------------------------------- 4 files changed, 123 insertions(+), 127 deletions(-) diff --git a/core/jsonx/json.go b/core/jsonx/json.go index 1b522e55a..61b4a4193 100644 --- a/core/jsonx/json.go +++ b/core/jsonx/json.go @@ -8,9 +8,25 @@ import ( "strings" ) -// Marshal marshals v into json bytes. +// Marshal marshals v into json bytes, without escaping HTML and removes the trailing newline. func Marshal(v any) ([]byte, error) { - return json.Marshal(v) + // why not use json.Marshal? https://github.com/golang/go/issues/28453 + // it changes the behavior of json.Marshal, like & -> \u0026, < -> \u003c, > -> \u003e + // which is not what we want in API responses + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + + bs := buf.Bytes() + // Remove trailing newline added by json.Encoder.Encode + if len(bs) > 0 && bs[len(bs)-1] == '\n' { + bs = bs[:len(bs)-1] + } + + return bs, nil } // MarshalToString marshals v into a string. diff --git a/core/jsonx/json_test.go b/core/jsonx/json_test.go index 016acf207..27e22b3ab 100644 --- a/core/jsonx/json_test.go +++ b/core/jsonx/json_test.go @@ -1,6 +1,7 @@ package jsonx import ( + "fmt" "strings" "testing" @@ -101,3 +102,105 @@ func TestUnmarshalFromReaderError(t *testing.T) { err := UnmarshalFromReader(strings.NewReader(s), &v) assert.NotNil(t, err) } + +func Test_doMarshalJson(t *testing.T) { + type args struct { + v any + } + + tests := []struct { + name string + args args + want []byte + wantErr assert.ErrorAssertionFunc + }{ + { + name: "nil", + args: args{nil}, + want: []byte("null"), + wantErr: assert.NoError, + }, + { + name: "string", + args: args{"hello"}, + want: []byte(`"hello"`), + wantErr: assert.NoError, + }, + { + name: "int", + args: args{42}, + want: []byte("42"), + wantErr: assert.NoError, + }, + { + name: "bool", + args: args{true}, + want: []byte("true"), + wantErr: assert.NoError, + }, + { + name: "struct", + args: args{ + struct { + Name string `json:"name"` + }{Name: "test"}, + }, + want: []byte(`{"name":"test"}`), + wantErr: assert.NoError, + }, + { + name: "slice", + args: args{[]int{1, 2, 3}}, + want: []byte("[1,2,3]"), + wantErr: assert.NoError, + }, + { + name: "map", + args: args{map[string]int{"a": 1, "b": 2}}, + want: []byte(`{"a":1,"b":2}`), + wantErr: assert.NoError, + }, + { + name: "unmarshalable type", + args: args{complex(1, 2)}, + want: nil, + wantErr: assert.Error, + }, + { + name: "channel type", + args: args{make(chan int)}, + want: nil, + wantErr: assert.Error, + }, + { + name: "url with query params", + args: args{"https://example.com/api?name=test&age=25"}, + want: []byte(`"https://example.com/api?name=test&age=25"`), + wantErr: assert.NoError, + }, + { + name: "url with encoded query params", + args: args{"https://example.com/api?data=hello%20world&special=%26%3D"}, + want: []byte(`"https://example.com/api?data=hello%20world&special=%26%3D"`), + wantErr: assert.NoError, + }, + { + name: "url with multiple query params", + args: args{"http://localhost:8080/users?page=1&limit=10&sort=name&order=asc"}, + want: []byte(`"http://localhost:8080/users?page=1&limit=10&sort=name&order=asc"`), + wantErr: assert.NoError, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + got, err := Marshal(tt.args.v) + if !tt.wantErr(t, err, fmt.Sprintf("Marshal(%v)", tt.args.v)) { + return + } + + assert.Equalf(t, string(tt.want), string(got), "Marshal(%v)", tt.args.v) + }) + } +} diff --git a/rest/httpx/responses.go b/rest/httpx/responses.go index 28b806275..dc1a7eced 100644 --- a/rest/httpx/responses.go +++ b/rest/httpx/responses.go @@ -1,15 +1,14 @@ package httpx import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "io" "net/http" "sync" + "github.com/zeromicro/go-zero/core/jsonx" "github.com/zeromicro/go-zero/core/logc" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/rest/internal/errcode" @@ -173,28 +172,8 @@ func doHandleError(w http.ResponseWriter, err error, handler func(error) (int, a } } -func doMarshalJson(v any) ([]byte, error) { - // why not use json.Marshal? https://github.com/golang/go/issues/28453 - // it change the behavior of json.Marshal, like & -> \u0026, < -> \u003c, > -> \u003e - // which is not what we want in logic response - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return nil, err - } - - bs := buf.Bytes() - // Remove trailing newline added by json.Encoder.Encode - if len(bs) > 0 && bs[len(bs)-1] == '\n' { - bs = bs[:len(bs)-1] - } - - return bs, nil -} - func doWriteJson(w http.ResponseWriter, code int, v any) error { - bs, err := doMarshalJson(v) + bs, err := jsonx.Marshal(v) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return fmt.Errorf("marshal json failed, error: %w", err) diff --git a/rest/httpx/responses_test.go b/rest/httpx/responses_test.go index 6c6bc0a02..00e099daf 100644 --- a/rest/httpx/responses_test.go +++ b/rest/httpx/responses_test.go @@ -443,105 +443,3 @@ func TestWriteJsonCtxMarshalFailed(t *testing.T) { }) assert.Equal(t, http.StatusInternalServerError, w.code) } - -func Test_doMarshalJson(t *testing.T) { - type args struct { - v any - } - - tests := []struct { - name string - args args - want []byte - wantErr assert.ErrorAssertionFunc - }{ - { - name: "nil", - args: args{nil}, - want: []byte("null"), - wantErr: assert.NoError, - }, - { - name: "string", - args: args{"hello"}, - want: []byte(`"hello"`), - wantErr: assert.NoError, - }, - { - name: "int", - args: args{42}, - want: []byte("42"), - wantErr: assert.NoError, - }, - { - name: "bool", - args: args{true}, - want: []byte("true"), - wantErr: assert.NoError, - }, - { - name: "struct", - args: args{ - struct { - Name string `json:"name"` - }{Name: "test"}, - }, - want: []byte(`{"name":"test"}`), - wantErr: assert.NoError, - }, - { - name: "slice", - args: args{[]int{1, 2, 3}}, - want: []byte("[1,2,3]"), - wantErr: assert.NoError, - }, - { - name: "map", - args: args{map[string]int{"a": 1, "b": 2}}, - want: []byte(`{"a":1,"b":2}`), - wantErr: assert.NoError, - }, - { - name: "unmarshalable type", - args: args{complex(1, 2)}, - want: nil, - wantErr: assert.Error, - }, - { - name: "channel type", - args: args{make(chan int)}, - want: nil, - wantErr: assert.Error, - }, - { - name: "url with query params", - args: args{"https://example.com/api?name=test&age=25"}, - want: []byte(`"https://example.com/api?name=test&age=25"`), - wantErr: assert.NoError, - }, - { - name: "url with encoded query params", - args: args{"https://example.com/api?data=hello%20world&special=%26%3D"}, - want: []byte(`"https://example.com/api?data=hello%20world&special=%26%3D"`), - wantErr: assert.NoError, - }, - { - name: "url with multiple query params", - args: args{"http://localhost:8080/users?page=1&limit=10&sort=name&order=asc"}, - want: []byte(`"http://localhost:8080/users?page=1&limit=10&sort=name&order=asc"`), - wantErr: assert.NoError, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - got, err := doMarshalJson(tt.args.v) - if !tt.wantErr(t, err, fmt.Sprintf("doMarshalJson(%v)", tt.args.v)) { - return - } - - assert.Equalf(t, string(tt.want), string(got), "doMarshalJson(%v)", tt.args.v) - }) - } -}