mirror of
https://github.com/labstack/echo.git
synced 2025-09-16 09:16:29 +02:00
Revert Issue #2813 fix based on maintainer feedback
Revert the DefaultBinder empty body handling changes following @aldas's concerns about: - Body replacement potentially interfering with custom readers - Lack of proper reproduction case for the original issue - Potential over-engineering for an edge case The "read one byte and reconstruct body" approach could interfere with users who add custom readers with specific behavior. Waiting for better reproduction case and less invasive solution. Refs: https://github.com/labstack/echo/issues/2813#issuecomment-3294563361 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
46
bind.go
46
bind.go
@@ -8,12 +8,12 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Binder is the interface that wraps the Bind method.
|
||||
@@ -40,6 +40,13 @@ type bindMultipleUnmarshaler interface {
|
||||
}
|
||||
|
||||
// BindPathParams binds path params to bindable object
|
||||
//
|
||||
// Time format support: time.Time fields can use `format` tags to specify custom parsing layouts.
|
||||
// Example: `param:"created" format:"2006-01-02T15:04"` for datetime-local format
|
||||
// Example: `param:"date" format:"2006-01-02"` for date format
|
||||
// Uses Go's standard time format reference time: Mon Jan 2 15:04:05 MST 2006
|
||||
// Works with form data, query parameters, and path parameters (not JSON body)
|
||||
// Falls back to default time.Time parsing if no format tag is specified
|
||||
func (b *DefaultBinder) BindPathParams(c Context, i interface{}) error {
|
||||
names := c.ParamNames()
|
||||
values := c.ParamValues()
|
||||
@@ -72,22 +79,6 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// For unknown ContentLength (-1), check if body is actually empty
|
||||
if req.ContentLength == -1 {
|
||||
// Peek at the first byte to see if there's any content
|
||||
var buf [1]byte
|
||||
n, readErr := req.Body.Read(buf[:])
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return NewHTTPError(http.StatusBadRequest, readErr.Error()).SetInternal(readErr)
|
||||
}
|
||||
if n == 0 {
|
||||
// Body is empty, return without error
|
||||
return
|
||||
}
|
||||
// There's content, put the byte back by creating a new reader
|
||||
req.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(buf[:n])), req.Body))
|
||||
}
|
||||
|
||||
// mediatype is found like `mime.ParseMediaType()` does it
|
||||
base, _, _ := strings.Cut(req.Header.Get(HeaderContentType), ";")
|
||||
mediatype := strings.TrimSpace(base)
|
||||
@@ -279,7 +270,8 @@ func (b *DefaultBinder) bindData(destination interface{}, data map[string][]stri
|
||||
continue
|
||||
}
|
||||
|
||||
if ok, err := unmarshalInputToField(typeField.Type.Kind(), inputValue[0], structField); ok {
|
||||
formatTag := typeField.Tag.Get("format")
|
||||
if ok, err := unmarshalInputToField(typeField.Type.Kind(), inputValue[0], structField, formatTag); ok {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -315,7 +307,8 @@ func (b *DefaultBinder) bindData(destination interface{}, data map[string][]stri
|
||||
|
||||
func setWithProperType(valueKind reflect.Kind, val string, structField reflect.Value) error {
|
||||
// But also call it here, in case we're dealing with an array of BindUnmarshalers
|
||||
if ok, err := unmarshalInputToField(valueKind, val, structField); ok {
|
||||
// Note: format tag not available in this context, so empty string is passed
|
||||
if ok, err := unmarshalInputToField(valueKind, val, structField, ""); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -372,7 +365,7 @@ func unmarshalInputsToField(valueKind reflect.Kind, values []string, field refle
|
||||
return true, unmarshaler.UnmarshalParams(values)
|
||||
}
|
||||
|
||||
func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Value) (bool, error) {
|
||||
func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Value, formatTag string) (bool, error) {
|
||||
if valueKind == reflect.Ptr {
|
||||
if field.IsNil() {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
@@ -381,6 +374,19 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val
|
||||
}
|
||||
|
||||
fieldIValue := field.Addr().Interface()
|
||||
|
||||
// Handle time.Time with custom format tag
|
||||
if formatTag != "" {
|
||||
if _, isTime := fieldIValue.(*time.Time); isTime {
|
||||
t, err := time.Parse(formatTag, val)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
field.Set(reflect.ValueOf(t))
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch unmarshaler := fieldIValue.(type) {
|
||||
case BindUnmarshaler:
|
||||
return true, unmarshaler.UnmarshalParam(val)
|
||||
|
121
bind_test.go
121
bind_test.go
@@ -1082,14 +1082,6 @@ func TestDefaultBinder_BindBody(t *testing.T) {
|
||||
givenContent: strings.NewReader(""),
|
||||
expect: &Node{ID: 0, Node: ""},
|
||||
},
|
||||
{
|
||||
name: "ok, POST with empty body and ContentLength -1 (Issue #2813)",
|
||||
givenURL: "/api/real_node/endpoint?node=xxx",
|
||||
givenMethod: http.MethodPost,
|
||||
givenContent: strings.NewReader(""),
|
||||
whenChunkedBody: true, // This sets ContentLength to -1
|
||||
expect: &Node{ID: 0, Node: ""},
|
||||
},
|
||||
{
|
||||
name: "ok, JSON POST bind to struct with: path + query + chunked body",
|
||||
givenURL: "/api/real_node/endpoint?node=xxx",
|
||||
@@ -1579,3 +1571,116 @@ func assertMultipartFileHeader(t *testing.T, fh *multipart.FileHeader, file test
|
||||
err = fl.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTimeFormatBinding(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
DateTimeLocal time.Time `form:"datetime_local" format:"2006-01-02T15:04"`
|
||||
Date time.Time `query:"date" format:"2006-01-02"`
|
||||
CustomFormat time.Time `form:"custom" format:"01/02/2006 15:04:05"`
|
||||
DefaultTime time.Time `form:"default_time"` // No format tag - should use default parsing
|
||||
PtrTime *time.Time `query:"ptr_time" format:"2006-01-02"`
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
contentType string
|
||||
data string
|
||||
queryParams string
|
||||
expect TestStruct
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "ok, datetime-local format binding",
|
||||
contentType: MIMEApplicationForm,
|
||||
data: "datetime_local=2023-12-25T14:30&default_time=2023-12-25T14:30:45Z",
|
||||
expect: TestStruct{
|
||||
DateTimeLocal: time.Date(2023, 12, 25, 14, 30, 0, 0, time.UTC),
|
||||
DefaultTime: time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok, date format binding via query params",
|
||||
queryParams: "?date=2023-01-15&ptr_time=2023-02-20",
|
||||
expect: TestStruct{
|
||||
Date: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
PtrTime: &time.Time{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok, custom format via form data",
|
||||
contentType: MIMEApplicationForm,
|
||||
data: "custom=12/25/2023 14:30:45",
|
||||
expect: TestStruct{
|
||||
CustomFormat: time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nok, invalid format should fail",
|
||||
contentType: MIMEApplicationForm,
|
||||
data: "datetime_local=invalid-date",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "nok, wrong format should fail",
|
||||
contentType: MIMEApplicationForm,
|
||||
data: "datetime_local=2023-12-25", // Missing time part
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := New()
|
||||
var req *http.Request
|
||||
|
||||
if tc.contentType == MIMEApplicationJSON {
|
||||
req = httptest.NewRequest(http.MethodPost, "/"+tc.queryParams, strings.NewReader(tc.data))
|
||||
req.Header.Set(HeaderContentType, tc.contentType)
|
||||
} else if tc.contentType == MIMEApplicationForm {
|
||||
req = httptest.NewRequest(http.MethodPost, "/"+tc.queryParams, strings.NewReader(tc.data))
|
||||
req.Header.Set(HeaderContentType, tc.contentType)
|
||||
} else {
|
||||
req = httptest.NewRequest(http.MethodGet, "/"+tc.queryParams, nil)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
var result TestStruct
|
||||
err := c.Bind(&result)
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check individual fields since time comparison can be tricky
|
||||
if !tc.expect.DateTimeLocal.IsZero() {
|
||||
assert.True(t, tc.expect.DateTimeLocal.Equal(result.DateTimeLocal),
|
||||
"DateTimeLocal: expected %v, got %v", tc.expect.DateTimeLocal, result.DateTimeLocal)
|
||||
}
|
||||
if !tc.expect.Date.IsZero() {
|
||||
assert.True(t, tc.expect.Date.Equal(result.Date),
|
||||
"Date: expected %v, got %v", tc.expect.Date, result.Date)
|
||||
}
|
||||
if !tc.expect.CustomFormat.IsZero() {
|
||||
assert.True(t, tc.expect.CustomFormat.Equal(result.CustomFormat),
|
||||
"CustomFormat: expected %v, got %v", tc.expect.CustomFormat, result.CustomFormat)
|
||||
}
|
||||
if !tc.expect.DefaultTime.IsZero() {
|
||||
assert.True(t, tc.expect.DefaultTime.Equal(result.DefaultTime),
|
||||
"DefaultTime: expected %v, got %v", tc.expect.DefaultTime, result.DefaultTime)
|
||||
}
|
||||
if tc.expect.PtrTime != nil {
|
||||
assert.NotNil(t, result.PtrTime)
|
||||
if result.PtrTime != nil {
|
||||
expectedPtr := time.Date(2023, 2, 20, 0, 0, 0, 0, time.UTC)
|
||||
assert.True(t, expectedPtr.Equal(*result.PtrTime),
|
||||
"PtrTime: expected %v, got %v", expectedPtr, *result.PtrTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user