mirror of
https://github.com/gin-gonic/gin.git
synced 2026-09-04 22:53:34 +08:00
As an experiment, with a thought for how Gin might eventually migrate to
and use features from jsonv2, I have put together a new json/codec for
json/v2.
---
Selected with -tags jsonv2, and constrained to go1.27 &&
goexperiment.jsonv2 since encoding/json/v2 is still gated by the
experiment in 1.27 and GOEXPERIMENT=nojsonv2 makes it unimportable.
json.go negates that entire expression, mirroring how it already
negates sonic's OS constraint, so -tags jsonv2 on Go 1.25/1.26 falls
back to encoding/json rather than leaving API nil.
Uses json.DefaultOptionsV1() to keep behaviour identical to the v1
codec: v2's native defaults would render nil slices/maps as []/{} and
match member names case-sensitively, silently zeroing fields that
previously bound.
Fills three v1 gaps v2's API doesn't cover directly: UseNumber has no
exported option, so it's reproduced with a custom *any unmarshaler;
MarshalWrite omits Encoder.Encode's trailing newline; MarshalIndent
becomes jsontext.WithIndentPrefix + WithIndent.
153 lines
5.3 KiB
Go
153 lines
5.3 KiB
Go
// Copyright 2026 Gin Core Team. All rights reserved.
|
|
// Use of this source code is governed by a MIT style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// Package json_test is an external test package on purpose. Each codec
|
|
// installs API from an init function, and package-level variables of an
|
|
// in-package test would be initialised before that init runs, leaving API
|
|
// nil. An importing package is guaranteed to observe a fully initialised
|
|
// codec/json, so tests here see API exactly as the rest of gin does.
|
|
package json_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gin-gonic/gin/codec/json"
|
|
)
|
|
|
|
// TestAPIInstalled guards the build constraints across the codec files. Every
|
|
// supported combination of tags, toolchain and GOEXPERIMENT must leave exactly
|
|
// one codec selected; if the constraints ever fail to overlap, no init runs and
|
|
// API stays nil, which builds cleanly and only panics at the first render.
|
|
func TestAPIInstalled(t *testing.T) {
|
|
require.NotNil(t, json.API, "no codec init ran: build constraints do not cover this configuration")
|
|
assert.NotEmpty(t, json.Package)
|
|
}
|
|
|
|
type payload struct {
|
|
Name string `json:"name"`
|
|
N int `json:"n"`
|
|
}
|
|
|
|
func TestMarshal(t *testing.T) {
|
|
got, err := json.API.Marshal(payload{Name: "gin", N: 1})
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"name":"gin","n":1}`, string(got))
|
|
}
|
|
|
|
// TestMarshalEscapesHTML pins the HTML escaping that Context.JSON relies on to
|
|
// stay safe when a response is embedded in a document. PureJSON is the only
|
|
// render that opts out, via Encoder.SetEscapeHTML.
|
|
func TestMarshalEscapesHTML(t *testing.T) {
|
|
got, err := json.API.Marshal(payload{Name: "<script>&"})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(got), `\u003cscript\u003e\u0026`)
|
|
assert.NotContains(t, string(got), "<script>")
|
|
}
|
|
|
|
func TestUnmarshal(t *testing.T) {
|
|
var got payload
|
|
require.NoError(t, json.API.Unmarshal([]byte(`{"name":"gin","n":1}`), &got))
|
|
assert.Equal(t, payload{Name: "gin", N: 1}, got)
|
|
}
|
|
|
|
// TestUnmarshalMatchesNamesCaseInsensitively pins v1's case-insensitive member
|
|
// matching. encoding/json/v2 matches case-sensitively by default, which would
|
|
// silently leave fields zeroed with a nil error rather than reporting a problem.
|
|
func TestUnmarshalMatchesNamesCaseInsensitively(t *testing.T) {
|
|
var got payload
|
|
require.NoError(t, json.API.Unmarshal([]byte(`{"NAME":"gin","N":1}`), &got))
|
|
assert.Equal(t, payload{Name: "gin", N: 1}, got)
|
|
}
|
|
|
|
// TestMarshalNilSliceAndMapAsNull pins v1's rendering of nil slices and maps.
|
|
// encoding/json/v2 renders them as [] and {} by default, a visible change to
|
|
// every response body carrying an unset slice or map.
|
|
func TestMarshalNilSliceAndMapAsNull(t *testing.T) {
|
|
got, err := json.API.Marshal(struct {
|
|
S []string `json:"s"`
|
|
M map[string]string `json:"m"`
|
|
}{})
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"s":null,"m":null}`, string(got))
|
|
}
|
|
|
|
// TestMarshalIndent asserts the exact indentation bytes, split into lines so
|
|
// the comparison is about layout rather than JSON equality.
|
|
func TestMarshalIndent(t *testing.T) {
|
|
got, err := json.API.MarshalIndent(payload{Name: "gin", N: 1}, "", " ")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{
|
|
"{",
|
|
` "name": "gin",`,
|
|
` "n": 1`,
|
|
"}",
|
|
}, strings.Split(string(got), "\n"))
|
|
}
|
|
|
|
// TestEncoder covers what render.PureJSON depends on: a trailing newline, and
|
|
// SetEscapeHTML(false) actually disabling escaping.
|
|
func TestEncoder(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
escapeHTML bool
|
|
want string
|
|
}{
|
|
{"escaped", true, "{\"name\":\"\\u003ca\\u003e\",\"n\":0}\n"},
|
|
{"unescaped", false, "{\"name\":\"<a>\",\"n\":0}\n"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
enc := json.API.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(tt.escapeHTML)
|
|
require.NoError(t, enc.Encode(payload{Name: "<a>"}))
|
|
assert.Equal(t, tt.want, buf.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecoder(t *testing.T) {
|
|
var got payload
|
|
require.NoError(t, json.API.NewDecoder(strings.NewReader(`{"name":"gin","n":1}`)).Decode(&got))
|
|
assert.Equal(t, payload{Name: "gin", N: 1}, got)
|
|
}
|
|
|
|
// TestDecoderDecodesStream pins that consecutive values can be read from one
|
|
// decoder, which binding relies on for request bodies that are not exhausted
|
|
// by a single value.
|
|
func TestDecoderDecodesStream(t *testing.T) {
|
|
dec := json.API.NewDecoder(strings.NewReader(`{"n":1}{"n":2}`))
|
|
for _, want := range []int{1, 2} {
|
|
var got payload
|
|
require.NoError(t, dec.Decode(&got))
|
|
assert.Equal(t, want, got.N)
|
|
}
|
|
}
|
|
|
|
// TestDecoderUseNumber backs binding.EnableDecoderUseNumber. Asserted through
|
|
// the textual form rather than a concrete type so it holds for every codec: a
|
|
// float64 would render as 1.5 and lose the trailing zero.
|
|
func TestDecoderUseNumber(t *testing.T) {
|
|
dec := json.API.NewDecoder(strings.NewReader(`{"n":1.50}`))
|
|
dec.UseNumber()
|
|
|
|
var got map[string]any
|
|
require.NoError(t, dec.Decode(&got))
|
|
assert.Equal(t, "1.50", fmt.Sprint(got["n"]))
|
|
}
|
|
|
|
// TestDecoderDisallowUnknownFields backs binding.EnableDecoderDisallowUnknownFields.
|
|
func TestDecoderDisallowUnknownFields(t *testing.T) {
|
|
dec := json.API.NewDecoder(strings.NewReader(`{"name":"gin","nope":1}`))
|
|
dec.DisallowUnknownFields()
|
|
|
|
var got payload
|
|
assert.Error(t, dec.Decode(&got))
|
|
}
|