Skip to content

Commit 629ff6e

Browse files
mromaszewiczclaude
andcommitted
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>
1 parent 00e51fe commit 629ff6e

File tree

2 files changed

+272
-24
lines changed

2 files changed

+272
-24
lines changed

bindparam.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,17 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
417417
k := t.Kind()
418418

419419
switch style {
420-
case "form":
420+
case "form", "spaceDelimited", "pipeDelimited":
421421
var parts []string
422422
if explode {
423423
// ok, the explode case in query arguments is very, very annoying,
424424
// because an exploded object, such as /users?role=admin&firstName=Alex
425425
// isn't actually present in the parameter array. We have to do
426426
// different things based on destination type.
427+
//
428+
// Note: spaceDelimited and pipeDelimited with explode=true are
429+
// serialized identically to form explode=true (each value is a
430+
// separate key=value pair), so we share this code path.
427431
values, found := queryParams[paramName]
428432
var err error
429433

@@ -510,7 +514,14 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
510514
if len(values) != 1 {
511515
return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName)
512516
}
513-
parts = strings.Split(values[0], ",")
517+
switch style {
518+
case "spaceDelimited":
519+
parts = strings.Split(values[0], " ")
520+
case "pipeDelimited":
521+
parts = strings.Split(values[0], "|")
522+
default:
523+
parts = strings.Split(values[0], ",")
524+
}
514525
}
515526
var err error
516527
switch k {
@@ -571,8 +582,6 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
571582
return errors.New("deepObjects must be exploded")
572583
}
573584
return unmarshalDeepObject(dest, paramName, queryParams, required)
574-
case "spaceDelimited", "pipeDelimited":
575-
return fmt.Errorf("query arguments of style '%s' aren't yet supported", style)
576585
default:
577586
return fmt.Errorf("style '%s' on parameter '%s' is invalid", style, paramName)
578587

@@ -655,10 +664,14 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
655664
k := t.Kind()
656665

657666
switch style {
658-
case "form":
667+
case "form", "spaceDelimited", "pipeDelimited":
659668
if explode {
660669
// For the explode case, url.ParseQuery is fine — there are no
661670
// delimiter commas to confuse with literal commas.
671+
//
672+
// Note: spaceDelimited and pipeDelimited with explode=true are
673+
// serialized identically to form explode=true (each value is a
674+
// separate key=value pair), so we share this code path.
662675
queryParams, err := url.ParseQuery(rawQuery)
663676
if err != nil {
664677
return fmt.Errorf("error parsing query string: %w", err)
@@ -707,9 +720,8 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
707720
return nil
708721
}
709722

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
723+
// explode=false — use findRawQueryParam to get the still-encoded
724+
// value, split on the style-specific delimiter, then URL-decode
713725
// each resulting part individually.
714726
rawValues, found := findRawQueryParam(rawQuery, paramName)
715727
if !found {
@@ -722,7 +734,19 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
722734
return fmt.Errorf("parameter '%s' is not exploded, but is specified multiple times", paramName)
723735
}
724736

725-
rawParts := strings.Split(rawValues[0], ",")
737+
var rawParts []string
738+
switch style {
739+
case "spaceDelimited":
740+
// Normalise all space representations to %20, then split.
741+
normalized := strings.ReplaceAll(rawValues[0], "+", "%20")
742+
normalized = strings.ReplaceAll(normalized, " ", "%20")
743+
rawParts = strings.Split(normalized, "%20")
744+
case "pipeDelimited":
745+
rawParts = strings.Split(rawValues[0], "|")
746+
default:
747+
rawParts = strings.Split(rawValues[0], ",")
748+
}
749+
726750
parts := make([]string, len(rawParts))
727751
for i, rp := range rawParts {
728752
decoded, err := url.QueryUnescape(rp)
@@ -767,8 +791,6 @@ func BindRawQueryParameter(style string, explode bool, required bool, paramName
767791
return fmt.Errorf("error parsing query string: %w", err)
768792
}
769793
return UnmarshalDeepObject(dest, paramName, queryParams)
770-
case "spaceDelimited", "pipeDelimited":
771-
return fmt.Errorf("query arguments of style '%s' aren't yet supported", style)
772794
default:
773795
return fmt.Errorf("style '%s' on parameter '%s' is invalid", style, paramName)
774796
}

bindparam_test.go

Lines changed: 239 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,19 +1045,8 @@ func TestBindRawQueryParameter(t *testing.T) {
10451045
assert.Contains(t, err.Error(), "exploded")
10461046
})
10471047

1048-
t.Run("spaceDelimited", func(t *testing.T) {
1049-
var dest []string
1050-
err := BindRawQueryParameter("spaceDelimited", false, true, "color", "color=a%20b%20c", &dest)
1051-
assert.Error(t, err)
1052-
assert.Contains(t, err.Error(), "spaceDelimited")
1053-
})
1054-
1055-
t.Run("pipeDelimited", func(t *testing.T) {
1056-
var dest []string
1057-
err := BindRawQueryParameter("pipeDelimited", false, true, "color", "color=a|b|c", &dest)
1058-
assert.Error(t, err)
1059-
assert.Contains(t, err.Error(), "pipeDelimited")
1060-
})
1048+
// Note: spaceDelimited and pipeDelimited are now supported
1049+
// and have their own test functions below.
10611050

10621051
t.Run("unknown style", func(t *testing.T) {
10631052
var dest string
@@ -1335,3 +1324,240 @@ func TestBindStyledParameter_HeaderWithCommas(t *testing.T) {
13351324
assert.Equal(t, []string{"a", "b", "c"}, dest)
13361325
})
13371326
}
1327+
1328+
func TestBindQueryParameter_SpaceDelimited(t *testing.T) {
1329+
t.Run("unexploded int array", func(t *testing.T) {
1330+
var dest []int
1331+
q := make(url.Values)
1332+
q.Set("ids", "3 4 5")
1333+
err := BindQueryParameterWithOptions("spaceDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{})
1334+
require.NoError(t, err)
1335+
assert.Equal(t, []int{3, 4, 5}, dest)
1336+
})
1337+
1338+
t.Run("unexploded string array", func(t *testing.T) {
1339+
var dest []string
1340+
q := make(url.Values)
1341+
q.Set("color", "red green blue")
1342+
err := BindQueryParameterWithOptions("spaceDelimited", false, true, "color", q, &dest, BindQueryParameterOptions{})
1343+
require.NoError(t, err)
1344+
assert.Equal(t, []string{"red", "green", "blue"}, dest)
1345+
})
1346+
1347+
t.Run("exploded int array", func(t *testing.T) {
1348+
var dest []int
1349+
q := make(url.Values)
1350+
q["ids"] = []string{"3", "4", "5"}
1351+
err := BindQueryParameterWithOptions("spaceDelimited", true, true, "ids", q, &dest, BindQueryParameterOptions{})
1352+
require.NoError(t, err)
1353+
assert.Equal(t, []int{3, 4, 5}, dest)
1354+
})
1355+
1356+
t.Run("optional missing", func(t *testing.T) {
1357+
var dest *[]int
1358+
q := make(url.Values)
1359+
err := BindQueryParameterWithOptions("spaceDelimited", false, false, "ids", q, &dest, BindQueryParameterOptions{})
1360+
require.NoError(t, err)
1361+
assert.Nil(t, dest)
1362+
})
1363+
1364+
t.Run("required missing", func(t *testing.T) {
1365+
var dest []int
1366+
q := make(url.Values)
1367+
err := BindQueryParameterWithOptions("spaceDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{})
1368+
assert.Error(t, err)
1369+
assert.Contains(t, err.Error(), "required")
1370+
})
1371+
}
1372+
1373+
func TestBindQueryParameter_PipeDelimited(t *testing.T) {
1374+
t.Run("unexploded int array", func(t *testing.T) {
1375+
var dest []int
1376+
q := make(url.Values)
1377+
q.Set("ids", "3|4|5")
1378+
err := BindQueryParameterWithOptions("pipeDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{})
1379+
require.NoError(t, err)
1380+
assert.Equal(t, []int{3, 4, 5}, dest)
1381+
})
1382+
1383+
t.Run("unexploded string array", func(t *testing.T) {
1384+
var dest []string
1385+
q := make(url.Values)
1386+
q.Set("color", "red|green|blue")
1387+
err := BindQueryParameterWithOptions("pipeDelimited", false, true, "color", q, &dest, BindQueryParameterOptions{})
1388+
require.NoError(t, err)
1389+
assert.Equal(t, []string{"red", "green", "blue"}, dest)
1390+
})
1391+
1392+
t.Run("exploded int array", func(t *testing.T) {
1393+
var dest []int
1394+
q := make(url.Values)
1395+
q["ids"] = []string{"3", "4", "5"}
1396+
err := BindQueryParameterWithOptions("pipeDelimited", true, true, "ids", q, &dest, BindQueryParameterOptions{})
1397+
require.NoError(t, err)
1398+
assert.Equal(t, []int{3, 4, 5}, dest)
1399+
})
1400+
1401+
t.Run("optional missing", func(t *testing.T) {
1402+
var dest *[]int
1403+
q := make(url.Values)
1404+
err := BindQueryParameterWithOptions("pipeDelimited", false, false, "ids", q, &dest, BindQueryParameterOptions{})
1405+
require.NoError(t, err)
1406+
assert.Nil(t, dest)
1407+
})
1408+
1409+
t.Run("required missing", func(t *testing.T) {
1410+
var dest []int
1411+
q := make(url.Values)
1412+
err := BindQueryParameterWithOptions("pipeDelimited", false, true, "ids", q, &dest, BindQueryParameterOptions{})
1413+
assert.Error(t, err)
1414+
assert.Contains(t, err.Error(), "required")
1415+
})
1416+
}
1417+
1418+
func TestBindRawQueryParameter_SpaceDelimited(t *testing.T) {
1419+
t.Run("unexploded with %20", func(t *testing.T) {
1420+
var dest []int
1421+
err := BindRawQueryParameter("spaceDelimited", false, true, "ids", "ids=3%204%205", &dest)
1422+
require.NoError(t, err)
1423+
assert.Equal(t, []int{3, 4, 5}, dest)
1424+
})
1425+
1426+
t.Run("unexploded with +", func(t *testing.T) {
1427+
var dest []int
1428+
err := BindRawQueryParameter("spaceDelimited", false, true, "ids", "ids=3+4+5", &dest)
1429+
require.NoError(t, err)
1430+
assert.Equal(t, []int{3, 4, 5}, dest)
1431+
})
1432+
1433+
t.Run("unexploded strings", func(t *testing.T) {
1434+
var dest []string
1435+
err := BindRawQueryParameter("spaceDelimited", false, true, "color", "color=red%20green%20blue", &dest)
1436+
require.NoError(t, err)
1437+
assert.Equal(t, []string{"red", "green", "blue"}, dest)
1438+
})
1439+
1440+
t.Run("exploded", func(t *testing.T) {
1441+
var dest []int
1442+
err := BindRawQueryParameter("spaceDelimited", true, true, "ids", "ids=3&ids=4&ids=5", &dest)
1443+
require.NoError(t, err)
1444+
assert.Equal(t, []int{3, 4, 5}, dest)
1445+
})
1446+
1447+
t.Run("optional missing", func(t *testing.T) {
1448+
var dest *[]int
1449+
err := BindRawQueryParameter("spaceDelimited", false, false, "ids", "other=val", &dest)
1450+
require.NoError(t, err)
1451+
assert.Nil(t, dest)
1452+
})
1453+
}
1454+
1455+
func TestBindRawQueryParameter_PipeDelimited(t *testing.T) {
1456+
t.Run("unexploded", func(t *testing.T) {
1457+
var dest []int
1458+
err := BindRawQueryParameter("pipeDelimited", false, true, "ids", "ids=3|4|5", &dest)
1459+
require.NoError(t, err)
1460+
assert.Equal(t, []int{3, 4, 5}, dest)
1461+
})
1462+
1463+
t.Run("unexploded strings", func(t *testing.T) {
1464+
var dest []string
1465+
err := BindRawQueryParameter("pipeDelimited", false, true, "color", "color=red|green|blue", &dest)
1466+
require.NoError(t, err)
1467+
assert.Equal(t, []string{"red", "green", "blue"}, dest)
1468+
})
1469+
1470+
t.Run("exploded", func(t *testing.T) {
1471+
var dest []int
1472+
err := BindRawQueryParameter("pipeDelimited", true, true, "ids", "ids=3&ids=4&ids=5", &dest)
1473+
require.NoError(t, err)
1474+
assert.Equal(t, []int{3, 4, 5}, dest)
1475+
})
1476+
1477+
t.Run("optional missing", func(t *testing.T) {
1478+
var dest *[]int
1479+
err := BindRawQueryParameter("pipeDelimited", false, false, "ids", "other=val", &dest)
1480+
require.NoError(t, err)
1481+
assert.Nil(t, dest)
1482+
})
1483+
}
1484+
1485+
func TestRoundTripQueryParameter_Delimited(t *testing.T) {
1486+
tests := []struct {
1487+
name string
1488+
style string
1489+
explode bool
1490+
paramName string
1491+
value interface{}
1492+
dest interface{}
1493+
expected interface{}
1494+
}{
1495+
{
1496+
name: "spaceDelimited/false int slice",
1497+
style: "spaceDelimited",
1498+
explode: false,
1499+
paramName: "ids",
1500+
value: []int{1, 2, 3},
1501+
dest: &[]int{},
1502+
expected: []int{1, 2, 3},
1503+
},
1504+
{
1505+
name: "spaceDelimited/false string slice",
1506+
style: "spaceDelimited",
1507+
explode: false,
1508+
paramName: "color",
1509+
value: []string{"red", "green", "blue"},
1510+
dest: &[]string{},
1511+
expected: []string{"red", "green", "blue"},
1512+
},
1513+
{
1514+
name: "spaceDelimited/true int slice",
1515+
style: "spaceDelimited",
1516+
explode: true,
1517+
paramName: "ids",
1518+
value: []int{1, 2, 3},
1519+
dest: &[]int{},
1520+
expected: []int{1, 2, 3},
1521+
},
1522+
{
1523+
name: "pipeDelimited/false int slice",
1524+
style: "pipeDelimited",
1525+
explode: false,
1526+
paramName: "ids",
1527+
value: []int{1, 2, 3},
1528+
dest: &[]int{},
1529+
expected: []int{1, 2, 3},
1530+
},
1531+
{
1532+
name: "pipeDelimited/false string slice",
1533+
style: "pipeDelimited",
1534+
explode: false,
1535+
paramName: "color",
1536+
value: []string{"red", "green", "blue"},
1537+
dest: &[]string{},
1538+
expected: []string{"red", "green", "blue"},
1539+
},
1540+
{
1541+
name: "pipeDelimited/true int slice",
1542+
style: "pipeDelimited",
1543+
explode: true,
1544+
paramName: "ids",
1545+
value: []int{1, 2, 3},
1546+
dest: &[]int{},
1547+
expected: []int{1, 2, 3},
1548+
},
1549+
}
1550+
1551+
for _, tt := range tests {
1552+
t.Run(tt.name, func(t *testing.T) {
1553+
raw, err := StyleParamWithLocation(tt.style, tt.explode, tt.paramName, ParamLocationQuery, tt.value)
1554+
require.NoError(t, err, "StyleParamWithLocation failed")
1555+
1556+
err = BindRawQueryParameter(tt.style, tt.explode, true, tt.paramName, raw, tt.dest)
1557+
require.NoError(t, err, "BindRawQueryParameter failed for raw=%q", raw)
1558+
1559+
actual := reflect.ValueOf(tt.dest).Elem().Interface()
1560+
assert.Equal(t, tt.expected, actual)
1561+
})
1562+
}
1563+
}

0 commit comments

Comments
 (0)