Compare commits

...

4 Commits

Author SHA1 Message Date
burybell
b6fc280012
Merge b0e3cdc07708cf0c0c153f7733a197a2ca8f4896 into d7776de7d444935ea4385999711bd6331a98fecb 2026-02-05 22:49:21 +02:00
Laurent Caumont
d7776de7d4
feat(render): add bson protocol (#4145) 2026-01-27 10:09:01 +08:00
wanghaolong613
e3118cc378
refactor: for loop can be modernized using range over int (#4392)
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-01-25 00:51:11 +08:00
linhujun
b0e3cdc077 fix(context): improve client disconnection handling in Stream method 2025-04-15 11:18:39 +08:00
16 changed files with 176 additions and 25 deletions

View File

@ -154,7 +154,7 @@ func runRequest(B *testing.B, r *Engine, method, path string) {
w := newMockWriter()
B.ReportAllocs()
B.ResetTimer()
for i := 0; i < B.N; i++ {
for B.Loop() {
r.ServeHTTP(w, req)
}
}

View File

@ -23,6 +23,7 @@ const (
MIMEYAML = "application/x-yaml"
MIMEYAML2 = "application/yaml"
MIMETOML = "application/toml"
MIMEBSON = "application/bson"
)
// Binding describes the interface which needs to be implemented for binding the
@ -86,6 +87,7 @@ var (
Header Binding = headerBinding{}
Plain BindingBody = plainBinding{}
TOML BindingBody = tomlBinding{}
BSON BindingBody = bsonBinding{}
)
// Default returns the appropriate Binding instance based on the HTTP method
@ -110,6 +112,8 @@ func Default(method, contentType string) Binding {
return TOML
case MIMEMultipartPOSTForm:
return FormMultipart
case MIMEBSON:
return BSON
default: // case MIMEPOSTForm:
return Form
}

View File

@ -21,6 +21,7 @@ const (
MIMEYAML = "application/x-yaml"
MIMEYAML2 = "application/yaml"
MIMETOML = "application/toml"
MIMEBSON = "application/bson"
)
// Binding describes the interface which needs to be implemented for binding the
@ -82,6 +83,7 @@ var (
Header = headerBinding{}
TOML = tomlBinding{}
Plain = plainBinding{}
BSON BindingBody = bsonBinding{}
)
// Default returns the appropriate Binding instance based on the HTTP method
@ -104,6 +106,8 @@ func Default(method, contentType string) Binding {
return FormMultipart
case MIMETOML:
return TOML
case MIMEBSON:
return BSON
default: // case MIMEPOSTForm:
return Form
}

View File

@ -21,6 +21,7 @@ import (
"github.com/gin-gonic/gin/testdata/protoexample"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
"google.golang.org/protobuf/proto"
)
@ -172,6 +173,9 @@ func TestBindingDefault(t *testing.T) {
assert.Equal(t, TOML, Default(http.MethodPost, MIMETOML))
assert.Equal(t, TOML, Default(http.MethodPut, MIMETOML))
assert.Equal(t, BSON, Default(http.MethodPost, MIMEBSON))
assert.Equal(t, BSON, Default(http.MethodPut, MIMEBSON))
}
func TestBindingJSONNilBody(t *testing.T) {
@ -731,6 +735,18 @@ func TestBindingProtoBufFail(t *testing.T) {
string(data), string(data[1:]))
}
func TestBindingBSON(t *testing.T) {
var obj FooStruct
obj.Foo = "bar"
data, _ := bson.Marshal(&obj)
testBodyBinding(t,
BSON, "bson",
"/", "/",
string(data),
// note: for badbody, we remove first byte to make it invalid
string(data[1:]))
}
func TestValidationFails(t *testing.T) {
var obj FooStruct
req := requestWithBody(http.MethodPost, "/", `{"bar": "foo"}`)

30
binding/bson.go Normal file
View File

@ -0,0 +1,30 @@
// Copyright 2025 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 binding
import (
"io"
"net/http"
"go.mongodb.org/mongo-driver/bson"
)
type bsonBinding struct{}
func (bsonBinding) Name() string {
return "bson"
}
func (b bsonBinding) Bind(req *http.Request, obj any) error {
buf, err := io.ReadAll(req.Body)
if err == nil {
err = b.BindBody(buf, obj)
}
return err
}
func (bsonBinding) BindBody(body []byte, obj any) error {
return bson.Unmarshal(body, obj)
}

View File

@ -27,7 +27,7 @@ func (err SliceValidationError) Error() string {
}
var b strings.Builder
for i := 0; i < len(err); i++ {
for i := range len(err) {
if err[i] != nil {
if b.Len() > 0 {
b.WriteString("\n")
@ -58,7 +58,7 @@ func (v *defaultValidator) ValidateStruct(obj any) error {
case reflect.Slice, reflect.Array:
count := value.Len()
validateRet := make(SliceValidationError, 0)
for i := 0; i < count; i++ {
for i := range count {
if err := v.ValidateStruct(value.Index(i).Interface()); err != nil {
validateRet = append(validateRet, err)
}

View File

@ -119,7 +119,7 @@ func mapping(value reflect.Value, field reflect.StructField, setter setter, tag
tValue := value.Type()
var isSet bool
for i := 0; i < value.NumField(); i++ {
for i := range value.NumField() {
sf := tValue.Field(i)
if sf.PkgPath != "" && !sf.Anonymous { // unexported
continue

View File

@ -40,6 +40,7 @@ const (
MIMEYAML2 = binding.MIMEYAML2
MIMETOML = binding.MIMETOML
MIMEPROTOBUF = binding.MIMEPROTOBUF
MIMEBSON = binding.MIMEBSON
)
// BodyBytesKey indicates a default body bytes key.
@ -1237,6 +1238,11 @@ func (c *Context) ProtoBuf(code int, obj any) {
c.Render(code, render.ProtoBuf{Data: obj})
}
// BSON serializes the given struct as BSON into the response body.
func (c *Context) BSON(code int, obj any) {
c.Render(code, render.BSON{Data: obj})
}
// String writes the given string into the response body.
func (c *Context) String(code int, format string, values ...any) {
c.Render(code, render.String{Format: format, Data: values})
@ -1314,7 +1320,7 @@ func (c *Context) SSEvent(name string, message any) {
// indicates "Is client disconnected in middle of stream"
func (c *Context) Stream(step func(w io.Writer) bool) bool {
w := c.Writer
clientGone := w.CloseNotify()
clientGone := c.Request.Context().Done()
for {
select {
case <-clientGone:
@ -1344,6 +1350,7 @@ type Negotiate struct {
Data any
TOMLData any
PROTOBUFData any
BSONData any
}
// Negotiate calls different Render according to acceptable Accept format.
@ -1373,6 +1380,10 @@ func (c *Context) Negotiate(code int, config Negotiate) {
data := chooseData(config.PROTOBUFData, config.Data)
c.ProtoBuf(code, data)
case binding.MIMEBSON:
data := chooseData(config.BSONData, config.Data)
c.BSON(code, data)
default:
c.AbortWithError(http.StatusNotAcceptable, errors.New("the accepted formats are not offered by the server")) //nolint: errcheck
}

View File

@ -32,6 +32,7 @@ import (
testdata "github.com/gin-gonic/gin/testdata/protoexample"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
"google.golang.org/protobuf/proto"
)
@ -1701,6 +1702,23 @@ func TestContextNegotiationWithPROTOBUF(t *testing.T) {
assert.Equal(t, "application/x-protobuf", w.Header().Get("Content-Type"))
}
func TestContextNegotiationWithBSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
c.Request, _ = http.NewRequest(http.MethodPost, "", nil)
c.Negotiate(http.StatusOK, Negotiate{
Offered: []string{MIMEBSON, MIMEXML, MIMEJSON, MIMEYAML, MIMEYAML2},
Data: H{"foo": "bar"},
})
bData, _ := bson.Marshal(H{"foo": "bar"})
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, string(bData), w.Body.String())
assert.Equal(t, "application/bson", w.Header().Get("Content-Type"))
}
func TestContextNegotiationNotSupport(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
@ -3677,22 +3695,22 @@ func BenchmarkGetMapFromFormData(b *testing.B) {
// Test case 3: Large dataset with many bracket keys
largeData := make(map[string][]string)
for i := 0; i < 100; i++ {
for i := range 100 {
key := fmt.Sprintf("ids[%d]", i)
largeData[key] = []string{fmt.Sprintf("value%d", i)}
}
for i := 0; i < 50; i++ {
for i := range 50 {
key := fmt.Sprintf("names[%d]", i)
largeData[key] = []string{fmt.Sprintf("name%d", i)}
}
for i := 0; i < 25; i++ {
for i := range 25 {
key := fmt.Sprintf("other[key%d]", i)
largeData[key] = []string{fmt.Sprintf("other%d", i)}
}
// Test case 4: Dataset with many non-matching keys (worst case)
worstCaseData := make(map[string][]string)
for i := 0; i < 100; i++ {
for i := range 100 {
key := fmt.Sprintf("nonmatching%d", i)
worstCaseData[key] = []string{fmt.Sprintf("value%d", i)}
}
@ -3728,7 +3746,7 @@ func BenchmarkGetMapFromFormData(b *testing.B) {
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for b.Loop() {
_, _ = getMapFromFormData(bm.data, bm.key)
}
})

View File

@ -400,7 +400,7 @@ func TestConcurrentHandleContext(t *testing.T) {
var wg sync.WaitGroup
iterations := 200
wg.Add(iterations)
for i := 0; i < iterations; i++ {
for range iterations {
go func() {
req, err := http.NewRequest(http.MethodGet, "/", nil)
assert.NoError(t, err)

13
go.mod
View File

@ -2,11 +2,13 @@ module github.com/gin-gonic/gin
go 1.24.0
toolchain go1.24.7
require (
github.com/bytedance/sonic v1.14.2
github.com/gin-contrib/sse v1.1.0
github.com/go-playground/validator/v10 v10.28.0
github.com/goccy/go-json v0.10.2
github.com/goccy/go-json v0.10.5
github.com/goccy/go-yaml v1.19.1
github.com/json-iterator/go v1.1.12
github.com/mattn/go-isatty v0.0.20
@ -15,10 +17,13 @@ require (
github.com/quic-go/quic-go v0.57.1
github.com/stretchr/testify v1.11.1
github.com/ugorji/go/codec v1.3.1
go.mongodb.org/mongo-driver v1.17.7
golang.org/x/net v0.47.0
google.golang.org/protobuf v1.36.10
)
require gopkg.in/yaml.v3 v3.0.1 // indirect
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
@ -30,13 +35,13 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
golang.org/x/arch v0.20.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

17
go.sum
View File

@ -22,8 +22,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -41,8 +41,9 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@ -70,10 +71,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU=
go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=

View File

@ -30,7 +30,7 @@ func rawStrToBytes(s string) []byte {
func TestBytesToString(t *testing.T) {
data := make([]byte, 1024)
for i := 0; i < 100; i++ {
for range 100 {
_, err := cRand.Read(data)
if err != nil {
t.Fatal(err)
@ -79,7 +79,7 @@ func RandStringBytesMaskImprSrcSB(n int) string {
}
func TestStringToBytes(t *testing.T) {
for i := 0; i < 100; i++ {
for range 100 {
s := RandStringBytesMaskImprSrcSB(64)
if !bytes.Equal(rawStrToBytes(s), StringToBytes(s)) {
t.Fatal("don't match")

34
render/bson.go Normal file
View File

@ -0,0 +1,34 @@
// Copyright 2025 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 render
import (
"net/http"
"go.mongodb.org/mongo-driver/bson"
)
// BSON contains the given interface object.
type BSON struct {
Data any
}
var bsonContentType = []string{"application/bson"}
// Render (BSON) marshals the given interface object and writes data with custom ContentType.
func (r BSON) Render(w http.ResponseWriter) error {
r.WriteContentType(w)
bytes, err := bson.Marshal(&r.Data)
if err == nil {
_, err = w.Write(bytes)
}
return err
}
// WriteContentType (BSONBuf) writes BSONBuf ContentType.
func (r BSON) WriteContentType(w http.ResponseWriter) {
writeContentType(w, bsonContentType)
}

View File

@ -19,6 +19,7 @@ import (
testdata "github.com/gin-gonic/gin/testdata/protoexample"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
"google.golang.org/protobuf/proto"
)
@ -359,6 +360,31 @@ func TestRenderProtoBufFail(t *testing.T) {
require.Error(t, err)
}
func TestRenderBSON(t *testing.T) {
w := httptest.NewRecorder()
reps := []int64{int64(1), int64(2)}
type mystruct struct {
Label string
Reps []int64
}
data := &mystruct{
Label: "test",
Reps: reps,
}
(BSON{data}).WriteContentType(w)
bsonData, err := bson.Marshal(data)
require.NoError(t, err)
assert.Equal(t, "application/bson", w.Header().Get("Content-Type"))
err = (BSON{data}).Render(w)
require.NoError(t, err)
assert.Equal(t, bsonData, w.Body.Bytes())
assert.Equal(t, "application/bson", w.Header().Get("Content-Type"))
}
func TestRenderXML(t *testing.T) {
w := httptest.NewRecorder()
data := xmlmap{

View File

@ -162,7 +162,7 @@ func resolveAddress(addr []string) string {
// https://stackoverflow.com/questions/53069040/checking-a-string-contains-only-ascii-characters
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
for i := range len(s) {
if s[i] > unicode.MaxASCII {
return false
}