From 629ff6e8e2199bcd0c992f841f8bee3a8af52c52 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 26 Mar 2026 09:32:22 -0700 Subject: [PATCH 1/2] feat: support spaceDelimited and pipeDelimited query parameter binding Implement deserialization for spaceDelimited and pipeDelimited query parameter styles in both BindQueryParameterWithOptions and BindRawQueryParameter. For explode=true, these styles are serialized identically to form explode=true (each value is a separate key=value pair), so they share the same code path. For explode=false, the values are split on their style-specific delimiter (space or pipe) instead of comma. The BindRawQueryParameter implementation handles all space representations (%20, +, and literal space) for spaceDelimited. Fixes #116 Co-Authored-By: Claude Opus 4.6 (1M context) --- bindparam.go | 44 ++++++-- bindparam_test.go | 252 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 272 insertions(+), 24 deletions(-) diff --git a/bindparam.go b/bindparam.go index b7dd324..667da14 100644 --- a/bindparam.go +++ b/bindparam.go @@ -417,13 +417,17 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa k := t.Kind() switch style { - case "form": + case "form", "spaceDelimited", "pipeDelimited": var parts []string if explode { // ok, the explode case in query arguments is very, very annoying, // because an exploded object, such as /users?role=admin&firstName=Alex // isn't actually present in the parameter array. We have to do // different things based on destination type. + // + // Note: spaceDelimited and pipeDelimited with explode=true are + // serialized identically to form explode=true (each value is a + // separate key=value pair), so we share this code path. values, found := queryParams[paramName] var err error @@ -510,7 +514,14 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa if len(values) != 1 { return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName) } - parts = strings.Split(values[0], ",") + switch style { + case "spaceDelimited": + parts = strings.Split(values[0], " ") + case "pipeDelimited": + parts = strings.Split(values[0], "|") + default: + parts = strings.Split(values[0], ",") + } } var err error switch k { @@ -571,8 +582,6 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa return errors.New("deepObjects must be exploded") } return unmarshalDeepObject(dest, paramName, queryParams, required) - case "spaceDelimited", "pipeDelimited": - return fmt.Errorf("query arguments of style '%s' aren't yet supported", style) default: return fmt.Errorf("style '%s' on parameter '%s' is invalid", style, paramName) @@ -655,10 +664,14 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName k := t.Kind() switch style { - case "form": + case "form", "spaceDelimited", "pipeDelimited": if explode { // For the explode case, url.ParseQuery is fine — there are no // delimiter commas to confuse with literal commas. + // + // Note: spaceDelimited and pipeDelimited with explode=true are + // serialized identically to form explode=true (each value is a + // separate key=value pair), so we share this code path. queryParams, err := url.ParseQuery(rawQuery) if err != nil { return fmt.Errorf("error parsing query string: %w", err) @@ -707,9 +720,8 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName return nil } - // form, explode=false — the core fix. - // Use findRawQueryParam to get the still-encoded value, split on - // literal ',' (which is the OpenAPI delimiter), then URL-decode + // explode=false — use findRawQueryParam to get the still-encoded + // value, split on the style-specific delimiter, then URL-decode // each resulting part individually. rawValues, found := findRawQueryParam(rawQuery, paramName) if !found { @@ -722,7 +734,19 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName) } - rawParts := strings.Split(rawValues[0], ",") + var rawParts []string + switch style { + case "spaceDelimited": + // Normalise all space representations to %20, then split. + normalized := strings.ReplaceAll(rawValues[0], "+", "%20") + normalized = strings.ReplaceAll(normalized, " ", "%20") + rawParts = strings.Split(normalized, "%20") + case "pipeDelimited": + rawParts = strings.Split(rawValues[0], "|") + default: + rawParts = strings.Split(rawValues[0], ",") + } + parts := make([]string, len(rawParts)) for i, rp := range rawParts { decoded, err := url.QueryUnescape(rp) @@ -767,8 +791,6 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName return fmt.Errorf("error parsing query string: %w", err) } return UnmarshalDeepObject(dest, paramName, queryParams) - case "spaceDelimited", "pipeDelimited": - return fmt.Errorf("query arguments of style '%s' aren't yet supported", style) default: return fmt.Errorf("style '%s' on parameter '%s' is invalid", style, paramName) } diff --git a/bindparam_test.go b/bindparam_test.go index 01e825f..d99638d 100644 --- a/bindparam_test.go +++ b/bindparam_test.go @@ -1045,19 +1045,8 @@ func TestBindRawQueryParameter(t *testing.T) { assert.Contains(t, err.Error(), "exploded") }) - t.Run("spaceDelimited", func(t *testing.T) { - var dest []string - err := BindRawQueryParameter("spaceDelimited", false, true, "color", "color=a%20b%20c", &dest) - assert.Error(t, err) - assert.Contains(t, err.Error(), "spaceDelimited") - }) - - t.Run("pipeDelimited", func(t *testing.T) { - var dest []string - err := BindRawQueryParameter("pipeDelimited", false, true, "color", "color=a|b|c", &dest) - assert.Error(t, err) - assert.Contains(t, err.Error(), "pipeDelimited") - }) + // Note: spaceDelimited and pipeDelimited are now supported + // and have their own test functions below. t.Run("unknown style", func(t *testing.T) { var dest string @@ -1335,3 +1324,240 @@ func TestBindStyledParameter_HeaderWithCommas(t *testing.T) { assert.Equal(t, []string{"a", "b", "c"}, dest) }) } + +func TestBindQueryParameter_SpaceDelimited(t *testing.T) { + t.Run("unexploded int array", func(t *testing.T) { + var dest []int + q := make(url.Values) + q.Set("ids", "3 4 5") + err := BindQueryParameterWithOptions("spaceDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("unexploded string array", func(t *testing.T) { + var dest []string + q := make(url.Values) + q.Set("color", "red green blue") + err := BindQueryParameterWithOptions("spaceDelimited", false, true, "color", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Equal(t, []string{"red", "green", "blue"}, dest) + }) + + t.Run("exploded int array", func(t *testing.T) { + var dest []int + q := make(url.Values) + q["ids"] = []string{"3", "4", "5"} + err := BindQueryParameterWithOptions("spaceDelimited", true, true, "ids", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("optional missing", func(t *testing.T) { + var dest *[]int + q := make(url.Values) + err := BindQueryParameterWithOptions("spaceDelimited", false, false, "ids", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Nil(t, dest) + }) + + t.Run("required missing", func(t *testing.T) { + var dest []int + q := make(url.Values) + err := BindQueryParameterWithOptions("spaceDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) +} + +func TestBindQueryParameter_PipeDelimited(t *testing.T) { + t.Run("unexploded int array", func(t *testing.T) { + var dest []int + q := make(url.Values) + q.Set("ids", "3|4|5") + err := BindQueryParameterWithOptions("pipeDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("unexploded string array", func(t *testing.T) { + var dest []string + q := make(url.Values) + q.Set("color", "red|green|blue") + err := BindQueryParameterWithOptions("pipeDelimited", false, true, "color", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Equal(t, []string{"red", "green", "blue"}, dest) + }) + + t.Run("exploded int array", func(t *testing.T) { + var dest []int + q := make(url.Values) + q["ids"] = []string{"3", "4", "5"} + err := BindQueryParameterWithOptions("pipeDelimited", true, true, "ids", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("optional missing", func(t *testing.T) { + var dest *[]int + q := make(url.Values) + err := BindQueryParameterWithOptions("pipeDelimited", false, false, "ids", q, &dest, BindQueryParameterOptions{}) + require.NoError(t, err) + assert.Nil(t, dest) + }) + + t.Run("required missing", func(t *testing.T) { + var dest []int + q := make(url.Values) + err := BindQueryParameterWithOptions("pipeDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) +} + +func TestBindRawQueryParameter_SpaceDelimited(t *testing.T) { + t.Run("unexploded with %20", func(t *testing.T) { + var dest []int + err := BindRawQueryParameter("spaceDelimited", false, true, "ids", "ids=3%204%205", &dest) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("unexploded with +", func(t *testing.T) { + var dest []int + err := BindRawQueryParameter("spaceDelimited", false, true, "ids", "ids=3+4+5", &dest) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("unexploded strings", func(t *testing.T) { + var dest []string + err := BindRawQueryParameter("spaceDelimited", false, true, "color", "color=red%20green%20blue", &dest) + require.NoError(t, err) + assert.Equal(t, []string{"red", "green", "blue"}, dest) + }) + + t.Run("exploded", func(t *testing.T) { + var dest []int + err := BindRawQueryParameter("spaceDelimited", true, true, "ids", "ids=3&ids=4&ids=5", &dest) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("optional missing", func(t *testing.T) { + var dest *[]int + err := BindRawQueryParameter("spaceDelimited", false, false, "ids", "other=val", &dest) + require.NoError(t, err) + assert.Nil(t, dest) + }) +} + +func TestBindRawQueryParameter_PipeDelimited(t *testing.T) { + t.Run("unexploded", func(t *testing.T) { + var dest []int + err := BindRawQueryParameter("pipeDelimited", false, true, "ids", "ids=3|4|5", &dest) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("unexploded strings", func(t *testing.T) { + var dest []string + err := BindRawQueryParameter("pipeDelimited", false, true, "color", "color=red|green|blue", &dest) + require.NoError(t, err) + assert.Equal(t, []string{"red", "green", "blue"}, dest) + }) + + t.Run("exploded", func(t *testing.T) { + var dest []int + err := BindRawQueryParameter("pipeDelimited", true, true, "ids", "ids=3&ids=4&ids=5", &dest) + require.NoError(t, err) + assert.Equal(t, []int{3, 4, 5}, dest) + }) + + t.Run("optional missing", func(t *testing.T) { + var dest *[]int + err := BindRawQueryParameter("pipeDelimited", false, false, "ids", "other=val", &dest) + require.NoError(t, err) + assert.Nil(t, dest) + }) +} + +func TestRoundTripQueryParameter_Delimited(t *testing.T) { + tests := []struct { + name string + style string + explode bool + paramName string + value interface{} + dest interface{} + expected interface{} + }{ + { + name: "spaceDelimited/false int slice", + style: "spaceDelimited", + explode: false, + paramName: "ids", + value: []int{1, 2, 3}, + dest: &[]int{}, + expected: []int{1, 2, 3}, + }, + { + name: "spaceDelimited/false string slice", + style: "spaceDelimited", + explode: false, + paramName: "color", + value: []string{"red", "green", "blue"}, + dest: &[]string{}, + expected: []string{"red", "green", "blue"}, + }, + { + name: "spaceDelimited/true int slice", + style: "spaceDelimited", + explode: true, + paramName: "ids", + value: []int{1, 2, 3}, + dest: &[]int{}, + expected: []int{1, 2, 3}, + }, + { + name: "pipeDelimited/false int slice", + style: "pipeDelimited", + explode: false, + paramName: "ids", + value: []int{1, 2, 3}, + dest: &[]int{}, + expected: []int{1, 2, 3}, + }, + { + name: "pipeDelimited/false string slice", + style: "pipeDelimited", + explode: false, + paramName: "color", + value: []string{"red", "green", "blue"}, + dest: &[]string{}, + expected: []string{"red", "green", "blue"}, + }, + { + name: "pipeDelimited/true int slice", + style: "pipeDelimited", + explode: true, + paramName: "ids", + value: []int{1, 2, 3}, + dest: &[]int{}, + expected: []int{1, 2, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := StyleParamWithLocation(tt.style, tt.explode, tt.paramName, ParamLocationQuery, tt.value) + require.NoError(t, err, "StyleParamWithLocation failed") + + err = BindRawQueryParameter(tt.style, tt.explode, true, tt.paramName, raw, tt.dest) + require.NoError(t, err, "BindRawQueryParameter failed for raw=%q", raw) + + actual := reflect.ValueOf(tt.dest).Elem().Interface() + assert.Equal(t, tt.expected, actual) + }) + } +} From e9fa7f5e49f201032363b5d729fd98190f1338de Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 26 Mar 2026 18:19:53 -0700 Subject: [PATCH 2/2] feat: add RequiredParameterError typed error for missing required query params Export RequiredParameterError so generated server code can use errors.As to detect missing required parameters and produce framework-specific typed errors for application error handlers. Previously these were plain fmt.Errorf strings that couldn't be distinguished from format errors without string matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- bindparam.go | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/bindparam.go b/bindparam.go index 667da14..3bf2244 100644 --- a/bindparam.go +++ b/bindparam.go @@ -26,6 +26,17 @@ import ( "github.com/oapi-codegen/runtime/types" ) +// RequiredParameterError is returned when a required query parameter is missing. +// Generated server code can use errors.As to detect this and produce a +// framework-specific typed error for the application's error handler. +type RequiredParameterError struct { + ParamName string +} + +func (e *RequiredParameterError) Error() string { + return fmt.Sprintf("query parameter '%s' is required", e.ParamName) +} + // BindStyledParameter binds a parameter as described in the Path Parameters // section here to a Go object: // https://swagger.io/docs/specification/serialization/ @@ -438,7 +449,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa if !found { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } else { // If an optional parameter is not found, we do nothing, return nil @@ -473,7 +484,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa // unmarshal. if len(values) == 0 { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } else { return nil } @@ -484,7 +495,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa if !found { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } else { // If an optional parameter is not found, we do nothing, return nil @@ -506,7 +517,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa values, found := queryParams[paramName] if !found { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } else { return nil } @@ -560,7 +571,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa default: if len(parts) == 0 { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } else { return nil } @@ -682,7 +693,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName case reflect.Slice: if !found { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } return nil } @@ -696,7 +707,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName default: if len(values) == 0 { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } return nil } @@ -705,7 +716,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName } if !found { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } return nil } @@ -726,7 +737,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName rawValues, found := findRawQueryParam(rawQuery, paramName) if !found { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } return nil } @@ -765,7 +776,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName default: if len(parts) == 0 { if required { - return fmt.Errorf("query parameter '%s' is required", paramName) + return &RequiredParameterError{ParamName: paramName} } return nil }