Skip to content

Commit 224825a

Browse files
mromaszewiczclaude
andauthored
fix: add Type/Format-aware parameter binding and styling for []byte (#97) (#98)
When an OpenAPI spec uses type: string, format: byte, the generated Go code produces *[]byte fields. Previously the runtime treated []byte as a generic []uint8 slice -- splitting on commas and parsing individual integers -- instead of base64-encoding/decoding it as a single value. This commit adds Type and Format string fields (matching the OpenAPI spec type/format) to the Options structs for parameter binding and styling functions. When Format is "byte" and the destination is []byte, the runtime base64-encodes (styling) or base64-decodes (binding) the value as a single string. New WithOptions functions and options structs: - BindStringToObjectWithOptions + BindStringToObjectOptions - StyleParamWithOptions + StyleParamOptions - BindQueryParameterWithOptions + BindQueryParameterOptions Extended existing options struct: - BindStyledParameterOptions: added Type and Format fields Existing functions delegate to WithOptions with zero-value options, preserving backward compatibility for all current callers. Encoding uses base64.StdEncoding (standard alphabet, padded) per OpenAPI 3.0 / RFC 4648 Section 4. Decoding is lenient: it inspects padding and URL-safe characters to select the correct decoder, avoiding the silent corruption that occurs when RawStdEncoding accepts padded input. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 03288f9 commit 224825a

File tree

8 files changed

+428
-10
lines changed

8 files changed

+428
-10
lines changed

bindparam.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ type BindStyledParameterOptions struct {
6363
Explode bool
6464
// Whether the parameter is required in the query
6565
Required bool
66+
// Type is the OpenAPI type of the parameter (e.g. "string", "integer").
67+
Type string
68+
// Format is the OpenAPI format of the parameter (e.g. "byte", "date-time").
69+
// When set to "byte" and the destination is []byte, the value is
70+
// base64-decoded rather than treated as a generic slice.
71+
Format string
6672
}
6773

6874
// BindStyledParameterWithOptions binds a parameter as described in the Path Parameters
@@ -121,6 +127,22 @@ func BindStyledParameterWithOptions(style string, paramName string, value string
121127
}
122128

123129
if t.Kind() == reflect.Slice {
130+
if opts.Format == "byte" && isByteSlice(t) {
131+
parts, err := splitStyledParameter(style, opts.Explode, false, paramName, value)
132+
if err != nil {
133+
return fmt.Errorf("error splitting input '%s' into parts: %w", value, err)
134+
}
135+
if len(parts) != 1 {
136+
return fmt.Errorf("expected single base64 value for byte slice parameter '%s', got %d parts", paramName, len(parts))
137+
}
138+
decoded, err := base64Decode(parts[0])
139+
if err != nil {
140+
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, err)
141+
}
142+
v.SetBytes(decoded)
143+
return nil
144+
}
145+
124146
// Chop up the parameter into parts based on its style
125147
parts, err := splitStyledParameter(style, opts.Explode, false, paramName, value)
126148
if err != nil {
@@ -308,6 +330,22 @@ func bindSplitPartsToDestinationStruct(paramName string, parts []string, explode
308330
// the Content parameter form.
309331
func BindQueryParameter(style string, explode bool, required bool, paramName string,
310332
queryParams url.Values, dest interface{}) error {
333+
return BindQueryParameterWithOptions(style, explode, required, paramName, queryParams, dest, BindQueryParameterOptions{})
334+
}
335+
336+
// BindQueryParameterOptions defines optional arguments for BindQueryParameterWithOptions.
337+
type BindQueryParameterOptions struct {
338+
// Type is the OpenAPI type of the parameter (e.g. "string", "integer").
339+
Type string
340+
// Format is the OpenAPI format of the parameter (e.g. "byte", "date-time").
341+
// When set to "byte" and the destination is []byte, the value is
342+
// base64-decoded rather than treated as a generic slice.
343+
Format string
344+
}
345+
346+
// BindQueryParameterWithOptions works like BindQueryParameter with additional options.
347+
func BindQueryParameterWithOptions(style string, explode bool, required bool, paramName string,
348+
queryParams url.Values, dest interface{}, opts BindQueryParameterOptions) error {
311349

312350
// dv = destination value.
313351
dv := reflect.Indirect(reflect.ValueOf(dest))
@@ -378,7 +416,18 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
378416
return nil
379417
}
380418
}
381-
err = bindSplitPartsToDestinationArray(values, output)
419+
if opts.Format == "byte" && isByteSlice(t) {
420+
if len(values) != 1 {
421+
return fmt.Errorf("expected single base64 value for byte slice parameter '%s', got %d values", paramName, len(values))
422+
}
423+
decoded, decErr := base64Decode(values[0])
424+
if decErr != nil {
425+
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, decErr)
426+
}
427+
v.SetBytes(decoded)
428+
} else {
429+
err = bindSplitPartsToDestinationArray(values, output)
430+
}
382431
case reflect.Struct:
383432
// This case is really annoying, and error prone, but the
384433
// form style object binding doesn't tell us which arguments
@@ -442,7 +491,18 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
442491
var err error
443492
switch k {
444493
case reflect.Slice:
445-
err = bindSplitPartsToDestinationArray(parts, output)
494+
if opts.Format == "byte" && isByteSlice(t) {
495+
// For non-exploded form, the value was split on commas above.
496+
// Rejoin to get the original base64 string.
497+
raw := strings.Join(parts, ",")
498+
decoded, decErr := base64Decode(raw)
499+
if decErr != nil {
500+
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, decErr)
501+
}
502+
v.SetBytes(decoded)
503+
} else {
504+
err = bindSplitPartsToDestinationArray(parts, output)
505+
}
446506
case reflect.Struct:
447507
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
448508
default:

bindparam_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,95 @@ import (
2727
"github.com/oapi-codegen/runtime/types"
2828
)
2929

30+
// TestBindStyledParameter_ByteSlice tests that BindStyledParameterWithOptions
31+
// correctly handles *[]byte destinations by base64-decoding the parameter value,
32+
// rather than treating []byte as a generic slice and splitting on commas.
33+
// See: https://github.com/oapi-codegen/runtime/issues/97
34+
func TestBindStyledParameter_ByteSlice(t *testing.T) {
35+
expected := []byte("test")
36+
37+
tests := []struct {
38+
name string
39+
style string
40+
explode bool
41+
value string
42+
}{
43+
{"simple/no-explode", "simple", false, "dGVzdA=="},
44+
{"simple/explode", "simple", true, "dGVzdA=="},
45+
{"label/no-explode", "label", false, ".dGVzdA=="},
46+
{"label/explode", "label", true, ".dGVzdA=="},
47+
{"matrix/no-explode", "matrix", false, ";data=dGVzdA=="},
48+
{"matrix/explode", "matrix", true, ";data=dGVzdA=="},
49+
{"form/no-explode", "form", false, "data=dGVzdA=="},
50+
{"form/explode", "form", true, "data=dGVzdA=="},
51+
}
52+
53+
for _, tc := range tests {
54+
t.Run(tc.name, func(t *testing.T) {
55+
var dest []byte
56+
err := BindStyledParameterWithOptions(tc.style, "data", tc.value, &dest, BindStyledParameterOptions{
57+
ParamLocation: ParamLocationUndefined,
58+
Explode: tc.explode,
59+
Required: true,
60+
Type: "string",
61+
Format: "byte",
62+
})
63+
require.NoError(t, err)
64+
assert.Equal(t, expected, dest)
65+
})
66+
}
67+
}
68+
69+
// TestBindQueryParameter_ByteSlice tests that BindQueryParameter correctly
70+
// handles *[]byte destinations by base64-decoding the query parameter value.
71+
// See: https://github.com/oapi-codegen/runtime/issues/97
72+
func TestBindQueryParameter_ByteSlice(t *testing.T) {
73+
expected := []byte("test")
74+
75+
opts := BindQueryParameterOptions{Type: "string", Format: "byte"}
76+
77+
t.Run("form/explode/required", func(t *testing.T) {
78+
var dest []byte
79+
queryParams := url.Values{"data": {"dGVzdA=="}}
80+
err := BindQueryParameterWithOptions("form", true, true, "data", queryParams, &dest, opts)
81+
require.NoError(t, err)
82+
assert.Equal(t, expected, dest)
83+
})
84+
85+
t.Run("form/no-explode/required", func(t *testing.T) {
86+
var dest []byte
87+
queryParams := url.Values{"data": {"dGVzdA=="}}
88+
err := BindQueryParameterWithOptions("form", false, true, "data", queryParams, &dest, opts)
89+
require.NoError(t, err)
90+
assert.Equal(t, expected, dest)
91+
})
92+
93+
t.Run("form/explode/optional/present", func(t *testing.T) {
94+
var dest *[]byte
95+
queryParams := url.Values{"data": {"dGVzdA=="}}
96+
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
97+
require.NoError(t, err)
98+
require.NotNil(t, dest)
99+
assert.Equal(t, expected, *dest)
100+
})
101+
102+
t.Run("form/explode/optional/absent", func(t *testing.T) {
103+
var dest *[]byte
104+
queryParams := url.Values{}
105+
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
106+
require.NoError(t, err)
107+
assert.Nil(t, dest)
108+
})
109+
110+
t.Run("form/explode/optional/empty", func(t *testing.T) {
111+
var dest []byte
112+
queryParams := url.Values{"data": {""}}
113+
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
114+
require.NoError(t, err)
115+
assert.Equal(t, []byte{}, dest)
116+
})
117+
}
118+
30119
// MockBinder is just an independent version of Binder that has the Bind implemented
31120
type MockBinder struct {
32121
time.Time

bindstring.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ import (
3131
// know the destination type each place that we use this, is to generate code
3232
// to read each specific type.
3333
func BindStringToObject(src string, dst interface{}) error {
34+
return BindStringToObjectWithOptions(src, dst, BindStringToObjectOptions{})
35+
}
36+
37+
// BindStringToObjectOptions defines optional arguments for BindStringToObjectWithOptions.
38+
type BindStringToObjectOptions struct {
39+
// Type is the OpenAPI type of the parameter (e.g. "string", "integer").
40+
Type string
41+
// Format is the OpenAPI format of the parameter (e.g. "byte", "date-time").
42+
// When set to "byte" and the destination is []byte, the source string is
43+
// base64-decoded rather than treated as a generic slice.
44+
Format string
45+
}
46+
47+
// BindStringToObjectWithOptions takes a string, and attempts to assign it to the destination
48+
// interface via whatever type conversion is necessary, with additional options.
49+
func BindStringToObjectWithOptions(src string, dst interface{}, opts BindStringToObjectOptions) error {
3450
var err error
3551

3652
v := reflect.ValueOf(dst)
@@ -59,6 +75,17 @@ func BindStringToObject(src string, dst interface{}) error {
5975
}
6076

6177
switch t.Kind() {
78+
case reflect.Slice:
79+
if opts.Format == "byte" && isByteSlice(t) {
80+
decoded, decErr := base64Decode(src)
81+
if decErr != nil {
82+
return fmt.Errorf("error binding string parameter: %w", decErr)
83+
}
84+
v.SetBytes(decoded)
85+
return nil
86+
}
87+
// Non-binary slices fall through to the default error case.
88+
fallthrough
6289
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
6390
var val int64
6491
val, err = strconv.ParseInt(src, 10, 64)

bindstring_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
package runtime
1515

1616
import (
17+
"encoding/base64"
1718
"fmt"
1819
"math"
1920
"testing"
2021
"time"
2122

2223
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
2325

2426
"github.com/oapi-codegen/runtime/types"
2527
)
@@ -210,3 +212,49 @@ func TestBindStringToObject(t *testing.T) {
210212
assert.Equal(t, dstUUID.String(), uuidString)
211213

212214
}
215+
216+
// TestBindStringToObject_ByteSlice tests that BindStringToObject correctly handles
217+
// *[]byte destinations by base64-decoding the input string, rather than failing
218+
// or treating []byte as a generic slice.
219+
// See: https://github.com/oapi-codegen/runtime/issues/97
220+
func TestBindStringToObject_ByteSlice(t *testing.T) {
221+
opts := BindStringToObjectOptions{Type: "string", Format: "byte"}
222+
223+
t.Run("valid base64 with padding", func(t *testing.T) {
224+
var dest []byte
225+
err := BindStringToObjectWithOptions("dGVzdA==", &dest, opts)
226+
require.NoError(t, err)
227+
assert.Equal(t, []byte("test"), dest)
228+
})
229+
230+
t.Run("valid base64 without padding", func(t *testing.T) {
231+
var dest []byte
232+
err := BindStringToObjectWithOptions("dGVzdA", &dest, opts)
233+
require.NoError(t, err)
234+
assert.Equal(t, []byte("test"), dest)
235+
})
236+
237+
t.Run("URL-safe base64", func(t *testing.T) {
238+
// "<<??>>+" in standard base64 is "PDw/Pz4+" but URL-safe uses "PDw_Pz4-"
239+
input := "PDw_Pz4-"
240+
var dest []byte
241+
err := BindStringToObjectWithOptions(input, &dest, opts)
242+
require.NoError(t, err)
243+
expected, decErr := base64.RawURLEncoding.DecodeString("PDw_Pz4-")
244+
require.NoError(t, decErr)
245+
assert.Equal(t, expected, dest)
246+
})
247+
248+
t.Run("empty string", func(t *testing.T) {
249+
var dest []byte
250+
err := BindStringToObjectWithOptions("", &dest, opts)
251+
require.NoError(t, err)
252+
assert.Equal(t, []byte{}, dest)
253+
})
254+
255+
t.Run("invalid base64", func(t *testing.T) {
256+
var dest []byte
257+
err := BindStringToObjectWithOptions("!!!not-base64!!!", &dest, opts)
258+
assert.Error(t, err)
259+
})
260+
}

paramformat.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package runtime
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"reflect"
7+
"strings"
8+
)
9+
10+
// isByteSlice reports whether t is []byte (or equivalently []uint8).
11+
func isByteSlice(t reflect.Type) bool {
12+
return t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8
13+
}
14+
15+
// base64Decode decodes s as base64.
16+
//
17+
// Per OpenAPI 3.0, format: byte uses RFC 4648 Section 4 (standard alphabet,
18+
// padded). We use padding presence to select the right decoder, rather than
19+
// blindly cascading (which can produce corrupt output when RawStdEncoding
20+
// silently accepts padded input and treats '=' as data).
21+
//
22+
// The logic:
23+
// 1. If s contains '=' padding → standard padded decoder (Std or URL based on alphabet).
24+
// 2. If s contains URL-safe characters ('_' or '-') → RawURLEncoding.
25+
// 3. Otherwise → RawStdEncoding (unpadded, standard alphabet).
26+
func base64Decode(s string) ([]byte, error) {
27+
if s == "" {
28+
return []byte{}, nil
29+
}
30+
31+
if strings.ContainsRune(s, '=') {
32+
// Padded input. Pick alphabet based on whether URL-safe chars are present.
33+
if strings.ContainsAny(s, "-_") {
34+
return base64Decode1(base64.URLEncoding, s)
35+
}
36+
return base64Decode1(base64.StdEncoding, s)
37+
}
38+
39+
// Unpadded input. Pick alphabet based on whether URL-safe chars are present.
40+
if strings.ContainsAny(s, "-_") {
41+
return base64Decode1(base64.RawURLEncoding, s)
42+
}
43+
return base64Decode1(base64.RawStdEncoding, s)
44+
}
45+
46+
func base64Decode1(enc *base64.Encoding, s string) ([]byte, error) {
47+
b, err := enc.DecodeString(s)
48+
if err != nil {
49+
return nil, fmt.Errorf("failed to base64-decode string %q: %w", s, err)
50+
}
51+
return b, nil
52+
}

0 commit comments

Comments
 (0)