From 9e193dc13c61709fcbf7901acd3bc0f02abb6393 Mon Sep 17 00:00:00 2001 From: ljluestc Date: Tue, 25 Nov 2025 13:56:26 -0800 Subject: [PATCH 1/5] feat: add GetRequestBody() method for reusable request body access - Add Context.GetRequestBody() method that caches request body bytes - Allows multiple reads of request body without consumption - Integrates with existing ShouldBindBodyWith* caching mechanism - Includes comprehensive tests and usage examples - Addresses issue #1974: Wrap Request.Body for reuse by Gin, middlewares and app code --- context.go | 27 ++++++++ context_test.go | 99 ++++++++++++++++++++++++++++ examples/get_request_body_example.go | 86 ++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 examples/get_request_body_example.go diff --git a/context.go b/context.go index 112f0ee0..e5a99b57 100644 --- a/context.go +++ b/context.go @@ -1080,6 +1080,33 @@ func (c *Context) GetRawData() ([]byte, error) { return io.ReadAll(c.Request.Body) } +// GetRequestBody returns the request body as bytes, caching it for reuse. +// This allows the body to be read multiple times without being consumed. +// Returns an error if the body cannot be read or if it's nil. +func (c *Context) GetRequestBody() ([]byte, error) { + if c.Request.Body == nil { + return nil, errors.New("cannot read nil body") + } + + // Check if body is already cached + if cb, ok := c.Get(BodyBytesKey); ok { + if cbb, ok := cb.([]byte); ok { + return cbb, nil + } + } + + // Read and cache the body + body, err := io.ReadAll(c.Request.Body) + if err != nil { + return nil, err + } + + // Cache the body for future use + c.Set(BodyBytesKey, body) + + return body, nil +} + // SetSameSite with cookie func (c *Context) SetSameSite(samesite http.SameSite) { c.sameSite = samesite diff --git a/context_test.go b/context_test.go index 126646fc..d8ff2d66 100644 --- a/context_test.go +++ b/context_test.go @@ -2872,6 +2872,105 @@ func TestContextGetRawData(t *testing.T) { assert.Equal(t, "Fetch binary post data", string(data)) } +func TestContextGetRequestBody(t *testing.T) { + // Test basic functionality + t.Run("basic functionality", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + bodyContent := "test request body data" + body := strings.NewReader(bodyContent) + c.Request, _ = http.NewRequest(http.MethodPost, "/", body) + + data, err := c.GetRequestBody() + require.NoError(t, err) + assert.Equal(t, bodyContent, string(data)) + }) + + // Test multiple calls return cached data + t.Run("multiple calls return cached data", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + bodyContent := "reusable request body" + body := strings.NewReader(bodyContent) + c.Request, _ = http.NewRequest(http.MethodPost, "/", body) + + // First call + data1, err1 := c.GetRequestBody() + require.NoError(t, err1) + assert.Equal(t, bodyContent, string(data1)) + + // Second call should return cached data + data2, err2 := c.GetRequestBody() + require.NoError(t, err2) + assert.Equal(t, bodyContent, string(data2)) + + // Third call should also return cached data + data3, err3 := c.GetRequestBody() + require.NoError(t, err3) + assert.Equal(t, bodyContent, string(data3)) + + // All calls should return the same data + assert.Equal(t, data1, data2) + assert.Equal(t, data2, data3) + }) + + // Test nil body error + t.Run("nil body error", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) + c.Request.Body = nil + + data, err := c.GetRequestBody() + assert.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "cannot read nil body") + }) + + // Test empty body + t.Run("empty body", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + body := strings.NewReader("") + c.Request, _ = http.NewRequest(http.MethodPost, "/", body) + + data, err := c.GetRequestBody() + require.NoError(t, err) + assert.Equal(t, "", string(data)) + assert.Equal(t, 0, len(data)) + }) + + // Test large body + t.Run("large body", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + largeContent := strings.Repeat("large body content ", 1000) + body := strings.NewReader(largeContent) + c.Request, _ = http.NewRequest(http.MethodPost, "/", body) + + data, err := c.GetRequestBody() + require.NoError(t, err) + assert.Equal(t, largeContent, string(data)) + assert.Equal(t, len(largeContent), len(data)) + }) + + // Test integration with ShouldBindBodyWith + t.Run("integration with ShouldBindBodyWith", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + jsonBody := `{"name":"test","value":123}` + body := strings.NewReader(jsonBody) + c.Request, _ = http.NewRequest(http.MethodPost, "/", body) + c.Request.Header.Set("Content-Type", "application/json") + + // Get body first + bodyData, err := c.GetRequestBody() + require.NoError(t, err) + assert.Equal(t, jsonBody, string(bodyData)) + + // ShouldBindBodyWith should still work and reuse cached body + var jsonData map[string]interface{} + err = c.ShouldBindBodyWithJSON(&jsonData) + require.NoError(t, err) + assert.Equal(t, "test", jsonData["name"]) + assert.Equal(t, float64(123), jsonData["value"]) + }) +} + func TestContextRenderDataFromReader(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) diff --git a/examples/get_request_body_example.go b/examples/get_request_body_example.go new file mode 100644 index 00000000..33dd07c7 --- /dev/null +++ b/examples/get_request_body_example.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +func main() { + r := gin.Default() + + // Example middleware that reads the request body + r.Use(func(c *gin.Context) { + // Get the request body - this can be called multiple times + body, err := c.GetRequestBody() + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + // Log the body length for demonstration + fmt.Printf("Middleware: Request body length: %d bytes\n", len(body)) + + // Store body in context for use by handlers + c.Set("rawBody", body) + c.Next() + }) + + // Handler that also reads the body + r.POST("/echo", func(c *gin.Context) { + // Get the body again - this will use the cached version + body, err := c.GetRequestBody() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Also get the body that was stored by middleware + storedBody, exists := c.Get("rawBody") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "body not found in context"}) + return + } + + // Both should be identical + if string(body) != string(storedBody.([]byte)) { + c.JSON(http.StatusInternalServerError, gin.H{"error": "bodies don't match"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Body received and cached successfully", + "body": string(body), + "length": len(body), + }) + }) + + // Handler that uses binding after GetRequestBody + r.POST("/bind-after-body", func(c *gin.Context) { + // First bind to a struct - this caches the body + var jsonData map[string]interface{} + if err := c.ShouldBindBodyWithJSON(&jsonData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Then get the raw body - this will use the cached version + rawBody, err := c.GetRequestBody() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "raw_body": string(rawBody), + "parsed_data": jsonData, + }) + }) + + fmt.Println("Server starting on :9090") + fmt.Println("Try: curl -X POST -d 'hello world' http://localhost:9090/echo") + fmt.Println("Or: curl -X POST -H 'Content-Type: application/json' -d '{\"name\":\"test\"}' http://localhost:9090/bind-after-body") + + r.Run(":9090") +} From 51137fbe4367a5df21688afe4619086bafab402a Mon Sep 17 00:00:00 2001 From: ljluestc Date: Tue, 25 Nov 2025 14:08:01 -0800 Subject: [PATCH 2/5] docs: add GetRequestBody() method documentation - Add 'Get Request Body' section to docs/doc.md - Include usage examples and curl test commands - Document the new API for reusable request body access --- docs/doc.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/docs/doc.md b/docs/doc.md index 0dd86684..37e2a1a0 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -29,6 +29,7 @@ - [Bind default value if none provided](#bind-default-value-if-none-provided) - [Collection format for arrays](#collection-format-for-arrays) - [Bind Uri](#bind-uri) + - [Get Request Body](#get-request-body) - [Bind custom unmarshaler](#bind-custom-unmarshaler) - [Bind Header](#bind-header) - [Bind HTML checkboxes](#bind-html-checkboxes) @@ -1007,6 +1008,69 @@ curl -v localhost:8088/thinkerou/987fbc97-4bed-5078-9f07-9141ba07c9f3 curl -v localhost:8088/thinkerou/not-uuid ``` +### Get Request Body + +`GetRequestBody` returns the request body as bytes, caching it for reuse. This allows the body to be read multiple times without being consumed, which is useful when middleware and handlers both need access to the same request body. + +```go +package main + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + + // Middleware that logs request body size + router.Use(func(c *gin.Context) { + body, err := c.GetRequestBody() + if err != nil { + fmt.Printf("Error reading body: %v\n", err) + return + } + fmt.Printf("Request body size: %d bytes\n", len(body)) + c.Next() + }) + + router.POST("/echo", func(c *gin.Context) { + // Get the body again - this uses the cached version + body, err := c.GetRequestBody() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // You can also use it with binding methods + var jsonData map[string]interface{} + if err := c.ShouldBindBodyWithJSON(&jsonData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "body": string(body), + "parsed": jsonData, + }) + }) + + router.Run(":8080") +} +``` + +Test it with: + +```sh +curl -X POST -H "Content-Type: application/json" \ + -d '{"message":"hello","count":42}' \ + http://localhost:8080/echo +``` + +This will output both the raw body and the parsed JSON, demonstrating that the body can be accessed multiple times. + ### Bind custom unmarshaler ```go From b296dbea8bdbcb09713bcd3aa66c1efd3ffac9bf Mon Sep 17 00:00:00 2001 From: ljluestc Date: Sat, 29 Nov 2025 14:04:30 -0800 Subject: [PATCH 3/5] fix context --- context.go | 26 ++++++++++++++++++++++++++ context_test.go | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/context.go b/context.go index e5a99b57..a9ea1ba3 100644 --- a/context.go +++ b/context.go @@ -5,6 +5,7 @@ package gin import ( + "bytes" "errors" "fmt" "io" @@ -914,6 +915,24 @@ func (c *Context) ShouldBindUri(obj any) error { // ShouldBindWith binds the passed struct pointer using the specified binding engine. // See the binding package. func (c *Context) ShouldBindWith(obj any, b binding.Binding) error { + if b.Name() == "form-urlencoded" || b.Name() == "form" { + var body []byte + if cb, ok := c.Get(BodyBytesKey); ok { + if cbb, ok := cb.([]byte); ok { + body = cbb + } + } + + if body == nil { + var err error + body, err = io.ReadAll(c.Request.Body) + if err != nil { + return err + } + c.Set(BodyBytesKey, body) + } + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + } return b.Bind(c.Request, obj) } @@ -1077,6 +1096,13 @@ func (c *Context) GetRawData() ([]byte, error) { if c.Request.Body == nil { return nil, errors.New("cannot read nil body") } + + if cb, ok := c.Get(BodyBytesKey); ok { + if cbb, ok := cb.([]byte); ok { + return cbb, nil + } + } + return io.ReadAll(c.Request.Body) } diff --git a/context_test.go b/context_test.go index d8ff2d66..2bc4314c 100644 --- a/context_test.go +++ b/context_test.go @@ -2349,6 +2349,28 @@ func TestContextShouldBindWithQuery(t *testing.T) { assert.Equal(t, 0, w.Body.Len()) } +func TestContextGetRawDataAfterShouldBind(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("p1=1&p2=2&p3=4")) + c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var s struct { + P1 string `form:"p1"` + P2 string `form:"p2"` + P3 string `form:"p3"` + } + require.NoError(t, c.ShouldBind(&s)) + assert.Equal(t, "1", s.P1) + assert.Equal(t, "2", s.P2) + assert.Equal(t, "4", s.P3) + + rawData, err := c.GetRawData() + require.NoError(t, err) + assert.Equal(t, "p1=1&p2=2&p3=4", string(rawData)) +} + func TestContextShouldBindWithYAML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) From f6e887d460cfa1a0a76ef9c280bb938fff7c7418 Mon Sep 17 00:00:00 2001 From: ljluestc Date: Sat, 29 Nov 2025 14:28:12 -0800 Subject: [PATCH 4/5] docs: remove GetRequestBody documentation changes --- docs/doc.md | 64 ----------------------------------------------------- 1 file changed, 64 deletions(-) diff --git a/docs/doc.md b/docs/doc.md index 37e2a1a0..0dd86684 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -29,7 +29,6 @@ - [Bind default value if none provided](#bind-default-value-if-none-provided) - [Collection format for arrays](#collection-format-for-arrays) - [Bind Uri](#bind-uri) - - [Get Request Body](#get-request-body) - [Bind custom unmarshaler](#bind-custom-unmarshaler) - [Bind Header](#bind-header) - [Bind HTML checkboxes](#bind-html-checkboxes) @@ -1008,69 +1007,6 @@ curl -v localhost:8088/thinkerou/987fbc97-4bed-5078-9f07-9141ba07c9f3 curl -v localhost:8088/thinkerou/not-uuid ``` -### Get Request Body - -`GetRequestBody` returns the request body as bytes, caching it for reuse. This allows the body to be read multiple times without being consumed, which is useful when middleware and handlers both need access to the same request body. - -```go -package main - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" -) - -func main() { - router := gin.Default() - - // Middleware that logs request body size - router.Use(func(c *gin.Context) { - body, err := c.GetRequestBody() - if err != nil { - fmt.Printf("Error reading body: %v\n", err) - return - } - fmt.Printf("Request body size: %d bytes\n", len(body)) - c.Next() - }) - - router.POST("/echo", func(c *gin.Context) { - // Get the body again - this uses the cached version - body, err := c.GetRequestBody() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // You can also use it with binding methods - var jsonData map[string]interface{} - if err := c.ShouldBindBodyWithJSON(&jsonData); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "body": string(body), - "parsed": jsonData, - }) - }) - - router.Run(":8080") -} -``` - -Test it with: - -```sh -curl -X POST -H "Content-Type: application/json" \ - -d '{"message":"hello","count":42}' \ - http://localhost:8080/echo -``` - -This will output both the raw body and the parsed JSON, demonstrating that the body can be accessed multiple times. - ### Bind custom unmarshaler ```go From 4c81c2a933738dc74934576edc3173eaa688c769 Mon Sep 17 00:00:00 2001 From: ljluestc Date: Sat, 29 Nov 2025 14:45:35 -0800 Subject: [PATCH 5/5] fix tests --- gin_integration_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/gin_integration_test.go b/gin_integration_test.go index e040993a..eb2c0c5c 100644 --- a/gin_integration_test.go +++ b/gin_integration_test.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "sync" "testing" @@ -64,7 +65,16 @@ func testRequest(t *testing.T, params ...string) { } func TestRunEmpty(t *testing.T) { - os.Setenv("PORT", "") + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + require.NoError(t, err) + l, err := net.ListenTCP("tcp", addr) + require.NoError(t, err) + port := strconv.Itoa(l.Addr().(*net.TCPAddr).Port) + l.Close() + + os.Setenv("PORT", port) + defer os.Unsetenv("PORT") + router := New() go func() { router.GET("/example", func(c *Context) { c.String(http.StatusOK, "it worked") }) @@ -74,8 +84,8 @@ func TestRunEmpty(t *testing.T) { // otherwise the main thread will complete time.Sleep(5 * time.Millisecond) - require.Error(t, router.Run(":8080")) - testRequest(t, "http://localhost:8080/example") + require.Error(t, router.Run(":"+port)) + testRequest(t, "http://localhost:"+port+"/example") } func TestBadTrustedCIDRs(t *testing.T) {