Compare commits

...

6 Commits

Author SHA1 Message Date
Charlie Chiang
f01262148b
Merge c9ab7eb86ff747a0552bef1af4376f7e0405bc38 into c3d5a28ed6d3849da820195b6774d212bcc038a9 2025-11-07 11:06:20 +07:00
Name
c3d5a28ed6
fix(gin): close os.File in RunFd to prevent resource leak (#4422)
Co-authored-by: 1911860538 <alxps1911@gmail.com>
2025-11-07 12:01:19 +08:00
Name
acc55e049e
feat(context): add Protocol Buffers support to content negotiation (#4423)
Co-authored-by: 1911860538 <alxps1911@gmail.com>
2025-11-07 11:59:58 +08:00
dependabot[bot]
0c0e99d253
chore(deps): bump github/codeql-action from 3 to 4 in the actions group (#4425)
Bumps the actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 3 to 4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-07 11:57:41 +08:00
Bo-Yi Wu
dceb61e6e7
docs(README): add a Trivy security scan badge (#4426)
- Add a Trivy security scan badge to the documentation
- Import the log package in the example code
- Improve error handling for server startup in the example code

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-11-07 11:57:12 +08:00
Charlie Chiang
c9ab7eb86f feat: add an option to allow trailing slash insensitive matching
Signed-off-by: Charlie Chiang <charlie_c_0129@outlook.com>
2025-07-25 15:42:13 +08:00
6 changed files with 204 additions and 39 deletions

View File

@ -39,7 +39,7 @@ jobs:
ignore-unfixed: true
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: 'trivy-results.sarif'

View File

@ -3,6 +3,7 @@
<img align="right" width="159px" src="https://raw.githubusercontent.com/gin-gonic/logo/master/color.png">
[![Build Status](https://github.com/gin-gonic/gin/actions/workflows/gin.yml/badge.svg?branch=master)](https://github.com/gin-gonic/gin/actions/workflows/gin.yml)
[![Trivy Security Scan](https://github.com/gin-gonic/gin/actions/workflows/trivy-scan.yml/badge.svg)](https://github.com/gin-gonic/gin/actions/workflows/trivy-scan.yml)
[![codecov](https://codecov.io/gh/gin-gonic/gin/branch/master/graph/badge.svg)](https://codecov.io/gh/gin-gonic/gin)
[![Go Report Card](https://goreportcard.com/badge/github.com/gin-gonic/gin)](https://goreportcard.com/report/github.com/gin-gonic/gin)
[![Go Reference](https://pkg.go.dev/badge/github.com/gin-gonic/gin?status.svg)](https://pkg.go.dev/github.com/gin-gonic/gin?tab=doc)
@ -62,6 +63,7 @@ Here's a complete example that demonstrates Gin's simplicity:
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
@ -70,7 +72,7 @@ import (
func main() {
// Create a Gin router with default middleware (logger and recovery)
r := gin.Default()
// Define a simple GET endpoint
r.GET("/ping", func(c *gin.Context) {
// Return JSON response
@ -78,10 +80,12 @@ func main() {
"message": "pong",
})
})
// Start server on port 8080 (default)
// Server will listen on 0.0.0.0:8080 (localhost:8080 on Windows)
r.Run()
if err := r.Run(); err != nil {
log.Fatalf("failed to run server: %v", err)
}
}
```
@ -190,7 +194,6 @@ Gin has a rich ecosystem of middleware for common web development needs. Explore
- CORS, Rate limiting, Compression
- Logging, Metrics, Tracing
- Static file serving, Template engines
- **[gin-gonic/contrib](https://github.com/gin-gonic/contrib)** - Additional community middleware
## 🏢 Production Usage

View File

@ -39,6 +39,7 @@ const (
MIMEYAML = binding.MIMEYAML
MIMEYAML2 = binding.MIMEYAML2
MIMETOML = binding.MIMETOML
MIMEPROTOBUF = binding.MIMEPROTOBUF
)
// BodyBytesKey indicates a default body bytes key.
@ -1280,14 +1281,15 @@ func (c *Context) Stream(step func(w io.Writer) bool) bool {
// Negotiate contains all negotiations data.
type Negotiate struct {
Offered []string
HTMLName string
HTMLData any
JSONData any
XMLData any
YAMLData any
Data any
TOMLData any
Offered []string
HTMLName string
HTMLData any
JSONData any
XMLData any
YAMLData any
Data any
TOMLData any
PROTOBUFData any
}
// Negotiate calls different Render according to acceptable Accept format.
@ -1313,6 +1315,10 @@ func (c *Context) Negotiate(code int, config Negotiate) {
data := chooseData(config.TOMLData, config.Data)
c.TOML(code, data)
case binding.MIMEPROTOBUF:
data := chooseData(config.PROTOBUFData, config.Data)
c.ProtoBuf(code, data)
default:
c.AbortWithError(http.StatusNotAcceptable, errors.New("the accepted formats are not offered by the server")) //nolint: errcheck
}

View File

@ -1628,6 +1628,32 @@ func TestContextNegotiationWithHTML(t *testing.T) {
assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type"))
}
func TestContextNegotiationWithPROTOBUF(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
reps := []int64{int64(1), int64(2)}
label := "test"
data := &testdata.Test{
Label: &label,
Reps: reps,
}
c.Negotiate(http.StatusCreated, Negotiate{
Offered: []string{MIMEPROTOBUF, MIMEJSON, MIMEXML},
Data: data,
})
// Marshal original data for comparison
protoData, err := proto.Marshal(data)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, string(protoData), w.Body.String())
assert.Equal(t, "application/x-protobuf", w.Header().Get("Content-Type"))
}
func TestContextNegotiationNotSupport(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)

81
gin.go
View File

@ -112,6 +112,15 @@ type Engine struct {
// RedirectTrailingSlash is independent of this option.
RedirectFixedPath bool
// TrailingSlashInsensitivity makes the router insensitive to trailing
// slashes. It works like RedirectTrailingSlash, but instead of generating a
// redirection response to the path with or without the trailing slash, it
// will just go to the corresponding handler.
//
// Enabling this option will make RedirectTrailingSlash ineffective since
// no redirection will be performed.
TrailingSlashInsensitivity bool
// HandleMethodNotAllowed if enabled, the router checks if another method is allowed for the
// current route, if the current request can not be routed.
// If this is the case, the request is answered with 'Method Not Allowed'
@ -184,12 +193,13 @@ var _ IRouter = (*Engine)(nil)
// New returns a new blank Engine instance without any middleware attached.
// By default, the configuration is:
// - RedirectTrailingSlash: true
// - RedirectFixedPath: false
// - HandleMethodNotAllowed: false
// - ForwardedByClientIP: true
// - UseRawPath: false
// - UnescapePathValues: true
// - RedirectTrailingSlash: true
// - RedirectFixedPath: false
// - TrailingSlashInsensitivity: false
// - HandleMethodNotAllowed: false
// - ForwardedByClientIP: true
// - UseRawPath: false
// - UnescapePathValues: true
func New(opts ...OptionFunc) *Engine {
debugPrintWARNINGNew()
engine := &Engine{
@ -198,22 +208,23 @@ func New(opts ...OptionFunc) *Engine {
basePath: "/",
root: true,
},
FuncMap: template.FuncMap{},
RedirectTrailingSlash: true,
RedirectFixedPath: false,
HandleMethodNotAllowed: false,
ForwardedByClientIP: true,
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
TrustedPlatform: defaultPlatform,
UseRawPath: false,
RemoveExtraSlash: false,
UnescapePathValues: true,
MaxMultipartMemory: defaultMultipartMemory,
trees: make(methodTrees, 0, 9),
delims: render.Delims{Left: "{{", Right: "}}"},
secureJSONPrefix: "while(1);",
trustedProxies: []string{"0.0.0.0/0", "::/0"},
trustedCIDRs: defaultTrustedCIDRs,
FuncMap: template.FuncMap{},
RedirectTrailingSlash: true,
RedirectFixedPath: false,
TrailingSlashInsensitivity: false,
HandleMethodNotAllowed: false,
ForwardedByClientIP: true,
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
TrustedPlatform: defaultPlatform,
UseRawPath: false,
RemoveExtraSlash: false,
UnescapePathValues: true,
MaxMultipartMemory: defaultMultipartMemory,
trees: make(methodTrees, 0, 9),
delims: render.Delims{Left: "{{", Right: "}}"},
secureJSONPrefix: "while(1);",
trustedProxies: []string{"0.0.0.0/0", "::/0"},
trustedCIDRs: defaultTrustedCIDRs,
}
engine.engine = engine
engine.pool.New = func() any {
@ -593,6 +604,7 @@ func (engine *Engine) RunFd(fd int) (err error) {
}
f := os.NewFile(uintptr(fd), fmt.Sprintf("fd@%d", fd))
defer f.Close()
listener, err := net.FileListener(f)
if err != nil {
return
@ -691,6 +703,19 @@ func (engine *Engine) handleHTTPRequest(c *Context) {
return
}
if httpMethod != http.MethodConnect && rPath != "/" {
// TrailingSlashInsensitivity has precedence over RedirectTrailingSlash.
if value.tsr && engine.TrailingSlashInsensitivity {
// Retry with the path with or without the trailing slash.
// It should succeed because tsr is true.
value = root.getValue(addOrRemoveTrailingSlash(rPath), c.params, c.skippedNodes, unescape)
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
}
if value.tsr && engine.RedirectTrailingSlash {
redirectTrailingSlash(c)
return
@ -745,6 +770,13 @@ func serveError(c *Context, code int, defaultMessage []byte) {
c.writermem.WriteHeaderNow()
}
func addOrRemoveTrailingSlash(p string) string {
if length := len(p); length > 1 && p[length-1] == '/' {
return p[:length-1]
}
return p + "/"
}
func redirectTrailingSlash(c *Context) {
req := c.Request
p := req.URL.Path
@ -754,10 +786,7 @@ func redirectTrailingSlash(c *Context) {
p = prefix + "/" + req.URL.Path
}
req.URL.Path = p + "/"
if length := len(p); length > 1 && p[length-1] == '/' {
req.URL.Path = p[:length-1]
}
req.URL.Path = addOrRemoveTrailingSlash(p)
redirectRequest(c)
}

View File

@ -246,6 +246,107 @@ func TestRouteRedirectTrailingSlash(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestRouteTrailingSlashInsensitivity(t *testing.T) {
router := New()
router.RedirectTrailingSlash = false
router.TrailingSlashInsensitivity = true
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
router.GET("/path2/", func(c *Context) { c.String(http.StatusOK, "path2") })
// Test that trailing slash insensitivity works.
w := PerformRequest(router, http.MethodGet, "/path/")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path2/")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path2", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path2")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path2", w.Body.String())
// If handlers for `/path` and `/path/` are different, the request should not be redirected.
router.GET("/path3", func(c *Context) { c.String(http.StatusOK, "path3") })
router.GET("/path3/", func(c *Context) { c.String(http.StatusOK, "path3/") })
w = PerformRequest(router, http.MethodGet, "/path3")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path3", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path3/")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path3/", w.Body.String())
// Should no longer match.
router.TrailingSlashInsensitivity = false
w = PerformRequest(router, http.MethodGet, "/path2")
assert.Equal(t, http.StatusNotFound, w.Code)
w = PerformRequest(router, http.MethodGet, "/path/")
assert.Equal(t, http.StatusNotFound, w.Code)
}
func BenchmarkRouteTrailingSlashInsensitivity(b *testing.B) {
b.Run("Insensitive", func(b *testing.B) {
router := New()
router.RedirectTrailingSlash = false
router.TrailingSlashInsensitivity = true
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Cause an insensitive match. Test if the retry logic is causing
// slowdowns.
w := PerformRequest(router, http.MethodGet, "/path/")
if w.Code != http.StatusOK || w.Body.String() != "path" {
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
}
})
b.Run("Exact", func(b *testing.B) {
router := New()
router.RedirectTrailingSlash = false
router.TrailingSlashInsensitivity = false
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := PerformRequest(router, http.MethodGet, "/path") // Exact match.
if w.Code != http.StatusOK || w.Body.String() != "path" {
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
}
})
b.Run("Redirect", func(b *testing.B) {
router := New()
router.RedirectTrailingSlash = true
router.TrailingSlashInsensitivity = false
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := PerformRequest(router, http.MethodGet, "/path/") // Redirect.
if w.Code != http.StatusMovedPermanently {
b.Fatalf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code)
}
}
})
}
func TestRouteRedirectFixedPath(t *testing.T) {
router := New()
router.RedirectFixedPath = true