Skip to content

Commit d451979

Browse files
mromaszewiczclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 5bb2934 commit d451979

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
@@ -444,7 +444,24 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
444444
case reflect.Slice:
445445
err = bindSplitPartsToDestinationArray(parts, output)
446446
case reflect.Struct:
447-
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
447+
// Some struct types (e.g. types.Date, time.Time) are scalar values
448+
// that should be bound from a single string, not decomposed as
449+
// key-value objects. Detect these via the Binder and
450+
// TextUnmarshaler interfaces.
451+
switch v := output.(type) {
452+
case Binder:
453+
if len(parts) != 1 {
454+
return fmt.Errorf("multiple values for single value parameter '%s'", paramName)
455+
}
456+
err = v.Bind(parts[0])
457+
case encoding.TextUnmarshaler:
458+
if len(parts) != 1 {
459+
return fmt.Errorf("multiple values for single value parameter '%s'", paramName)
460+
}
461+
err = v.UnmarshalText([]byte(parts[0]))
462+
default:
463+
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
464+
}
448465
default:
449466
if len(parts) == 0 {
450467
if required {

bindparam_test.go

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

339+
// Regression tests for https://github.com/oapi-codegen/runtime/issues/21
340+
// types.Date should bind correctly as a query parameter in all configurations.
341+
t.Run("date_form_explode_required", func(t *testing.T) {
342+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
343+
var date types.Date
344+
queryParams := url.Values{
345+
"date": {"2023-01-01"},
346+
}
347+
err := BindQueryParameter("form", true, true, "date", queryParams, &date)
348+
assert.NoError(t, err)
349+
assert.Equal(t, expectedDate, date)
350+
})
351+
352+
t.Run("date_form_explode_optional", func(t *testing.T) {
353+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
354+
var date *types.Date
355+
queryParams := url.Values{
356+
"date": {"2023-01-01"},
357+
}
358+
err := BindQueryParameter("form", true, false, "date", queryParams, &date)
359+
assert.NoError(t, err)
360+
require.NotNil(t, date)
361+
assert.Equal(t, expectedDate, *date)
362+
})
363+
364+
t.Run("date_form_explode_optional_missing", func(t *testing.T) {
365+
var date *types.Date
366+
queryParams := url.Values{}
367+
err := BindQueryParameter("form", true, false, "date", queryParams, &date)
368+
assert.NoError(t, err)
369+
assert.Nil(t, date)
370+
})
371+
372+
t.Run("date_form_no_explode_required", func(t *testing.T) {
373+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
374+
var date types.Date
375+
queryParams := url.Values{
376+
"date": {"2023-01-01"},
377+
}
378+
err := BindQueryParameter("form", false, true, "date", queryParams, &date)
379+
assert.NoError(t, err)
380+
assert.Equal(t, expectedDate, date)
381+
})
382+
383+
t.Run("date_form_no_explode_optional", func(t *testing.T) {
384+
expectedDate := types.Date{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}
385+
var date *types.Date
386+
queryParams := url.Values{
387+
"date": {"2023-01-01"},
388+
}
389+
err := BindQueryParameter("form", false, false, "date", queryParams, &date)
390+
assert.NoError(t, err)
391+
require.NotNil(t, date)
392+
assert.Equal(t, expectedDate, *date)
393+
})
394+
395+
// time.Time has the same bug as types.Date for form/no-explode.
396+
t.Run("time_form_no_explode_required", func(t *testing.T) {
397+
expectedTime := time.Date(2020, 12, 9, 16, 9, 53, 0, time.UTC)
398+
var ts time.Time
399+
queryParams := url.Values{
400+
"ts": {"2020-12-09T16:09:53Z"},
401+
}
402+
err := BindQueryParameter("form", false, true, "ts", queryParams, &ts)
403+
assert.NoError(t, err)
404+
assert.Equal(t, expectedTime, ts)
405+
})
406+
407+
t.Run("date_in_struct_form_explode", func(t *testing.T) {
408+
type Params struct {
409+
Name string `json:"name"`
410+
StartDate types.Date `json:"start_date"`
411+
}
412+
queryParams := url.Values{
413+
"name": {"test"},
414+
"start_date": {"2023-06-15"},
415+
}
416+
var params Params
417+
err := BindQueryParameter("form", true, true, "params", queryParams, &params)
418+
assert.NoError(t, err)
419+
assert.Equal(t, "test", params.Name)
420+
assert.Equal(t, types.Date{Time: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)}, params.StartDate)
421+
})
422+
423+
t.Run("date_pointer_in_struct_form_explode", func(t *testing.T) {
424+
type Params struct {
425+
Name string `json:"name"`
426+
StartDate *types.Date `json:"start_date"`
427+
}
428+
queryParams := url.Values{
429+
"name": {"test"},
430+
"start_date": {"2023-06-15"},
431+
}
432+
var params Params
433+
err := BindQueryParameter("form", true, true, "params", queryParams, &params)
434+
assert.NoError(t, err)
435+
assert.Equal(t, "test", params.Name)
436+
require.NotNil(t, params.StartDate)
437+
assert.Equal(t, types.Date{Time: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)}, *params.StartDate)
438+
})
439+
339440
t.Run("optional", func(t *testing.T) {
340441
queryParams := url.Values{
341442
"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)