From 50d90fcdb03e8b500ad88378061caef255023d1a Mon Sep 17 00:00:00 2001 From: f-kanari Date: Tue, 7 Apr 2026 11:40:09 +0900 Subject: [PATCH 1/2] fix explode=false behaviour --- bindparam.go | 42 ++++++++++++++++++++++++++++++++++++++++++ bindparam_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/bindparam.go b/bindparam.go index b7dd324..5995431 100644 --- a/bindparam.go +++ b/bindparam.go @@ -510,6 +510,24 @@ 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) } + + // For primitive types, the raw value should be used as-is + // without splitting on commas. Per the OpenAPI specification, + // explode has no effect on primitive types — the serialization + // is the same regardless of the explode value. Comma splitting + // is only meaningful for array and object types. + // See: https://swagger.io/docs/specification/serialization/ + if k != reflect.Slice && k != reflect.Struct && k != reflect.Map { + err := BindStringToObject(values[0], output) + if err != nil { + return err + } + if extraIndirect { + dv.Set(reflect.ValueOf(output)) + } + return nil + } + parts = strings.Split(values[0], ",") } var err error @@ -547,6 +565,9 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output) } default: + // This case is now unreachable for form style with explode=false, + // as primitive types are handled above before comma splitting. + // It remains for other styles that may reach here. if len(parts) == 0 { if required { return fmt.Errorf("query parameter '%s' is required", paramName) @@ -722,6 +743,25 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName) } + // For primitive types, decode the raw value as-is without splitting + // on commas. Per the OpenAPI specification, explode has no effect on + // primitive types. Comma splitting is only meaningful for array and + // object types. + if k != reflect.Slice && k != reflect.Struct && k != reflect.Map { + decoded, err := url.QueryUnescape(rawValues[0]) + if err != nil { + return fmt.Errorf("error decoding query parameter '%s' value %q: %w", paramName, rawValues[0], err) + } + err = BindStringToObject(decoded, output) + if err != nil { + return err + } + if extraIndirect { + dv.Set(reflect.ValueOf(output)) + } + return nil + } + rawParts := strings.Split(rawValues[0], ",") parts := make([]string, len(rawParts)) for i, rp := range rawParts { @@ -739,6 +779,8 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName case reflect.Struct: err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output) default: + // Unreachable for form style with explode=false, as primitive + // types are handled above. Remains for other styles. if len(parts) == 0 { if required { return fmt.Errorf("query parameter '%s' is required", paramName) diff --git a/bindparam_test.go b/bindparam_test.go index 01e825f..8910e7c 100644 --- a/bindparam_test.go +++ b/bindparam_test.go @@ -567,6 +567,30 @@ func TestBindQueryParameter(t *testing.T) { assert.Equal(t, expectedDate, *date) }) + // Regression test: primitive string with explode=false should not be + // split on commas. Per the OpenAPI specification, explode has no effect + // on primitive types — the value must be bound as-is. + t.Run("string_form_no_explode_required_with_commas", func(t *testing.T) { + var scope string + queryParams := url.Values{ + "scope": {"openid,profile,email"}, + } + err := BindQueryParameter("form", false, true, "scope", queryParams, &scope) + assert.NoError(t, err) + assert.Equal(t, "openid,profile,email", scope) + }) + + t.Run("string_form_no_explode_optional_with_commas", func(t *testing.T) { + var scope *string + queryParams := url.Values{ + "scope": {"openid,profile,email"}, + } + err := BindQueryParameter("form", false, false, "scope", queryParams, &scope) + assert.NoError(t, err) + require.NotNil(t, scope) + assert.Equal(t, "openid,profile,email", *scope) + }) + // 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) @@ -974,6 +998,24 @@ func TestBindRawQueryParameter(t *testing.T) { assert.Equal(t, "red", *dest) }) + // Regression test: primitive string with explode=false should not be + // split on commas. Per the OpenAPI specification, explode has no effect + // on primitive types. + t.Run("string with commas required", func(t *testing.T) { + var dest string + err := BindRawQueryParameter("form", false, true, "scope", "scope=openid%2Cprofile%2Cemail", &dest) + require.NoError(t, err) + assert.Equal(t, "openid,profile,email", dest) + }) + + t.Run("string with commas optional", func(t *testing.T) { + var dest *string + err := BindRawQueryParameter("form", false, false, "scope", "scope=openid%2Cprofile%2Cemail", &dest) + require.NoError(t, err) + require.NotNil(t, dest) + assert.Equal(t, "openid,profile,email", *dest) + }) + t.Run("duplicate param errors", func(t *testing.T) { var dest []string err := BindRawQueryParameter("form", false, true, "color", "color=red&color=blue", &dest) From 53ba42a9dc5f5506a3f9a7acbefe5eec3c1e5a0c Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Wed, 8 Apr 2026 14:06:05 -0700 Subject: [PATCH 2/2] Remove unreachable default branches in non-exploded query binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primitive-type early returns added for the explode=false fix guarantee that only Slice, Struct, and Map kinds reach the post-split switch. The default branches in both BindQueryParameterWithOptions and BindRawQueryParameter are now dead code — remove them. Co-Authored-By: Claude Opus 4.6 (1M context) --- bindparam.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/bindparam.go b/bindparam.go index 831ae11..1fccc87 100644 --- a/bindparam.go +++ b/bindparam.go @@ -586,21 +586,6 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa default: err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output) } - default: - // This case is now unreachable for form style with explode=false, - // as primitive types are handled above before comma splitting. - // It remains for other styles that may reach here. - if len(parts) == 0 { - if required { - return &RequiredParameterError{ParamName: paramName} - } else { - return nil - } - } - if len(parts) != 1 { - return fmt.Errorf("multiple values for single value parameter '%s'", paramName) - } - err = BindStringToObject(parts[0], output) } if err != nil { return err @@ -813,19 +798,6 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName err = bindSplitPartsToDestinationArray(parts, output) case reflect.Struct: err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output) - default: - // Unreachable for form style with explode=false, as primitive - // types are handled above. Remains for other styles. - if len(parts) == 0 { - if required { - return &RequiredParameterError{ParamName: paramName} - } - return nil - } - if len(parts) != 1 { - return fmt.Errorf("multiple values for single value parameter '%s'", paramName) - } - err = BindStringToObject(parts[0], output) } if err != nil { return err