Skip to content

Commit 87770d8

Browse files
mromaszewiczclaude
andcommitted
fix: add TypeHint-aware parameter binding and styling for []byte (#97)
When an OpenAPI spec uses type: string, format: byte, oapi-codegen generates a []byte field. Previously the runtime treated []byte as a generic []uint8 slice -- splitting on commas and parsing individual integers -- instead of base64 encoding/decoding. This adds a TypeHint mechanism via new WithOptions variants of existing functions. Existing functions delegate to the new variants with zero-value options, preserving all current behavior. Only when TypeHintBinary is explicitly passed does base64 handling activate. Encoding uses base64.StdEncoding (standard alphabet, padded) per OpenAPI 3.0 reference to RFC 4648 Section 4. Decoding is lenient: it inspects the input to select the correct decoder rather than blindly cascading (which could corrupt data when RawStdEncoding silently accepts padded input and treats '=' as data). If '=' padding is present, it uses the padded decoder; otherwise the raw (unpadded) decoder. If URL-safe characters ('-' or '_') are present, it uses the URL-safe alphabet; otherwise the standard alphabet. This accommodates real-world clients that may send unpadded or URL-safe base64, while still being correct for spec-compliant padded standard base64. New public API: - TypeHint, TypeHintNone, TypeHintBinary - BindStringToObjectOptions / BindStringToObjectWithOptions - StyleParamOptions / StyleParamWithOptions - BindQueryParameterOptions / BindQueryParameterWithOptions - BindStyledParameterOptions.TypeHint field The oapi-codegen code generator must be updated separately to emit calls to these new WithOptions functions with TypeHintBinary for format: byte fields. Until then, generated code continues to use the existing functions with unchanged behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent effec1a commit 87770d8

File tree

8 files changed

+426
-10
lines changed

8 files changed

+426
-10
lines changed

bindparam.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ type BindStyledParameterOptions struct {
6363
Explode bool
6464
// Whether the parameter is required in the query
6565
Required bool
66+
// TypeHint provides additional type information. When set to TypeHintBinary,
67+
// []byte destinations are base64-decoded rather than treated as generic slices.
68+
TypeHint TypeHint
6669
}
6770

6871
// BindStyledParameterWithOptions binds a parameter as described in the Path Parameters
@@ -121,6 +124,22 @@ func BindStyledParameterWithOptions(style string, paramName string, value string
121124
}
122125

123126
if t.Kind() == reflect.Slice {
127+
if opts.TypeHint == TypeHintBinary && isByteSlice(t) {
128+
parts, err := splitStyledParameter(style, opts.Explode, false, paramName, value)
129+
if err != nil {
130+
return fmt.Errorf("error splitting input '%s' into parts: %w", value, err)
131+
}
132+
if len(parts) != 1 {
133+
return fmt.Errorf("expected single base64 value for byte slice parameter '%s', got %d parts", paramName, len(parts))
134+
}
135+
decoded, err := base64Decode(parts[0])
136+
if err != nil {
137+
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, err)
138+
}
139+
v.SetBytes(decoded)
140+
return nil
141+
}
142+
124143
// Chop up the parameter into parts based on its style
125144
parts, err := splitStyledParameter(style, opts.Explode, false, paramName, value)
126145
if err != nil {
@@ -308,6 +327,19 @@ func bindSplitPartsToDestinationStruct(paramName string, parts []string, explode
308327
// the Content parameter form.
309328
func BindQueryParameter(style string, explode bool, required bool, paramName string,
310329
queryParams url.Values, dest interface{}) error {
330+
return BindQueryParameterWithOptions(style, explode, required, paramName, queryParams, dest, BindQueryParameterOptions{})
331+
}
332+
333+
// BindQueryParameterOptions defines optional arguments for BindQueryParameterWithOptions.
334+
type BindQueryParameterOptions struct {
335+
// TypeHint provides additional type information. When set to TypeHintBinary,
336+
// []byte destinations are base64-decoded rather than treated as generic slices.
337+
TypeHint TypeHint
338+
}
339+
340+
// BindQueryParameterWithOptions works like BindQueryParameter with additional options.
341+
func BindQueryParameterWithOptions(style string, explode bool, required bool, paramName string,
342+
queryParams url.Values, dest interface{}, opts BindQueryParameterOptions) error {
311343

312344
// dv = destination value.
313345
dv := reflect.Indirect(reflect.ValueOf(dest))
@@ -378,7 +410,18 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
378410
return nil
379411
}
380412
}
381-
err = bindSplitPartsToDestinationArray(values, output)
413+
if opts.TypeHint == TypeHintBinary && isByteSlice(t) {
414+
if len(values) != 1 {
415+
return fmt.Errorf("expected single base64 value for byte slice parameter '%s', got %d values", paramName, len(values))
416+
}
417+
decoded, decErr := base64Decode(values[0])
418+
if decErr != nil {
419+
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, decErr)
420+
}
421+
v.SetBytes(decoded)
422+
} else {
423+
err = bindSplitPartsToDestinationArray(values, output)
424+
}
382425
case reflect.Struct:
383426
// This case is really annoying, and error prone, but the
384427
// form style object binding doesn't tell us which arguments
@@ -442,7 +485,18 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
442485
var err error
443486
switch k {
444487
case reflect.Slice:
445-
err = bindSplitPartsToDestinationArray(parts, output)
488+
if opts.TypeHint == TypeHintBinary && isByteSlice(t) {
489+
// For non-exploded form, the value was split on commas above.
490+
// Rejoin to get the original base64 string.
491+
raw := strings.Join(parts, ",")
492+
decoded, decErr := base64Decode(raw)
493+
if decErr != nil {
494+
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, decErr)
495+
}
496+
v.SetBytes(decoded)
497+
} else {
498+
err = bindSplitPartsToDestinationArray(parts, output)
499+
}
446500
case reflect.Struct:
447501
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
448502
default:

bindparam_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,94 @@ 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+
TypeHint: TypeHintBinary,
61+
})
62+
require.NoError(t, err)
63+
assert.Equal(t, expected, dest)
64+
})
65+
}
66+
}
67+
68+
// TestBindQueryParameter_ByteSlice tests that BindQueryParameter correctly
69+
// handles *[]byte destinations by base64-decoding the query parameter value.
70+
// See: https://github.com/oapi-codegen/runtime/issues/97
71+
func TestBindQueryParameter_ByteSlice(t *testing.T) {
72+
expected := []byte("test")
73+
74+
opts := BindQueryParameterOptions{TypeHint: TypeHintBinary}
75+
76+
t.Run("form/explode/required", func(t *testing.T) {
77+
var dest []byte
78+
queryParams := url.Values{"data": {"dGVzdA=="}}
79+
err := BindQueryParameterWithOptions("form", true, true, "data", queryParams, &dest, opts)
80+
require.NoError(t, err)
81+
assert.Equal(t, expected, dest)
82+
})
83+
84+
t.Run("form/no-explode/required", func(t *testing.T) {
85+
var dest []byte
86+
queryParams := url.Values{"data": {"dGVzdA=="}}
87+
err := BindQueryParameterWithOptions("form", false, true, "data", queryParams, &dest, opts)
88+
require.NoError(t, err)
89+
assert.Equal(t, expected, dest)
90+
})
91+
92+
t.Run("form/explode/optional/present", func(t *testing.T) {
93+
var dest *[]byte
94+
queryParams := url.Values{"data": {"dGVzdA=="}}
95+
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
96+
require.NoError(t, err)
97+
require.NotNil(t, dest)
98+
assert.Equal(t, expected, *dest)
99+
})
100+
101+
t.Run("form/explode/optional/absent", func(t *testing.T) {
102+
var dest *[]byte
103+
queryParams := url.Values{}
104+
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
105+
require.NoError(t, err)
106+
assert.Nil(t, dest)
107+
})
108+
109+
t.Run("form/explode/optional/empty", func(t *testing.T) {
110+
var dest []byte
111+
queryParams := url.Values{"data": {""}}
112+
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
113+
require.NoError(t, err)
114+
assert.Equal(t, []byte{}, dest)
115+
})
116+
}
117+
30118
// MockBinder is just an independent version of Binder that has the Bind implemented
31119
type MockBinder struct {
32120
time.Time

bindstring.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ 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+
// TypeHint provides additional type information. When set to TypeHintBinary,
40+
// []byte destinations are base64-decoded rather than failing.
41+
TypeHint TypeHint
42+
}
43+
44+
// BindStringToObjectWithOptions takes a string, and attempts to assign it to the destination
45+
// interface via whatever type conversion is necessary, with additional options.
46+
func BindStringToObjectWithOptions(src string, dst interface{}, opts BindStringToObjectOptions) error {
3447
var err error
3548

3649
v := reflect.ValueOf(dst)
@@ -59,6 +72,17 @@ func BindStringToObject(src string, dst interface{}) error {
5972
}
6073

6174
switch t.Kind() {
75+
case reflect.Slice:
76+
if opts.TypeHint == TypeHintBinary && isByteSlice(t) {
77+
decoded, decErr := base64Decode(src)
78+
if decErr != nil {
79+
return fmt.Errorf("error binding string parameter: %w", decErr)
80+
}
81+
v.SetBytes(decoded)
82+
return nil
83+
}
84+
// Non-binary slices fall through to the default error case.
85+
fallthrough
6286
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
6387
var val int64
6488
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{TypeHint: TypeHintBinary}
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+
}

styleparam.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package runtime
1616
import (
1717
"bytes"
1818
"encoding"
19+
"encoding/base64"
1920
"encoding/json"
2021
"errors"
2122
"fmt"
@@ -51,10 +52,27 @@ func StyleParam(style string, explode bool, paramName string, value interface{})
5152
return StyleParamWithLocation(style, explode, paramName, ParamLocationUndefined, value)
5253
}
5354

54-
// Given an input value, such as a primitive type, array or object, turn it
55-
// into a parameter based on style/explode definition, performing whatever
56-
// escaping is necessary based on parameter location
55+
// StyleParamWithLocation serializes a Go value into an OpenAPI-styled parameter
56+
// string, performing escaping based on parameter location.
5757
func StyleParamWithLocation(style string, explode bool, paramName string, paramLocation ParamLocation, value interface{}) (string, error) {
58+
return StyleParamWithOptions(style, explode, paramName, value, StyleParamOptions{
59+
ParamLocation: paramLocation,
60+
})
61+
}
62+
63+
// StyleParamOptions defines optional arguments for StyleParamWithOptions.
64+
type StyleParamOptions struct {
65+
// ParamLocation controls URL escaping behavior.
66+
ParamLocation ParamLocation
67+
// TypeHint provides additional type information. When set to TypeHintBinary,
68+
// []byte values are base64-encoded as a single string rather than treated
69+
// as a generic slice of uint8.
70+
TypeHint TypeHint
71+
}
72+
73+
// StyleParamWithOptions serializes a Go value into an OpenAPI-styled parameter
74+
// string with additional options.
75+
func StyleParamWithOptions(style string, explode bool, paramName string, value interface{}, opts StyleParamOptions) (string, error) {
5876
t := reflect.TypeOf(value)
5977
v := reflect.ValueOf(value)
6078

@@ -83,24 +101,28 @@ func StyleParamWithLocation(style string, explode bool, paramName string, paramL
83101
return "", fmt.Errorf("error marshaling '%s' as text: %w", value, err)
84102
}
85103

86-
return stylePrimitive(style, explode, paramName, paramLocation, string(b))
104+
return stylePrimitive(style, explode, paramName, opts.ParamLocation, string(b))
87105
}
88106
}
89107

90108
switch t.Kind() {
91109
case reflect.Slice:
110+
if opts.TypeHint == TypeHintBinary && isByteSlice(t) {
111+
encoded := base64.StdEncoding.EncodeToString(v.Bytes())
112+
return stylePrimitive(style, explode, paramName, opts.ParamLocation, encoded)
113+
}
92114
n := v.Len()
93115
sliceVal := make([]interface{}, n)
94116
for i := 0; i < n; i++ {
95117
sliceVal[i] = v.Index(i).Interface()
96118
}
97-
return styleSlice(style, explode, paramName, paramLocation, sliceVal)
119+
return styleSlice(style, explode, paramName, opts.ParamLocation, sliceVal)
98120
case reflect.Struct:
99-
return styleStruct(style, explode, paramName, paramLocation, value)
121+
return styleStruct(style, explode, paramName, opts.ParamLocation, value)
100122
case reflect.Map:
101-
return styleMap(style, explode, paramName, paramLocation, value)
123+
return styleMap(style, explode, paramName, opts.ParamLocation, value)
102124
default:
103-
return stylePrimitive(style, explode, paramName, paramLocation, value)
125+
return stylePrimitive(style, explode, paramName, opts.ParamLocation, value)
104126
}
105127
}
106128

0 commit comments

Comments
 (0)