Merge 091584830bc3fcf4e9a62e15f73165782476ea28 into dcaa4296d111981ffb31ac3eba90bb63e1eb5ab9

This commit is contained in:
Pete Steyert-Woods 2026-09-01 13:42:00 +00:00 committed by GitHub
commit c19f798f07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 353 additions and 3 deletions

View File

@ -33,13 +33,14 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
go: ["1.25", "1.26"]
go: ["1.25", "1.26", "1.27"]
test-tags:
[
"",
"-tags nomsgpack",
'--ldflags="-checklinkname=0" -tags sonic',
"-tags go_json",
"-tags jsonv2",
"-race",
]
include:

View File

@ -4,7 +4,7 @@ GO_VERSION=$(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
PACKAGES ?= $(shell $(GO) list ./...)
VETPACKAGES ?= $(shell $(GO) list ./... | grep -v /examples/)
GOFILES := $(shell find . -name "*.go")
TESTFOLDER := $(shell $(GO) list ./... | grep -E 'gin$$|ginS$$|binding$$|render$$' | grep -v examples)
TESTFOLDER := $(shell $(GO) list ./... | grep -E 'gin$$|ginS$$|binding$$|render$$|codec/json$$' | grep -v examples)
TESTTAGS ?= ""
.PHONY: test

View File

@ -2,7 +2,7 @@
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.
//go:build !jsoniter && !go_json && !(sonic && (linux || windows || darwin))
//go:build !(jsonv2 && go1.27 && goexperiment.jsonv2) && !jsoniter && !go_json && !(sonic && (linux || windows || darwin))
package json

View File

@ -0,0 +1,117 @@
// 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
import (
"io"
"strings"
"testing"
"github.com/gin-gonic/gin/codec/json"
)
// These benchmarks compare codecs, and on go1.27 also compare the engine behind
// encoding/json: the jsonv2 GOEXPERIMENT is on by default there, so v1 is itself
// implemented over encoding/json/v2. To see a codec or engine difference, run
// the same benchmark under each configuration and compare with benchstat, e.g.
//
// go test -bench . -count 10 ./codec/json/ > new.txt
// GOEXPERIMENT=nojsonv2 go test -bench . -count 10 ./codec/json/ > old.txt
// benchstat old.txt new.txt
//
// Marshal and Unmarshal do not necessarily move in the same direction, so read
// them separately rather than as one number.
type benchPayload struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
Meta map[string]string `json:"meta"`
Nested []benchPayload `json:"nested,omitempty"`
}
var nested = benchPayload{
ID: 1,
Name: "gin <framework>",
Tags: []string{"a", "b", "c"},
Meta: map[string]string{"k1": "v1", "k2": "v2"},
Nested: []benchPayload{
{ID: 2, Name: "x", Tags: []string{"z"}},
{ID: 3, Name: "y", Meta: map[string]string{"q": "r"}},
},
}
// flat carries no map, so it isolates struct and slice encoding from the key
// sorting that marshaling a map requires.
type benchFlat struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
N float64 `json:"n"`
OK bool `json:"ok"`
}
var flat = []benchFlat{
{1, "alpha <a>", []string{"x", "y"}, 1.5, true},
{2, "beta", []string{"z"}, 2.25, false},
{3, "gamma", nil, 3.75, true},
}
// Safe at package scope: this is an external test package, so codec/json is
// fully initialised and API is installed before these run.
var nestedJSON, _ = json.API.Marshal(nested)
func BenchmarkMarshal(b *testing.B) {
for b.Loop() {
if _, err := json.API.Marshal(nested); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMarshalFlat(b *testing.B) {
for b.Loop() {
if _, err := json.API.Marshal(flat); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMarshalIndent(b *testing.B) {
for b.Loop() {
if _, err := json.API.MarshalIndent(nested, "", " "); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkUnmarshal(b *testing.B) {
for b.Loop() {
var out benchPayload
if err := json.API.Unmarshal(nestedJSON, &out); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkEncoder(b *testing.B) {
for b.Loop() {
if err := json.API.NewEncoder(io.Discard).Encode(nested); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkDecoder(b *testing.B) {
r := strings.NewReader("")
for b.Loop() {
r.Reset(string(nestedJSON))
var out benchPayload
if err := json.API.NewDecoder(r).Decode(&out); err != nil {
b.Fatal(err)
}
}
}

152
codec/json/json_test.go Normal file
View File

@ -0,0 +1,152 @@
// 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))
}

80
codec/json/jsonv2.go Normal file
View File

@ -0,0 +1,80 @@
// 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.
//go:build jsonv2 && go1.27 && goexperiment.jsonv2 && !jsoniter && !go_json && !(sonic && (linux || windows || darwin))
package json
import (
"encoding/json"
"encoding/json/jsontext"
jsonv2 "encoding/json/v2"
"io"
)
// Package indicates what library is being used for JSON encoding.
const Package = "encoding/json/v2"
func init() {
API = jsonv2Api{}
}
// v1Compat matches the observable behavior of encoding/json v1: HTML escaping
// on, case-insensitive member matching, nil slices and maps as null, and the
// legacy omitempty and error semantics.
var v1Compat = json.DefaultOptionsV1()
// newline is written after each streamed value; a package-level slice keeps
// Encode from allocating one per call.
var newline = []byte{'\n'}
type jsonv2Api struct{}
func (jsonv2Api) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, v1Compat)
}
func (jsonv2Api) Unmarshal(data []byte, v any) error {
return jsonv2.Unmarshal(data, v, v1Compat)
}
func (jsonv2Api) MarshalIndent(v any, prefix, indent string) ([]byte, error) {
return jsonv2.Marshal(v, v1Compat,
jsontext.WithIndentPrefix(prefix), jsontext.WithIndent(indent))
}
// NewEncoder wraps MarshalWrite rather than returning v1's encoder, which
// marshals into an intermediate buffer so that SetIndent can reformat the
// result. Gin never indents a stream, so writing straight to the io.Writer
// avoids that buffer. Re-measure BenchmarkEncoder before replacing this with
// json.NewEncoder.
func (jsonv2Api) NewEncoder(writer io.Writer) Encoder {
// v1Compat already escapes HTML, matching v1's default.
return &v2Encoder{writer: writer, opts: v1Compat}
}
// NewDecoder returns v1's decoder. It satisfies Decoder as-is, implements
// UseNumber natively (v2 exposes no option for it), and measures the same as a
// wrapper over jsontext.Decoder, so there is nothing to gain by wrapping.
func (jsonv2Api) NewDecoder(reader io.Reader) Decoder {
return json.NewDecoder(reader)
}
type v2Encoder struct {
writer io.Writer
opts jsonv2.Options
}
func (e *v2Encoder) SetEscapeHTML(on bool) {
e.opts = jsonv2.JoinOptions(v1Compat, jsontext.EscapeForHTML(on))
}
func (e *v2Encoder) Encode(v any) error {
// MarshalWrite omits the trailing newline that v1's Encoder.Encode writes.
if err := jsonv2.MarshalWrite(e.writer, v, e.opts); err != nil {
return err
}
_, err := e.writer.Write(newline)
return err
}