Skip to content

Commit dfe6f3b

Browse files
mromaszewiczclaude
andauthored
feat: support spaceDelimited and pipeDelimited query parameter binding (#117)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5ea8c65 commit dfe6f3b

File tree

2 files changed

+293
-34
lines changed

2 files changed

+293
-34
lines changed

bindparam.go

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ import (
2626
"github.com/oapi-codegen/runtime/types"
2727
)
2828

29+
// RequiredParameterError is returned when a required query parameter is missing.
30+
// Generated server code can use errors.As to detect this and produce a
31+
// framework-specific typed error for the application's error handler.
32+
type RequiredParameterError struct {
33+
ParamName string
34+
}
35+
36+
func (e *RequiredParameterError) Error() string {
37+
return fmt.Sprintf("query parameter '%s' is required", e.ParamName)
38+
}
39+
2940
// BindStyledParameter binds a parameter as described in the Path Parameters
3041
// section here to a Go object:
3142
// https://swagger.io/docs/specification/serialization/
@@ -417,13 +428,17 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
417428
k := t.Kind()
418429

419430
switch style {
420-
case "form":
431+
case "form", "spaceDelimited", "pipeDelimited":
421432
var parts []string
422433
if explode {
423434
// ok, the explode case in query arguments is very, very annoying,
424435
// because an exploded object, such as /users?role=admin&firstName=Alex
425436
// isn't actually present in the parameter array. We have to do
426437
// different things based on destination type.
438+
//
439+
// Note: spaceDelimited and pipeDelimited with explode=true are
440+
// serialized identically to form explode=true (each value is a
441+
// separate key=value pair), so we share this code path.
427442
values, found := queryParams[paramName]
428443
var err error
429444

@@ -434,7 +449,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
434449

435450
if !found {
436451
if required {
437-
return fmt.Errorf("query parameter '%s' is required", paramName)
452+
return &RequiredParameterError{ParamName: paramName}
438453
} else {
439454
// If an optional parameter is not found, we do nothing,
440455
return nil
@@ -469,7 +484,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
469484
// unmarshal.
470485
if len(values) == 0 {
471486
if required {
472-
return fmt.Errorf("query parameter '%s' is required", paramName)
487+
return &RequiredParameterError{ParamName: paramName}
473488
} else {
474489
return nil
475490
}
@@ -480,7 +495,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
480495

481496
if !found {
482497
if required {
483-
return fmt.Errorf("query parameter '%s' is required", paramName)
498+
return &RequiredParameterError{ParamName: paramName}
484499
} else {
485500
// If an optional parameter is not found, we do nothing,
486501
return nil
@@ -502,15 +517,22 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
502517
values, found := queryParams[paramName]
503518
if !found {
504519
if required {
505-
return fmt.Errorf("query parameter '%s' is required", paramName)
520+
return &RequiredParameterError{ParamName: paramName}
506521
} else {
507522
return nil
508523
}
509524
}
510525
if len(values) != 1 {
511526
return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName)
512527
}
513-
parts = strings.Split(values[0], ",")
528+
switch style {
529+
case "spaceDelimited":
530+
parts = strings.Split(values[0], " ")
531+
case "pipeDelimited":
532+
parts = strings.Split(values[0], "|")
533+
default:
534+
parts = strings.Split(values[0], ",")
535+
}
514536
}
515537
var err error
516538
switch k {
@@ -549,7 +571,7 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
549571
default:
550572
if len(parts) == 0 {
551573
if required {
552-
return fmt.Errorf("query parameter '%s' is required", paramName)
574+
return &RequiredParameterError{ParamName: paramName}
553575
} else {
554576
return nil
555577
}
@@ -571,8 +593,6 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
571593
return errors.New("deepObjects must be exploded")
572594
}
573595
return unmarshalDeepObject(dest, paramName, queryParams, required)
574-
case "spaceDelimited", "pipeDelimited":
575-
return fmt.Errorf("query arguments of style '%s' aren't yet supported", style)
576596
default:
577597
return fmt.Errorf("style '%s' on parameter '%s' is invalid", style, paramName)
578598

@@ -655,10 +675,14 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
655675
k := t.Kind()
656676

657677
switch style {
658-
case "form":
678+
case "form", "spaceDelimited", "pipeDelimited":
659679
if explode {
660680
// For the explode case, url.ParseQuery is fine — there are no
661681
// delimiter commas to confuse with literal commas.
682+
//
683+
// Note: spaceDelimited and pipeDelimited with explode=true are
684+
// serialized identically to form explode=true (each value is a
685+
// separate key=value pair), so we share this code path.
662686
queryParams, err := url.ParseQuery(rawQuery)
663687
if err != nil {
664688
return fmt.Errorf("error parsing query string: %w", err)
@@ -669,7 +693,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
669693
case reflect.Slice:
670694
if !found {
671695
if required {
672-
return fmt.Errorf("query parameter '%s' is required", paramName)
696+
return &RequiredParameterError{ParamName: paramName}
673697
}
674698
return nil
675699
}
@@ -683,7 +707,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
683707
default:
684708
if len(values) == 0 {
685709
if required {
686-
return fmt.Errorf("query parameter '%s' is required", paramName)
710+
return &RequiredParameterError{ParamName: paramName}
687711
}
688712
return nil
689713
}
@@ -692,7 +716,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
692716
}
693717
if !found {
694718
if required {
695-
return fmt.Errorf("query parameter '%s' is required", paramName)
719+
return &RequiredParameterError{ParamName: paramName}
696720
}
697721
return nil
698722
}
@@ -707,22 +731,33 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
707731
return nil
708732
}
709733

710-
// form, explode=false — the core fix.
711-
// Use findRawQueryParam to get the still-encoded value, split on
712-
// literal ',' (which is the OpenAPI delimiter), then URL-decode
734+
// explode=false — use findRawQueryParam to get the still-encoded
735+
// value, split on the style-specific delimiter, then URL-decode
713736
// each resulting part individually.
714737
rawValues, found := findRawQueryParam(rawQuery, paramName)
715738
if !found {
716739
if required {
717-
return fmt.Errorf("query parameter '%s' is required", paramName)
740+
return &RequiredParameterError{ParamName: paramName}
718741
}
719742
return nil
720743
}
721744
if len(rawValues) != 1 {
722745
return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName)
723746
}
724747

725-
rawParts := strings.Split(rawValues[0], ",")
748+
var rawParts []string
749+
switch style {
750+
case "spaceDelimited":
751+
// Normalise all space representations to %20, then split.
752+
normalized := strings.ReplaceAll(rawValues[0], "+", "%20")
753+
normalized = strings.ReplaceAll(normalized, " ", "%20")
754+
rawParts = strings.Split(normalized, "%20")
755+
case "pipeDelimited":
756+
rawParts = strings.Split(rawValues[0], "|")
757+
default:
758+
rawParts = strings.Split(rawValues[0], ",")
759+
}
760+
726761
parts := make([]string, len(rawParts))
727762
for i, rp := range rawParts {
728763
decoded, err := url.QueryUnescape(rp)
@@ -741,7 +776,7 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
741776
default:
742777
if len(parts) == 0 {
743778
if required {
744-
return fmt.Errorf("query parameter '%s' is required", paramName)
779+
return &RequiredParameterError{ParamName: paramName}
745780
}
746781
return nil
747782
}
@@ -767,8 +802,6 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
767802
return fmt.Errorf("error parsing query string: %w", err)
768803
}
769804
return UnmarshalDeepObject(dest, paramName, queryParams)
770-
case "spaceDelimited", "pipeDelimited":
771-
return fmt.Errorf("query arguments of style '%s' aren't yet supported", style)
772805
default:
773806
return fmt.Errorf("style '%s' on parameter '%s' is invalid", style, paramName)
774807
}

0 commit comments

Comments
 (0)