diff --git a/rest/httpx/responses.go b/rest/httpx/responses.go index 6f2de0953..4f3aaf8cd 100644 --- a/rest/httpx/responses.go +++ b/rest/httpx/responses.go @@ -1,6 +1,7 @@ package httpx import ( + "bytes" "context" "encoding/json" "errors" @@ -172,8 +173,28 @@ 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 + 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 := json.Marshal(v) + bs, err := doMarshalJson(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 00e099daf..b8492326b 100644 --- a/rest/httpx/responses_test.go +++ b/rest/httpx/responses_test.go @@ -12,9 +12,10 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/zeromicro/go-zero/core/logx" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/zeromicro/go-zero/core/logx" ) type message struct { @@ -443,3 +444,103 @@ 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 { + 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) + }, + ) + } +}