From 82099e26ee2ca4e1737c5ae17d9aaedc84fed9b3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 06:35:01 +0000 Subject: [PATCH 1/2] feat: Add request body size limit and enhance error logging This commit introduces two main improvements to the Touka web framework: 1. **Configurable Request Body Size Limit:** - Added `MaxRequestBodySize int64` to `touka.Engine` (default 10MB). - You can customize this via `engine.SetMaxRequestBodySize()`. - The context methods `GetReqBodyFull()`, `GetReqBodyBuffer()`, and `ShouldBindJSON()` now adhere to this limit. They check `Content-Length` upfront and use `http.MaxBytesReader` to prevent reading excessively large request bodies into memory, enhancing protection against potential DoS attacks or high memory usage. - Added comprehensive unit tests in `context_test.go` for this feature, covering scenarios where the limit is active, disabled, and exceeded. 2. **Enhanced Error Logging in Default Handler:** - The `defaultErrorHandle` in `engine.go` now logs not only the primary error passed to it but also any additional errors collected in `Context.Errors` (via `c.AddError()`). - This provides more comprehensive diagnostic information in the logs without altering the JSON error response structure sent to the client, ensuring backward compatibility. These changes aim to improve the framework's robustness, memory safety, and debuggability. --- context.go | 76 +++++++++++++--- context_test.go | 238 ++++++++++++++++++++++++++++++++++++++++++++++++ engine.go | 37 +++++++- 3 files changed, 336 insertions(+), 15 deletions(-) create mode 100644 context_test.go diff --git a/context.go b/context.go index 0620c41..a7d77e2 100644 --- a/context.go +++ b/context.go @@ -330,14 +330,27 @@ func (c *Context) ShouldBindJSON(obj interface{}) error { if c.Request.Body == nil { return errors.New("request body is empty") } - /* - decoder := json.NewDecoder(c.Request.Body) - if err := decoder.Decode(obj); err != nil { - return fmt.Errorf("json binding error: %w", err) + // defer c.Request.Body.Close() // 通常由调用方或中间件确保关闭,但如果这里是唯一消耗点,可以考虑 + + var reader io.Reader = c.Request.Body + if c.engine != nil && c.engine.MaxRequestBodySize > 0 { + if c.Request.ContentLength != -1 && c.Request.ContentLength > c.engine.MaxRequestBodySize { + return fmt.Errorf("request body size (%d bytes) exceeds configured limit (%d bytes)", c.Request.ContentLength, c.engine.MaxRequestBodySize) } - */ - err := json.UnmarshalRead(c.Request.Body, obj) + // 注意:http.MaxBytesReader(nil, ...) 中的 nil ResponseWriter 参数意味着当超出限制时, + // MaxBytesReader 会直接返回错误,而不会尝试写入 HTTP 错误响应。这对于 API 来说是合适的。 + reader = http.MaxBytesReader(nil, c.Request.Body, c.engine.MaxRequestBodySize) + } + + err := json.UnmarshalRead(reader, obj) if err != nil { + // 检查错误类型是否为 http.MaxBytesError + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return fmt.Errorf("request body size exceeds configured limit (%d bytes): %w", c.engine.MaxRequestBodySize, err) + } + // 检查是否是 json 相关的错误,可能需要更细致的错误处理 + // 例如,json.SyntaxError, json.UnmarshalTypeError 等 return fmt.Errorf("json binding error: %w", err) } return nil @@ -441,13 +454,31 @@ func (c *Context) GetReqBody() io.ReadCloser { // 注意:请求体只能读取一次 func (c *Context) GetReqBodyFull() ([]byte, error) { if c.Request.Body == nil { - return nil, nil + return nil, nil // 或者返回一个错误: errors.New("request body is nil") } defer c.Request.Body.Close() // 确保请求体被关闭 - data, err := io.ReadAll(c.Request.Body) + + var reader io.Reader = c.Request.Body + if c.engine != nil && c.engine.MaxRequestBodySize > 0 { + if c.Request.ContentLength != -1 && c.Request.ContentLength > c.engine.MaxRequestBodySize { + err := fmt.Errorf("request body size (%d bytes) exceeds configured limit (%d bytes)", c.Request.ContentLength, c.engine.MaxRequestBodySize) + c.AddError(err) + return nil, err + } + reader = http.MaxBytesReader(nil, c.Request.Body, c.engine.MaxRequestBodySize) + } + + data, err := io.ReadAll(reader) if err != nil { - c.AddError(fmt.Errorf("failed to read request body: %w", err)) - return nil, fmt.Errorf("failed to read request body: %w", err) + // 检查错误类型是否为 http.MaxBytesError,如果是,则表示超出了限制 + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + err = fmt.Errorf("request body size exceeds configured limit (%d bytes): %w", c.engine.MaxRequestBodySize, err) + } else { + err = fmt.Errorf("failed to read request body: %w", err) + } + c.AddError(err) + return nil, err } return data, nil } @@ -455,13 +486,30 @@ func (c *Context) GetReqBodyFull() ([]byte, error) { // 类似 GetReqBodyFull, 返回 *bytes.Buffer func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) { if c.Request.Body == nil { - return nil, nil + return nil, nil // 或者返回一个错误: errors.New("request body is nil") } defer c.Request.Body.Close() // 确保请求体被关闭 - data, err := io.ReadAll(c.Request.Body) + + var reader io.Reader = c.Request.Body + if c.engine != nil && c.engine.MaxRequestBodySize > 0 { + if c.Request.ContentLength != -1 && c.Request.ContentLength > c.engine.MaxRequestBodySize { + err := fmt.Errorf("request body size (%d bytes) exceeds configured limit (%d bytes)", c.Request.ContentLength, c.engine.MaxRequestBodySize) + c.AddError(err) + return nil, err + } + reader = http.MaxBytesReader(nil, c.Request.Body, c.engine.MaxRequestBodySize) + } + + data, err := io.ReadAll(reader) if err != nil { - c.AddError(fmt.Errorf("failed to read request body: %w", err)) - return nil, fmt.Errorf("failed to read request body: %w", err) + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + err = fmt.Errorf("request body size exceeds configured limit (%d bytes): %w", c.engine.MaxRequestBodySize, err) + } else { + err = fmt.Errorf("failed to read request body: %w", err) + } + c.AddError(err) + return nil, err } return bytes.NewBuffer(data), nil } diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..5250788 --- /dev/null +++ b/context_test.go @@ -0,0 +1,238 @@ +package touka + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type TestJSON struct { + Name string `json:"name"` + Value int `json:"value"` +} + +func TestGetReqBodyFull_Limit(t *testing.T) { + smallLimit := int64(10) + largeBody := "this is a body larger than 10 bytes" + smallBody := "small" + + // Scenario 1: Request body larger than limit + t.Run("BodyLargerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + _, err := c.GetReqBodyFull() + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + }) + + // Scenario 2: ContentLength header larger than limit + t.Run("ContentLengthLargerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) // Actual body is small + req.ContentLength = smallLimit + 1 + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + _, err := c.GetReqBodyFull() + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size (%d bytes) exceeds configured limit (%d bytes)", smallLimit+1, smallLimit)) + }) + + // Scenario 3: Request body smaller than limit + t.Run("BodySmallerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) + req.ContentLength = int64(len(smallBody)) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + bodyBytes, err := c.GetReqBodyFull() + assert.NoError(t, err) + assert.Equal(t, smallBody, string(bodyBytes)) + }) + + // Scenario 4: Request body slightly larger than limit, but no ContentLength + // http.MaxBytesReader will still catch this + t.Run("BodySlightlyLargerNoContentLength", func(t *testing.T) { + slightlyLargeBody := "elevenbytes" // 11 bytes + req, _ := http.NewRequest("POST", "/", strings.NewReader(slightlyLargeBody)) + // No ContentLength header + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) // Limit is 10 + + _, err := c.GetReqBodyFull() + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + }) +} + +func TestShouldBindJSON_Limit(t *testing.T) { + smallLimit := int64(20) + validJSON := `{"name":"test","value":1}` // approx 25 bytes, check exact + largeJSON := `{"name":"this is a very long name","value":12345}` + smallValidJSON := `{"name":"s","v":1}` // small enough + + // Scenario 1: JSON body larger than limit + t.Run("JSONLargerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeJSON)) + req.Header.Set("Content-Type", "application/json") + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + var data TestJSON + err := c.ShouldBindJSON(&data) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + }) + + // Scenario 2: ContentLength header larger than limit for JSON + t.Run("ContentLengthLargerThanLimitJSON", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallValidJSON)) // Actual body is small + req.Header.Set("Content-Type", "application/json") + req.ContentLength = smallLimit + 1 + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + var data TestJSON + err := c.ShouldBindJSON(&data) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size (%d bytes) exceeds configured limit (%d bytes)", smallLimit+1, smallLimit)) + }) + + // Scenario 3: JSON body smaller than limit + t.Run("JSONSmallerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(validJSON)) + req.Header.Set("Content-Type", "application/json") + // Set a limit that is larger than the validJSON + engineLimit := int64(len(validJSON) + 5) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(engineLimit) + + + var data TestJSON + err := c.ShouldBindJSON(&data) + assert.NoError(t, err) + assert.Equal(t, "test", data.Name) + assert.Equal(t, 1, data.Value) + }) + + // Scenario 4: JSON body (no content length) slightly larger than limit + t.Run("JSONSlightlyLargerNoContentLength", func(t *testing.T) { + // This JSON is `{"name":"abcde","value":1}` which is 24 bytes. Limit is 20. + slightlyLargeJSON := `{"name":"abcde","value":1}` + req, _ := http.NewRequest("POST", "/", strings.NewReader(slightlyLargeJSON)) + req.Header.Set("Content-Type", "application/json") + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) // Limit is 20 + + var data TestJSON + err := c.ShouldBindJSON(&data) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + }) +} + +func TestMaxRequestBodySize_Disabled(t *testing.T) { + largeBody := strings.Repeat("a", 20*1024*1024) // 20MB body + largeJSON := `{"name":"` + strings.Repeat("b", 5*1024*1024) + `","value":1}` // Large JSON + + // Scenario 1: GetReqBodyFull with MaxRequestBodySize = 0 + t.Run("GetReqBodyFull_DisabledZero", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(0) // Disable limit + + bodyBytes, err := c.GetReqBodyFull() + assert.NoError(t, err) + assert.Equal(t, largeBody, string(bodyBytes)) + }) + + // Scenario 2: GetReqBodyFull with MaxRequestBodySize = -1 + t.Run("GetReqBodyFull_DisabledNegative", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(-1) // Disable limit + + bodyBytes, err := c.GetReqBodyFull() + assert.NoError(t, err) + assert.Equal(t, largeBody, string(bodyBytes)) + }) + + // Scenario 3: ShouldBindJSON with MaxRequestBodySize = 0 + t.Run("ShouldBindJSON_DisabledZero", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeJSON)) + req.Header.Set("Content-Type", "application/json") + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(0) // Disable limit + + var data TestJSON + err := c.ShouldBindJSON(&data) + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(data.Name, "bbb")) // Just check prefix of large name + assert.Equal(t, 1, data.Value) + }) + + // Scenario 4: ShouldBindJSON with MaxRequestBodySize = -1 + t.Run("ShouldBindJSON_DisabledNegative", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeJSON)) + req.Header.Set("Content-Type", "application/json") + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(-1) // Disable limit + + var data TestJSON + err := c.ShouldBindJSON(&data) + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(data.Name, "bbb")) + assert.Equal(t, 1, data.Value) + }) +} + +// TestGetReqBodyBuffer_Limit (Optional, as logic is very similar to GetReqBodyFull) +// You can add tests for GetReqBodyBuffer if you want explicit coverage, +// but its core limiting logic is identical to GetReqBodyFull. +func TestGetReqBodyBuffer_Limit(t *testing.T) { + smallLimit := int64(10) + largeBody := "this is a body larger than 10 bytes" + smallBody := "small" + + // Scenario 1: Request body larger than limit + t.Run("BufferBodyLargerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + _, err := c.GetReqBodyBuffer() + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + }) + + // Scenario 2: ContentLength header larger than limit + t.Run("BufferContentLengthLargerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) + req.ContentLength = smallLimit + 1 + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + _, err := c.GetReqBodyBuffer() + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("request body size (%d bytes) exceeds configured limit (%d bytes)", smallLimit+1, smallLimit)) + }) + + // Scenario 3: Request body smaller than limit + t.Run("BufferBodySmallerThanLimit", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) + req.ContentLength = int64(len(smallBody)) + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + engine.SetMaxRequestBodySize(smallLimit) + + buffer, err := c.GetReqBodyBuffer() + assert.NoError(t, err) + assert.Equal(t, smallBody, buffer.String()) + }) +} diff --git a/engine.go b/engine.go index ff47fc3..4922664 100644 --- a/engine.go +++ b/engine.go @@ -74,6 +74,8 @@ type Engine struct { // 如果设置了此回调,它将优先于 ServerConfigurator 被用于 HTTPS 服务器 // 如果未设置,HTTPS 服务器将回退使用 ServerConfigurator (如果已设置) TLSServerConfigurator func(*http.Server) + + MaxRequestBodySize int64 // 限制读取Body的最大字节数 } type ErrorHandle struct { @@ -87,12 +89,39 @@ type ErrorHandler func(c *Context, code int, err error) func defaultErrorHandle(c *Context, code int, err error) { // 检查客户端是否已断开连接 select { case <-c.Request.Context().Done(): - + // 客户端断开连接,无需进一步处理 return default: + // 检查响应是否已经写入 if c.Writer.Written() { return } + + // 收集错误信息用于日志记录 + primaryErrStr := "none" + if err != nil { + primaryErrStr = err.Error() + } + + var collectedErrors []string + for _, e := range c.GetErrors() { + collectedErrors = append(collectedErrors, e.Error()) + } + collectedErrorsStr := strings.Join(collectedErrors, "; ") + if collectedErrorsStr == "" { + collectedErrorsStr = "none" + } + + // 记录错误日志 + logMessage := fmt.Sprintf("[Touka ErrorHandler] Request: [%s] %s | Primary Error: %s | Collected Errors: %s", + c.Request.Method, c.Request.URL.Path, primaryErrStr, collectedErrorsStr) + + if c.engine != nil && c.engine.LogReco != nil { + c.engine.LogReco.Error(logMessage) + } else { + log.Println(logMessage) // Fallback to standard logger + } + // 输出json 状态码与状态码对应描述 var errMsg string if err != nil { @@ -160,6 +189,7 @@ func New() *Engine { noRoutes: make(HandlersChain, 0), ServerConfigurator: nil, TLSServerConfigurator: nil, + MaxRequestBodySize: 10 * 1024 * 1024, // 默认 10MB } //engine.SetProtocols(GetDefaultProtocolsConfig()) engine.SetDefaultProtocols() @@ -189,6 +219,11 @@ func Default() *Engine { // === 外部操作方法 === +// SetMaxRequestBodySize 设置读取Body的最大字节数 +func (engine *Engine) SetMaxRequestBodySize(size int64) { + engine.MaxRequestBodySize = size +} + // SetServerConfigurator 设置一个函数,该函数将在任何 HTTP 或 HTTPS 服务器 // (通过 RunShutdown, RunTLS, RunTLSRedir) 启动前被调用, // 允许用户对底层的 *http.Server 实例进行自定义配置 From 6339532d7835cf51bc2596f4384d9c814895b6a6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 07:12:05 +0000 Subject: [PATCH 2/2] feat: Implement data binding, enhance HTML rendering, add context tests This commit introduces several significant enhancements and completes key features: 1. **Enhanced HTML Rendering:** - Defined a new `touka.HTMLRender` interface in `engine.go` for pluggable HTML rendering. - `Engine.HTMLRender` now uses this interface type. - `Context.HTML` is updated to use the configured `HTMLRender` instance, with improved error handling if no renderer is set or if rendering fails. The previous `fmt.Sprintf` fallback has been removed. - Added `DefaultHTMLRenderer` (using `html/template`) and helper methods `Engine.LoadHTMLGlob()` and `Engine.SetHTMLTemplate()` for easy setup. 2. **Comprehensive Data Binding:** - **`Context.ShouldBindForm`**: Implemented using `gorilla/schema` to bind `x-www-form-urlencoded` and `multipart/form-data` (fields) from the request body. - **`Context.ShouldBindQuery`**: Implemented using `gorilla/schema` to bind URL query parameters. - **`Context.ShouldBindXML`**: Implemented using `encoding/xml` to bind XML data from the request body. This method also respects the `Engine.MaxRequestBodySize` limit. - **`Context.ShouldBind`**: Implemented as a generic binder that inspects the `Content-Type` header and dispatches to the appropriate specific binder (JSON, XML, Form). It handles missing or unsupported content types. 3. **Comprehensive Unit Tests for `context.go`:** - Massively expanded `context_test.go` to provide extensive test coverage for nearly all methods in `context.go`. - This includes detailed tests for all new data binding methods, the updated HTML rendering logic, state management (`Keys`), request/response utilities, error handling, header and cookie manipulation, streaming, Go context integration, and logging. - Mocks for `HTMLRender`, `ErrorHandler`, and `reco.Logger` were used to facilitate thorough testing. These changes significantly improve the framework's feature set, robustness, and maintainability due to increased test coverage. --- context.go | 218 +++++++- context_test.go | 1385 ++++++++++++++++++++++++++++++++++++++++++++--- engine.go | 32 +- go.mod | 5 +- go.sum | 2 + 5 files changed, 1547 insertions(+), 95 deletions(-) diff --git a/context.go b/context.go index a7d77e2..376cfd4 100644 --- a/context.go +++ b/context.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "encoding/gob" + "encoding/xml" // Added for XML binding "errors" "fmt" "html/template" "io" "math" + "mime" // Added for Content-Type parsing in ShouldBind "net" "net/http" "net/netip" @@ -19,6 +21,7 @@ import ( "github.com/fenthope/reco" "github.com/go-json-experiment/json" + "github.com/gorilla/schema" // Added for form binding "github.com/WJQSERVER-STUDIO/go-utils/copyb" "github.com/WJQSERVER-STUDIO/httpc" @@ -298,21 +301,24 @@ func (c *Context) HTML(code int, name string, obj interface{}) { c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.WriteHeader(code) - if c.engine != nil && c.engine.HTMLRender != nil { - // 假设 HTMLRender 是一个 *template.Template 实例 - if tpl, ok := c.engine.HTMLRender.(*template.Template); ok { - err := tpl.ExecuteTemplate(c.Writer, name, obj) - if err != nil { - c.AddError(fmt.Errorf("failed to render HTML template '%s': %w", name, err)) - //c.String(http.StatusInternalServerError, "Internal Server Error: Failed to render HTML template") - c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to render HTML template '%s': %w", name, err)) - } - return + if c.engine == nil || c.engine.HTMLRender == nil { + errMsg := "HTML renderer not configured" + if c.engine != nil && c.engine.LogReco != nil { + c.engine.LogReco.Error("[Context.HTML] HTMLRender not configured on engine") + } else { + // Fallback logging if LogReco is also nil, though unlikely if engine is not nil + // log.Println("[Context.HTML] HTMLRender not configured on engine") } - // 可以扩展支持其他渲染器接口 + c.ErrorUseHandle(http.StatusInternalServerError, errors.New(errMsg)) + return + } + + err := c.engine.HTMLRender.Render(c.Writer, name, obj, c) + if err != nil { + renderErr := fmt.Errorf("failed to render HTML template '%s': %w", name, err) + c.AddError(renderErr) + c.ErrorUseHandle(http.StatusInternalServerError, renderErr) } - // 默认简单输出,用于未配置 HTMLRender 的情况 - c.Writer.Write([]byte(fmt.Sprintf("\n
%v", name, obj))) } // Redirect 执行 HTTP 重定向 @@ -356,21 +362,181 @@ func (c *Context) ShouldBindJSON(obj interface{}) error { return nil } +// ShouldBindXML 尝试将请求体中的 XML 数据绑定到 obj。 +func (c *Context) ShouldBindXML(obj interface{}) error { + if c.Request == nil || c.Request.Body == nil { + return errors.New("request body is empty for XML binding") + } + // defer c.Request.Body.Close() // Caller is responsible for closing the body + + var reader io.Reader = c.Request.Body + if c.engine != nil && c.engine.MaxRequestBodySize > 0 { + if c.Request.ContentLength != -1 && c.Request.ContentLength > c.engine.MaxRequestBodySize { + return fmt.Errorf("request body size (%d bytes) exceeds XML binding limit (%d bytes)", c.Request.ContentLength, c.engine.MaxRequestBodySize) + } + reader = http.MaxBytesReader(nil, c.Request.Body, c.engine.MaxRequestBodySize) + } + + decoder := xml.NewDecoder(reader) + if err := decoder.Decode(obj); err != nil { + // Check for MaxBytesError specifically + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return fmt.Errorf("request body size exceeds XML binding limit (%d bytes): %w", c.engine.MaxRequestBodySize, err) + } + return fmt.Errorf("xml binding error: %w", err) + } + return nil +} + +// ShouldBindQuery 尝试将 URL 查询参数绑定到 obj。 +// 它使用 gorilla/schema 来执行绑定。 +func (c *Context) ShouldBindQuery(obj interface{}) error { + if c.Request == nil { + return errors.New("request is nil") + } + + if c.queryCache == nil { + c.queryCache = c.Request.URL.Query() + } + values := c.queryCache + + if len(values) == 0 { + // No query parameters to bind + return nil + } + + decoder := schema.NewDecoder() + // decoder.IgnoreUnknownKeys(true) // Optional + + if err := decoder.Decode(obj, values); err != nil { + return fmt.Errorf("query parameter binding error using schema: %w", err) + } + + return nil +} + // ShouldBind 尝试将请求体绑定到各种类型(JSON, Form, XML 等) -// 这是一个复杂的通用绑定接口,通常根据 Content-Type 或其他头部来判断绑定方式 -// 预留接口,可根据项目需求进行扩展 +// 根据请求的 Content-Type 自动选择合适的绑定器。 func (c *Context) ShouldBind(obj interface{}) error { - // TODO: 完整的通用绑定逻辑 - // 可以根据 c.Request.Header.Get("Content-Type") 来判断是 JSON, Form, XML 等 - // 例如: - // contentType := c.Request.Header.Get("Content-Type") - // if strings.HasPrefix(contentType, "application/json") { - // return c.ShouldBindJSON(obj) - // } - // if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") || strings.HasPrefix(contentType, "multipart/form-data") { - // return c.ShouldBindForm(obj) // 需要实现 ShouldBindForm - // } - return errors.New("generic binding not fully implemented yet, implement based on Content-Type") + if c.Request == nil { + return errors.New("request is nil for binding") + } + + // If there's no body, no binding from body can occur. + if c.Request.Body == nil || c.Request.Body == http.NoBody { + // Consider if query binding should be attempted for GET requests by default. + // For now, if no body, assume successful (empty) binding from body perspective. + return nil + } + + contentType := c.ContentType() // This uses c.GetReqHeader("Content-Type") + if contentType == "" { + // If there is a body (ContentLength > 0 or chunked) but no Content-Type, this is an issue. + if c.Request.ContentLength > 0 || len(c.Request.TransferEncoding) > 0 { + return errors.New("missing Content-Type header for request body binding") + } + // If no Content-Type and no actual body content indicated, effectively no body to bind. + return nil + } + + mimeType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return fmt.Errorf("error parsing Content-Type header '%s': %w", contentType, err) + } + + switch mimeType { + case "application/json": + return c.ShouldBindJSON(obj) + case "application/xml", "text/xml": + return c.ShouldBindXML(obj) + case "application/x-www-form-urlencoded": + return c.ShouldBindForm(obj) + case "multipart/form-data": + return c.ShouldBindForm(obj) // ShouldBindForm handles multipart fields + default: + return fmt.Errorf("unsupported Content-Type for binding: %s", mimeType) + } +} + +// ShouldBindForm 尝试将请求体中的表单数据绑定到 obj。 +// 它使用 gorilla/schema 来执行绑定。 +// 注意:此方法期望请求体是 application/x-www-form-urlencoded 或 multipart/form-data 类型。 +// 调用此方法前,应确保请求的 Content-Type 是合适的。 +func (c *Context) ShouldBindForm(obj interface{}) error { + if c.Request == nil { + return errors.New("request is nil") + } + + // ParseMultipartForm populates c.Request.PostForm and c.Request.MultipartForm. + // defaultMemory is used to limit the size of memory used for storing file parts. + // If the form data exceeds this, it will be stored in temporary files. + // MaxBytesReader applied earlier (if any) would have limited the total body size. + if err := c.Request.ParseMultipartForm(defaultMemory); err != nil { + // Ignore "http: multipart handled by ParseMultipartForm" error, which means it was already parsed. + // This can happen if a middleware or previous ShouldBind call already parsed the form. + // Other errors (e.g., I/O errors during parsing) should be returned. + // Note: Gorilla schema might not need this if it directly uses r.Form or r.PostForm + // which are populated by ParseForm/ParseMultipartForm. + // For now, we ensure it's parsed. A more specific check might be needed if this causes issues. + // A common error to ignore here is `http.ErrNotMultipart` if the content type isn't multipart, + // as ParseMultipartForm expects that. ParseForm might be more general if we only expect + // x-www-form-urlencoded, but ParseMultipartForm handles both. + // Let's proceed and let schema decoder handle empty PostForm if parsing wasn't applicable. + // However, a direct "request body too large" from MaxBytesReader should have priority if it happened before. + // This specific error from ParseMultipartForm might relate to parts of a valid multipart form being too large for memory, + // not the overall request size. + // For simplicity in this step, we'll return the error unless it's a known "already parsed" scenario (which is not standard). + // A better approach for "already parsed" would be to check if c.Request.PostForm is already populated. + if c.formCache == nil && c.Request.PostForm == nil { // Attempt to parse only if not already cached or populated + if perr := c.Request.ParseMultipartForm(defaultMemory); perr != nil { + // http.ErrNotMultipart is returned if Content-Type is not multipart/form-data + // For x-www-form-urlencoded, ParseForm() is implicitly called by accessing PostForm + // Let's try to populate PostForm if it's not already + if c.Request.PostForm == nil { + if perr2 := c.Request.ParseForm(); perr2 != nil { + return fmt.Errorf("form parse error (ParseForm): %w", perr2) + } + } + // If it was not multipart and ParseForm also failed or PostForm is still nil, + // then we might have an issue. However, gorilla/schema works on `url.Values` + // which `c.Request.PostForm` provides. + // If `Content-Type` was not form-like, `PostForm` would be empty. + } + } + // If `c.formCache` is not nil, `PostForm()` would have already tried parsing. + // We will use `c.Request.PostForm` which gets populated by `ParseMultipartForm` or `ParseForm`. + } + + // Initialize schema decoder + decoder := schema.NewDecoder() + // decoder.IgnoreUnknownKeys(true) // Optional: if you want to ignore fields in form not in struct + + // Get form values. c.Request.PostForm includes values from both + // application/x-www-form-urlencoded and multipart/form-data bodies. + // It needs to be called after ParseMultipartForm or ParseForm. + // Accessing c.Request.PostForm itself can trigger ParseForm if not already parsed and content type is x-www-form-urlencoded. + if err := c.Request.ParseForm(); err != nil && c.Request.PostForm == nil { + // If ParseForm itself errors and PostForm is still nil, then return error. + // This ensures that for x-www-form-urlencoded, parsing is attempted. + // ParseMultipartForm handles multipart, and its error is handled above. + return fmt.Errorf("form parse error (PostForm init): %w", err) + } + + values := c.Request.PostForm + if len(values) == 0 { + // If PostForm is empty, there's nothing to bind from the POST body. + // This is not necessarily an error for schema.Decode, it will just bind zero values. + // Depending on requirements, one might want to return an error here if binding is mandatory. + // For now, we let schema.Decode handle it (it will likely do nothing or bind zero values). + } + + // Decode the form values into the object + if err := decoder.Decode(obj, values); err != nil { + return fmt.Errorf("form binding error using schema: %w", err) + } + + return nil } // AddError 添加一个错误到 Context diff --git a/context_test.go b/context_test.go index 5250788..f470b9f 100644 --- a/context_test.go +++ b/context_test.go @@ -2,17 +2,115 @@ package touka import ( "bytes" + "context" + "encoding/xml" + "errors" "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" + "net/url" "strings" + "sync" "testing" + "time" + "github.com/fenthope/reco" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) -type TestJSON struct { +// --- Test Structures --- + +// TestBindStruct is a common struct used for various binding tests. +type TestBindStruct struct { + Name string `json:"name" xml:"name" form:"name" query:"name" schema:"name"` + Age int `json:"age" xml:"age" form:"age" query:"age" schema:"age"` + IsActive bool `json:"isActive" xml:"isActive" form:"isActive" query:"isActive" schema:"isActive"` + // Add a nested struct for more complex scenarios if needed + // Nested TestNestedStruct `json:"nested" xml:"nested" form:"nested" query:"nested"` +} + +// TestNestedStruct example for future use. +// type TestNestedStruct struct { +// Field string `json:"field" xml:"field" form:"field" query:"field"` +// } + +// mockHTMLRender implements HTMLRender for testing Context.HTML. +type mockHTMLRender struct { + CalledWithWriter io.Writer + CalledWithName string + CalledWithData interface{} + CalledWithCtx *Context + ReturnError error +} + +func (m *mockHTMLRender) Render(writer io.Writer, name string, data interface{}, c *Context) error { + m.CalledWithWriter = writer + m.CalledWithName = name + m.CalledWithData = data + m.CalledWithCtx = c + return m.ReturnError +} + +// mockErrorHandler for testing ErrorUseHandle. +type mockErrorHandler struct { + CalledWithCtx *Context + CalledWithCode int + CalledWithErr error + mutex sync.Mutex +} + +func (m *mockErrorHandler) Handle(c *Context, code int, err error) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.CalledWithCtx = c + m.CalledWithCode = code + m.CalledWithErr = err +} +func (m *mockErrorHandler) GetArgs() (*Context, int, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.CalledWithCtx, m.CalledWithCode, m.CalledWithErr +} + + +// MockRecoLogger is a mock implementation of reco.Logger for testing. +type MockRecoLogger struct { + mock.Mock +} + +func (m *MockRecoLogger) Debugf(format string, args ...any) { m.Called(format, args) } +func (m *MockRecoLogger) Infof(format string, args ...any) { m.Called(format, args) } +func (m *MockRecoLogger) Warnf(format string, args ...any) { m.Called(format, args) } +func (m *MockRecoLogger) Errorf(format string, args ...any) { m.Called(format, args) } +func (m *MockRecoLogger) Fatalf(format string, args ...any) { m.Called(format, args); panic("Fatalf called") } // Panic to simplify test flow +func (m *MockRecoLogger) Panicf(format string, args ...any) { m.Called(format, args); panic("Panicf called") } +func (m *MockRecoLogger) Debug(args ...any) { m.Called(args) } +func (m *MockRecoLogger) Info(args ...any) { m.Called(args) } +func (m *MockRecoLogger) Warn(args ...any) { m.Called(args) } +func (m *MockRecoLogger) Error(args ...any) { m.Called(args) } +func (m *MockRecoLogger) Fatal(args ...any) { m.Called(args); panic("Fatal called") } +func (m *MockRecoLogger) Panic(args ...any) { m.Called(args); panic("Panic called") } +func (m *MockRecoLogger) WithFields(fields map[string]any) *reco.Logger { + args := m.Called(fields) + if logger, ok := args.Get(0).(*reco.Logger); ok { + return logger + } + // In a real mock, you might return a new MockRecoLogger instance configured with these fields. + // For simplicity here, we assume the test won't heavily rely on chaining WithFields. + // Or, ensure your mock reco.Logger has its own WithFields that returns itself or a new mock. + // Fallback: create a new reco.Logger which might not be ideal for asserting chained calls. + fallbackLogger, _ := reco.New(reco.Config{Output: io.Discard}) + return fallbackLogger +} + + +// --- Existing Tests (MaxRequestBodySize, etc.) --- +// (Keeping existing tests as they are valuable) + +type TestJSON struct { // This was the original struct for some limit tests Name string `json:"name"` Value int `json:"value"` } @@ -22,99 +120,87 @@ func TestGetReqBodyFull_Limit(t *testing.T) { largeBody := "this is a body larger than 10 bytes" smallBody := "small" - // Scenario 1: Request body larger than limit t.Run("BodyLargerThanLimit", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - _, err := c.GetReqBodyFull() assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + assert.Contains(t, err.Error(), "request body size exceeds configured limit") }) - // Scenario 2: ContentLength header larger than limit t.Run("ContentLengthLargerThanLimit", func(t *testing.T) { - req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) // Actual body is small + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) req.ContentLength = smallLimit + 1 c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - _, err := c.GetReqBodyFull() assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size (%d bytes) exceeds configured limit (%d bytes)", smallLimit+1, smallLimit)) + assert.Contains(t, err.Error(), "request body size") + assert.Contains(t, err.Error(), "exceeds configured limit") }) - // Scenario 3: Request body smaller than limit t.Run("BodySmallerThanLimit", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) req.ContentLength = int64(len(smallBody)) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - bodyBytes, err := c.GetReqBodyFull() assert.NoError(t, err) assert.Equal(t, smallBody, string(bodyBytes)) }) - // Scenario 4: Request body slightly larger than limit, but no ContentLength - // http.MaxBytesReader will still catch this t.Run("BodySlightlyLargerNoContentLength", func(t *testing.T) { slightlyLargeBody := "elevenbytes" // 11 bytes req, _ := http.NewRequest("POST", "/", strings.NewReader(slightlyLargeBody)) - // No ContentLength header c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) // Limit is 10 - _, err := c.GetReqBodyFull() assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + assert.Contains(t, err.Error(), "request body size exceeds configured limit") }) } -func TestShouldBindJSON_Limit(t *testing.T) { +// Renamed original TestShouldBindJSON_Limit to avoid conflict with new comprehensive TestShouldBindJSON +func TestShouldBindJSON_MaxBodyLimit(t *testing.T) { smallLimit := int64(20) - validJSON := `{"name":"test","value":1}` // approx 25 bytes, check exact - largeJSON := `{"name":"this is a very long name","value":12345}` - smallValidJSON := `{"name":"s","v":1}` // small enough + // Original TestJSON is fine here as we are testing limits, not field variety + largeJSON := `{"name":"this is a very long name that exceeds the small limit","value":12345}` + smallValidJSON := `{"name":"s","v":1}` - // Scenario 1: JSON body larger than limit t.Run("JSONLargerThanLimit", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeJSON)) req.Header.Set("Content-Type", "application/json") c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - var data TestJSON err := c.ShouldBindJSON(&data) assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + assert.Contains(t, err.Error(), "request body size exceeds configured limit") }) - // Scenario 2: ContentLength header larger than limit for JSON t.Run("ContentLengthLargerThanLimitJSON", func(t *testing.T) { - req, _ := http.NewRequest("POST", "/", strings.NewReader(smallValidJSON)) // Actual body is small + req, _ := http.NewRequest("POST", "/", strings.NewReader(smallValidJSON)) req.Header.Set("Content-Type", "application/json") req.ContentLength = smallLimit + 1 c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - var data TestJSON err := c.ShouldBindJSON(&data) assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size (%d bytes) exceeds configured limit (%d bytes)", smallLimit+1, smallLimit)) + assert.Contains(t, err.Error(), "request body size") + assert.Contains(t, err.Error(), "exceeds configured limit") }) - // Scenario 3: JSON body smaller than limit - t.Run("JSONSmallerThanLimit", func(t *testing.T) { - req, _ := http.NewRequest("POST", "/", strings.NewReader(validJSON)) + // This test was a bit ambiguous, using TestJSON for a TestBindStruct scenario. + // Keeping it but clarifying it tests the limit, not comprehensive binding. + t.Run("JSONSmallerThanLimit_MaxBodyTest", func(t *testing.T) { + validJSONSpecific := `{"name":"test","value":1}` // This is TestJSON struct + req, _ := http.NewRequest("POST", "/", strings.NewReader(validJSONSpecific)) req.Header.Set("Content-Type", "application/json") - // Set a limit that is larger than the validJSON - engineLimit := int64(len(validJSON) + 5) + engineLimit := int64(len(validJSONSpecific) + 5) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(engineLimit) - - var data TestJSON err := c.ShouldBindJSON(&data) assert.NoError(t, err) @@ -122,69 +208,58 @@ func TestShouldBindJSON_Limit(t *testing.T) { assert.Equal(t, 1, data.Value) }) - // Scenario 4: JSON body (no content length) slightly larger than limit - t.Run("JSONSlightlyLargerNoContentLength", func(t *testing.T) { - // This JSON is `{"name":"abcde","value":1}` which is 24 bytes. Limit is 20. - slightlyLargeJSON := `{"name":"abcde","value":1}` + t.Run("JSONSlightlyLargerNoContentLength_MaxBodyTest", func(t *testing.T) { + slightlyLargeJSON := `{"name":"abcde","value":1}` // 24 bytes req, _ := http.NewRequest("POST", "/", strings.NewReader(slightlyLargeJSON)) req.Header.Set("Content-Type", "application/json") c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) // Limit is 20 - - var data TestJSON + var data TestJSON // Using original TestJSON for this specific limit test err := c.ShouldBindJSON(&data) assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + assert.Contains(t, err.Error(), "request body size exceeds configured limit") }) } func TestMaxRequestBodySize_Disabled(t *testing.T) { - largeBody := strings.Repeat("a", 20*1024*1024) // 20MB body - largeJSON := `{"name":"` + strings.Repeat("b", 5*1024*1024) + `","value":1}` // Large JSON + largeBody := strings.Repeat("a", 1*1024*1024) // 1MB, reduced for test speed + largeJSON := `{"name":"` + strings.Repeat("b", 1*1024*500) + `","value":1}` - // Scenario 1: GetReqBodyFull with MaxRequestBodySize = 0 t.Run("GetReqBodyFull_DisabledZero", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) - engine.SetMaxRequestBodySize(0) // Disable limit - + engine.SetMaxRequestBodySize(0) bodyBytes, err := c.GetReqBodyFull() assert.NoError(t, err) assert.Equal(t, largeBody, string(bodyBytes)) }) - // Scenario 2: GetReqBodyFull with MaxRequestBodySize = -1 t.Run("GetReqBodyFull_DisabledNegative", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) - engine.SetMaxRequestBodySize(-1) // Disable limit - + engine.SetMaxRequestBodySize(-1) bodyBytes, err := c.GetReqBodyFull() assert.NoError(t, err) assert.Equal(t, largeBody, string(bodyBytes)) }) - // Scenario 3: ShouldBindJSON with MaxRequestBodySize = 0 t.Run("ShouldBindJSON_DisabledZero", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeJSON)) req.Header.Set("Content-Type", "application/json") c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) - engine.SetMaxRequestBodySize(0) // Disable limit - + engine.SetMaxRequestBodySize(0) var data TestJSON err := c.ShouldBindJSON(&data) assert.NoError(t, err) - assert.True(t, strings.HasPrefix(data.Name, "bbb")) // Just check prefix of large name + assert.True(t, strings.HasPrefix(data.Name, "bbb")) assert.Equal(t, 1, data.Value) }) - // Scenario 4: ShouldBindJSON with MaxRequestBodySize = -1 t.Run("ShouldBindJSON_DisabledNegative", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeJSON)) req.Header.Set("Content-Type", "application/json") c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) - engine.SetMaxRequestBodySize(-1) // Disable limit - + engine.SetMaxRequestBodySize(-1) var data TestJSON err := c.ShouldBindJSON(&data) assert.NoError(t, err) @@ -193,46 +268,1224 @@ func TestMaxRequestBodySize_Disabled(t *testing.T) { }) } -// TestGetReqBodyBuffer_Limit (Optional, as logic is very similar to GetReqBodyFull) -// You can add tests for GetReqBodyBuffer if you want explicit coverage, -// but its core limiting logic is identical to GetReqBodyFull. func TestGetReqBodyBuffer_Limit(t *testing.T) { smallLimit := int64(10) largeBody := "this is a body larger than 10 bytes" smallBody := "small" - // Scenario 1: Request body larger than limit t.Run("BufferBodyLargerThanLimit", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(largeBody)) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - _, err := c.GetReqBodyBuffer() assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size exceeds configured limit (%d bytes)", smallLimit)) + assert.Contains(t, err.Error(), "request body size exceeds configured limit") }) - // Scenario 2: ContentLength header larger than limit t.Run("BufferContentLengthLargerThanLimit", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) req.ContentLength = smallLimit + 1 c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - _, err := c.GetReqBodyBuffer() assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("request body size (%d bytes) exceeds configured limit (%d bytes)", smallLimit+1, smallLimit)) + assert.Contains(t, err.Error(), "request body size") + assert.Contains(t, err.Error(), "exceeds configured limit") }) - // Scenario 3: Request body smaller than limit t.Run("BufferBodySmallerThanLimit", func(t *testing.T) { req, _ := http.NewRequest("POST", "/", strings.NewReader(smallBody)) req.ContentLength = int64(len(smallBody)) c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) engine.SetMaxRequestBodySize(smallLimit) - buffer, err := c.GetReqBodyBuffer() assert.NoError(t, err) assert.Equal(t, smallBody, buffer.String()) }) } + + +// --- Phase 1: Binding Methods Tests --- + +func TestShouldBindJSON(t *testing.T) { + defaultLimit := int64(10 * 1024 * 1024) // Assuming default engine limit + + testCases := []struct { + name string + contentType string + body string + maxBodySize *int64 // nil to use engine default, pointer to override + expectedError string // Substring of expected error + expectedData *TestBindStruct + }{ + { + name: "Success", + contentType: "application/json", + body: `{"name":"John Doe","age":30,"isActive":true}`, + expectedData: &TestBindStruct{Name: "John Doe", Age: 30, IsActive: true}, + }, + { + name: "Malformed JSON", + contentType: "application/json", + body: `{"name":"John Doe",`, + expectedError: "json binding error", + }, + { + name: "Empty request body", + contentType: "application/json", + body: "", + expectedError: "request body is empty", // Error from ShouldBindJSON + }, + { + name: "MaxRequestBodySize exceeded", + contentType: "application/json", + body: `{"name":"This body is intentionally made larger than the small limit","age":99,"isActive":false}`, + maxBodySize: func(i int64) *int64 { return &i }(20), + expectedError: "request body size exceeds configured limit", + }, + { + name: "Partial fields", + contentType: "application/json", + body: `{"name":"Jane"}`, + expectedData: &TestBindStruct{Name: "Jane", Age: 0, IsActive: false}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var reqBody io.Reader + if tc.body == "" && tc.name == "Empty request body" { // Special case for ShouldBindJSON expecting non-nil body + reqBody = http.NoBody // http.NewRequest will set body to nil if reader is nil + } else { + reqBody = strings.NewReader(tc.body) + } + + req, _ := http.NewRequest("POST", "/", reqBody) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + if tc.body != "" && tc.name != "Empty request body"{ // Set ContentLength if body is not empty (and not the specific empty body test) + req.ContentLength = int64(len(tc.body)) + } + + + c, engine := CreateTestContextWithRequest(httptest.NewRecorder(), req) + if tc.maxBodySize != nil { + engine.SetMaxRequestBodySize(*tc.maxBodySize) + } else { + engine.SetMaxRequestBodySize(defaultLimit) // Ensure a known default for tests not overriding + } + + var data TestBindStruct + err := c.ShouldBindJSON(&data) + + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + if strings.Contains(tc.expectedError, "MaxBytesError") { // Check specific error type + var maxBytesErr *http.MaxBytesError + assert.ErrorAs(t, err, &maxBytesErr, "Error should be of type *http.MaxBytesError") + } + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedData, &data) + } + }) + } +} + +func TestShouldBindXML(t *testing.T) { + defaultLimit := int64(10 * 1024 * 1024) + + testCases := []struct { + name string + contentType string + body string + maxBodySize *int64 + expectedError string + expectedData *TestBindStruct + }{ + { + name: "Success", + contentType: "application/xml", + body: `