From dc05ab997ec6f5c504f03600a706050492d4b467 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 11 Nov 2026 21:54:26 -0800 Subject: [PATCH] fix(bind): skip empty scalar header values in v4 Backport of #3223. Closes #3169 for v4. --- bind.go | 16 +++++++++++++ bind_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/bind.go b/bind.go index b9cffab09..983ef50ee 100644 --- a/bind.go +++ b/bind.go @@ -198,6 +198,11 @@ func (b *DefaultBinder) bindData(destination interface{}, data map[string][]stri val.Set(reflect.MakeMap(typ)) } for k, v := range data { + // A scalar destination needs a value. Slice destinations can retain + // empty slices, which is their existing binding behavior. + if len(v) == 0 && !isElemSliceOfStrings { + continue + } if isElemString { val.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(v[0])) } else if isElemInterface { @@ -278,6 +283,17 @@ func (b *DefaultBinder) bindData(destination interface{}, data map[string][]stri if !exists { continue } + if len(inputValue) == 0 { + // Preserve custom multi-value unmarshalling, but otherwise treat + // entries without values as absent (without allocating pointers). + value := structField + if structFieldKind != reflect.Pointer { + value = structField.Addr() + } + if _, multiple := value.Interface().(bindMultipleUnmarshaler); !multiple { + continue + } + } // NOTE: algorithm here is not particularly sophisticated. It probably does not work with absurd types like `**[]*int` // but it is smart enough to handle niche cases like `*int`,`*[]string`,`[]*int` . diff --git a/bind_test.go b/bind_test.go index b2a4f7fa9..a6560de32 100644 --- a/bind_test.go +++ b/bind_test.go @@ -1869,3 +1869,69 @@ func TestOpenAPIFormatTimeBindingEdgeCases(t *testing.T) { }) } } + +func TestBindHeadersEmptyValues(t *testing.T) { + for _, values := range [][]string{nil, {}, {""}, {"value"}} { + t.Run(fmt.Sprintf("%#v", values), func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header["X-Value"] = values + c := New().NewContext(req, httptest.NewRecorder()) + want := "original" + if len(values) > 0 { + want = values[0] + } + var dst = struct { + Value string `header:"X-Value"` + }{Value: "original"} + assert.NotPanics(t, func() { assert.NoError(t, new(DefaultBinder).BindHeaders(c, &dst)) }) + assert.Equal(t, want, dst.Value) + m := map[string]string{"X-Value": "original"} + assert.NotPanics(t, func() { assert.NoError(t, new(DefaultBinder).BindHeaders(c, &m)) }) + assert.Equal(t, want, m["X-Value"]) + mi := map[string]any{"X-Value": "original"} + assert.NotPanics(t, func() { assert.NoError(t, new(DefaultBinder).BindHeaders(c, &mi)) }) + assert.Equal(t, want, mi["X-Value"]) + ms := map[string][]string{} + assert.NoError(t, new(DefaultBinder).BindHeaders(c, &ms)) + assert.Equal(t, values, ms["X-Value"]) + if len(values) == 0 { + var pointer struct { + Value *string `header:"X-Value"` + } + assert.NoError(t, new(DefaultBinder).BindHeaders(c, &pointer)) + assert.Nil(t, pointer.Value) + } + }) + } +} + +type headerValuesUnmarshaler struct { + called bool + values []string +} + +func (v *headerValuesUnmarshaler) UnmarshalParams(values []string) error { + v.called, v.values = true, values + return nil +} + +func TestBindHeadersEmptyValuesCustomUnmarshaler(t *testing.T) { + for _, values := range [][]string{nil, {}} { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header["X-Value"] = values + var dst struct { + Value headerValuesUnmarshaler `header:"X-Value"` + } + assert.NoError(t, new(DefaultBinder).BindHeaders(New().NewContext(req, httptest.NewRecorder()), &dst)) + assert.True(t, dst.Value.called) + assert.Equal(t, values, dst.Value.values) + var pointer struct { + Value *headerValuesUnmarshaler `header:"X-Value"` + } + assert.NoError(t, new(DefaultBinder).BindHeaders(New().NewContext(req, httptest.NewRecorder()), &pointer)) + if assert.NotNil(t, pointer.Value) { + assert.True(t, pointer.Value.called) + assert.Equal(t, values, pointer.Value.values) + } + } +}