Skip to content

Commit b76a24f

Browse files
mromaszewiczclaude
andauthored
fix: bind Date and Time query params as scalar values (#21) (#93)
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 <noreply@anthropic.com>
1 parent 1f844c3 commit b76a24f

File tree

3 files changed

+134
-1
lines changed

3 files changed

+134
-1
lines changed

bindparam.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,24 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
509509
err = bindSplitPartsToDestinationArray(parts, output)
510510
}
511511
case reflect.Struct:
512-
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
512+
// Some struct types (e.g. types.Date, time.Time) are scalar values
513+
// that should be bound from a single string, not decomposed as
514+
// key-value objects. Detect these via the Binder and
515+
// TextUnmarshaler interfaces.
516+
switch v := output.(type) {
517+
case Binder:
518+
if len(parts) != 1 {
519+
return fmt.Errorf("multiple values for single value parameter '%s'", paramName)
520+
}
521+
err = v.Bind(parts[0])
522+
case encoding.TextUnmarshaler:
523+
if len(parts) != 1 {
524+
return fmt.Errorf("multiple values for single value parameter '%s'", paramName)
525+
}
526+
err = v.UnmarshalText([]byte(parts[0]))
527+
default:
528+
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
529+
}
513530
default:
514531
if len(parts) == 0 {
515532
if required {

bindparam_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,107 @@ func TestBindQueryParameter(t *testing.T) {
441441
assert.Equal(t, expected, birthday)
442442
})
443443

444+
// Regression tests for https://github.com/oapi-codegen/runtime/issues/21
445+
// types.Date should bind correctly as a query parameter in all configurations.
446+
t.Run("date_form_explode_required", func(t *testing.T) {
447+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
448+
var date types.Date
449+
queryParams := url.Values{
450+
"date": {"2023-01-01"},
451+
}
452+
err := BindQueryParameter("form", true, true, "date", queryParams, &date)
453+
assert.NoError(t, err)
454+
assert.Equal(t, expectedDate, date)
455+
})
456+
457+
t.Run("date_form_explode_optional", func(t *testing.T) {
458+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
459+
var date *types.Date
460+
queryParams := url.Values{
461+
"date": {"2023-01-01"},
462+
}
463+
err := BindQueryParameter("form", true, false, "date", queryParams, &date)
464+
assert.NoError(t, err)
465+
require.NotNil(t, date)
466+
assert.Equal(t, expectedDate, *date)
467+
})
468+
469+
t.Run("date_form_explode_optional_missing", func(t *testing.T) {
470+
var date *types.Date
471+
queryParams := url.Values{}
472+
err := BindQueryParameter("form", true, false, "date", queryParams, &date)
473+
assert.NoError(t, err)
474+
assert.Nil(t, date)
475+
})
476+
477+
t.Run("date_form_no_explode_required", func(t *testing.T) {
478+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
479+
var date types.Date
480+
queryParams := url.Values{
481+
"date": {"2023-01-01"},
482+
}
483+
err := BindQueryParameter("form", false, true, "date", queryParams, &date)
484+
assert.NoError(t, err)
485+
assert.Equal(t, expectedDate, date)
486+
})
487+
488+
t.Run("date_form_no_explode_optional", func(t *testing.T) {
489+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
490+
var date *types.Date
491+
queryParams := url.Values{
492+
"date": {"2023-01-01"},
493+
}
494+
err := BindQueryParameter("form", false, false, "date", queryParams, &date)
495+
assert.NoError(t, err)
496+
require.NotNil(t, date)
497+
assert.Equal(t, expectedDate, *date)
498+
})
499+
500+
// time.Time has the same bug as types.Date for form/no-explode.
501+
t.Run("time_form_no_explode_required", func(t *testing.T) {
502+
expectedTime := time.Date(2020, 12, 9, 16, 9, 53, 0, time.UTC)
503+
var ts time.Time
504+
queryParams := url.Values{
505+
"ts": {"2020-12-09T16:09:53Z"},
506+
}
507+
err := BindQueryParameter("form", false, true, "ts", queryParams, &ts)
508+
assert.NoError(t, err)
509+
assert.Equal(t, expectedTime, ts)
510+
})
511+
512+
t.Run("date_in_struct_form_explode", func(t *testing.T) {
513+
type Params struct {
514+
Name string `json:"name"`
515+
StartDate types.Date `json:"start_date"`
516+
}
517+
queryParams := url.Values{
518+
"name": {"test"},
519+
"start_date": {"2023-06-15"},
520+
}
521+
var params Params
522+
err := BindQueryParameter("form", true, true, "params", queryParams, &params)
523+
assert.NoError(t, err)
524+
assert.Equal(t, "test", params.Name)
525+
assert.Equal(t, types.Date{Time: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)}, params.StartDate)
526+
})
527+
528+
t.Run("date_pointer_in_struct_form_explode", func(t *testing.T) {
529+
type Params struct {
530+
Name string `json:"name"`
531+
StartDate *types.Date `json:"start_date"`
532+
}
533+
queryParams := url.Values{
534+
"name": {"test"},
535+
"start_date": {"2023-06-15"},
536+
}
537+
var params Params
538+
err := BindQueryParameter("form", true, true, "params", queryParams, &params)
539+
assert.NoError(t, err)
540+
assert.Equal(t, "test", params.Name)
541+
require.NotNil(t, params.StartDate)
542+
assert.Equal(t, types.Date{Time: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)}, *params.StartDate)
543+
})
544+
444545
t.Run("optional", func(t *testing.T) {
445546
queryParams := url.Values{
446547
"time": {"2020-12-09T16:09:53+00:00"},

types/date.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,18 @@ func (d *Date) UnmarshalText(data []byte) error {
4141
d.Time = parsed
4242
return nil
4343
}
44+
45+
// Bind implements the runtime.Binder interface so that Date is treated as a
46+
// scalar value when binding query parameters rather than being decomposed as
47+
// a struct with key-value pairs.
48+
func (d *Date) Bind(src string) error {
49+
if src == "" {
50+
return nil
51+
}
52+
parsed, err := time.Parse(DateFormat, src)
53+
if err != nil {
54+
return err
55+
}
56+
d.Time = parsed
57+
return nil
58+
}

0 commit comments

Comments
 (0)