From d45197977958f16a079d723fbafc9da535ad56ce Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Mon, 23 Feb 2026 22:28:57 -0800 Subject: [PATCH] fix: bind Date and Time query params as scalar values (#21) BindQueryParameter treated all structs as key-value objects in the non-exploded form path, causing types.Date and time.Time to fail with "property/values need to be pairs". Scalar struct types that implement Binder or encoding.TextUnmarshaler are now bound directly via their interface methods instead of being routed to bindSplitPartsToDestinationStruct. Also adds Binder implementation to types.Date so it self-identifies as a scalar binding target. Co-Authored-By: Claude Opus 4.6 --- bindparam.go | 19 ++++++++- bindparam_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++++ types/date.go | 15 +++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/bindparam.go b/bindparam.go index 435caa4..c383687 100644 --- a/bindparam.go +++ b/bindparam.go @@ -444,7 +444,24 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str case reflect.Slice: err = bindSplitPartsToDestinationArray(parts, output) case reflect.Struct: - err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output) + // Some struct types (e.g. types.Date, time.Time) are scalar values + // that should be bound from a single string, not decomposed as + // key-value objects. Detect these via the Binder and + // TextUnmarshaler interfaces. + switch v := output.(type) { + case Binder: + if len(parts) != 1 { + return fmt.Errorf("multiple values for single value parameter '%s'", paramName) + } + err = v.Bind(parts[0]) + case encoding.TextUnmarshaler: + if len(parts) != 1 { + return fmt.Errorf("multiple values for single value parameter '%s'", paramName) + } + err = v.UnmarshalText([]byte(parts[0])) + default: + err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output) + } default: if len(parts) == 0 { if required { diff --git a/bindparam_test.go b/bindparam_test.go index eec3d3a..3712160 100644 --- a/bindparam_test.go +++ b/bindparam_test.go @@ -336,6 +336,107 @@ func TestBindQueryParameter(t *testing.T) { assert.Equal(t, expected, birthday) }) + // Regression tests for https://github.com/oapi-codegen/runtime/issues/21 + // types.Date should bind correctly as a query parameter in all configurations. + t.Run("date_form_explode_required", func(t *testing.T) { + expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)} + var date types.Date + queryParams := url.Values{ + "date": {"2023-01-01"}, + } + err := BindQueryParameter("form", true, true, "date", queryParams, &date) + assert.NoError(t, err) + assert.Equal(t, expectedDate, date) + }) + + t.Run("date_form_explode_optional", func(t *testing.T) { + expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)} + var date *types.Date + queryParams := url.Values{ + "date": {"2023-01-01"}, + } + err := BindQueryParameter("form", true, false, "date", queryParams, &date) + assert.NoError(t, err) + require.NotNil(t, date) + assert.Equal(t, expectedDate, *date) + }) + + t.Run("date_form_explode_optional_missing", func(t *testing.T) { + var date *types.Date + queryParams := url.Values{} + err := BindQueryParameter("form", true, false, "date", queryParams, &date) + assert.NoError(t, err) + assert.Nil(t, date) + }) + + t.Run("date_form_no_explode_required", func(t *testing.T) { + expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)} + var date types.Date + queryParams := url.Values{ + "date": {"2023-01-01"}, + } + err := BindQueryParameter("form", false, true, "date", queryParams, &date) + assert.NoError(t, err) + assert.Equal(t, expectedDate, date) + }) + + t.Run("date_form_no_explode_optional", func(t *testing.T) { + expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)} + var date *types.Date + queryParams := url.Values{ + "date": {"2023-01-01"}, + } + err := BindQueryParameter("form", false, false, "date", queryParams, &date) + assert.NoError(t, err) + require.NotNil(t, date) + assert.Equal(t, expectedDate, *date) + }) + + // time.Time has the same bug as types.Date for form/no-explode. + t.Run("time_form_no_explode_required", func(t *testing.T) { + expectedTime := time.Date(2020, 12, 9, 16, 9, 53, 0, time.UTC) + var ts time.Time + queryParams := url.Values{ + "ts": {"2020-12-09T16:09:53Z"}, + } + err := BindQueryParameter("form", false, true, "ts", queryParams, &ts) + assert.NoError(t, err) + assert.Equal(t, expectedTime, ts) + }) + + t.Run("date_in_struct_form_explode", func(t *testing.T) { + type Params struct { + Name string `json:"name"` + StartDate types.Date `json:"start_date"` + } + queryParams := url.Values{ + "name": {"test"}, + "start_date": {"2023-06-15"}, + } + var params Params + err := BindQueryParameter("form", true, true, "params", queryParams, ¶ms) + assert.NoError(t, err) + assert.Equal(t, "test", params.Name) + assert.Equal(t, types.Date{Time: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)}, params.StartDate) + }) + + t.Run("date_pointer_in_struct_form_explode", func(t *testing.T) { + type Params struct { + Name string `json:"name"` + StartDate *types.Date `json:"start_date"` + } + queryParams := url.Values{ + "name": {"test"}, + "start_date": {"2023-06-15"}, + } + var params Params + err := BindQueryParameter("form", true, true, "params", queryParams, ¶ms) + assert.NoError(t, err) + assert.Equal(t, "test", params.Name) + require.NotNil(t, params.StartDate) + assert.Equal(t, types.Date{Time: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)}, *params.StartDate) + }) + t.Run("optional", func(t *testing.T) { queryParams := url.Values{ "time": {"2020-12-09T16:09:53+00:00"}, diff --git a/types/date.go b/types/date.go index 6663bdc..155751a 100644 --- a/types/date.go +++ b/types/date.go @@ -41,3 +41,18 @@ func (d *Date) UnmarshalText(data []byte) error { d.Time = parsed return nil } + +// Bind implements the runtime.Binder interface so that Date is treated as a +// scalar value when binding query parameters rather than being decomposed as +// a struct with key-value pairs. +func (d *Date) Bind(src string) error { + if src == "" { + return nil + } + parsed, err := time.Parse(DateFormat, src) + if err != nil { + return err + } + d.Time = parsed + return nil +}