diff --git a/bind.go b/bind.go index bec2e3b3a..0e5ddd469 100644 --- a/bind.go +++ b/bind.go @@ -241,6 +241,11 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m 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 { @@ -322,6 +327,17 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m 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 771c59fb8..c1ef54435 100644 --- a/bind_test.go +++ b/bind_test.go @@ -1873,3 +1873,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, BindHeaders(c, &dst)) }) + assert.Equal(t, want, dst.Value) + m := map[string]string{"X-Value": "original"} + assert.NotPanics(t, func() { assert.NoError(t, BindHeaders(c, &m)) }) + assert.Equal(t, want, m["X-Value"]) + mi := map[string]any{"X-Value": "original"} + assert.NotPanics(t, func() { assert.NoError(t, BindHeaders(c, &mi)) }) + assert.Equal(t, want, mi["X-Value"]) + ms := map[string][]string{} + assert.NoError(t, 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, 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, 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, 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) + } + } +}