Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions bindparam.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ type BindStyledParameterOptions struct {
Explode bool
// Whether the parameter is required in the query
Required bool
// Type is the OpenAPI type of the parameter (e.g. "string", "integer").
Type string
// Format is the OpenAPI format of the parameter (e.g. "byte", "date-time").
// When set to "byte" and the destination is []byte, the value is
// base64-decoded rather than treated as a generic slice.
Format string
}

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

if t.Kind() == reflect.Slice {
if opts.Format == "byte" && isByteSlice(t) {
parts, err := splitStyledParameter(style, opts.Explode, false, paramName, value)
if err != nil {
return fmt.Errorf("error splitting input '%s' into parts: %w", value, err)
}
if len(parts) != 1 {
return fmt.Errorf("expected single base64 value for byte slice parameter '%s', got %d parts", paramName, len(parts))
}
decoded, err := base64Decode(parts[0])
if err != nil {
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, err)
}
v.SetBytes(decoded)
return nil
}

// Chop up the parameter into parts based on its style
parts, err := splitStyledParameter(style, opts.Explode, false, paramName, value)
if err != nil {
Expand Down Expand Up @@ -308,6 +330,22 @@ func bindSplitPartsToDestinationStruct(paramName string, parts []string, explode
// the Content parameter form.
func BindQueryParameter(style string, explode bool, required bool, paramName string,
queryParams url.Values, dest interface{}) error {
return BindQueryParameterWithOptions(style, explode, required, paramName, queryParams, dest, BindQueryParameterOptions{})
}

// BindQueryParameterOptions defines optional arguments for BindQueryParameterWithOptions.
type BindQueryParameterOptions struct {
// Type is the OpenAPI type of the parameter (e.g. "string", "integer").
Type string
// Format is the OpenAPI format of the parameter (e.g. "byte", "date-time").
// When set to "byte" and the destination is []byte, the value is
// base64-decoded rather than treated as a generic slice.
Format string
}

// BindQueryParameterWithOptions works like BindQueryParameter with additional options.
func BindQueryParameterWithOptions(style string, explode bool, required bool, paramName string,
queryParams url.Values, dest interface{}, opts BindQueryParameterOptions) error {

// dv = destination value.
dv := reflect.Indirect(reflect.ValueOf(dest))
Expand Down Expand Up @@ -378,7 +416,18 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
return nil
}
}
err = bindSplitPartsToDestinationArray(values, output)
if opts.Format == "byte" && isByteSlice(t) {
if len(values) != 1 {
return fmt.Errorf("expected single base64 value for byte slice parameter '%s', got %d values", paramName, len(values))
}
decoded, decErr := base64Decode(values[0])
if decErr != nil {
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, decErr)
}
v.SetBytes(decoded)
} else {
err = bindSplitPartsToDestinationArray(values, output)
}
case reflect.Struct:
// This case is really annoying, and error prone, but the
// form style object binding doesn't tell us which arguments
Expand Down Expand Up @@ -442,7 +491,18 @@ func BindQueryParameter(style string, explode bool, required bool, paramName str
var err error
switch k {
case reflect.Slice:
err = bindSplitPartsToDestinationArray(parts, output)
if opts.Format == "byte" && isByteSlice(t) {
// For non-exploded form, the value was split on commas above.
// Rejoin to get the original base64 string.
raw := strings.Join(parts, ",")
decoded, decErr := base64Decode(raw)
if decErr != nil {
return fmt.Errorf("error decoding base64 parameter '%s': %w", paramName, decErr)
}
v.SetBytes(decoded)
} else {
err = bindSplitPartsToDestinationArray(parts, output)
}
case reflect.Struct:
err = bindSplitPartsToDestinationStruct(paramName, parts, explode, output)
default:
Expand Down
89 changes: 89 additions & 0 deletions bindparam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,95 @@ import (
"github.com/oapi-codegen/runtime/types"
)

// TestBindStyledParameter_ByteSlice tests that BindStyledParameterWithOptions
// correctly handles *[]byte destinations by base64-decoding the parameter value,
// rather than treating []byte as a generic slice and splitting on commas.
// See: https://github.com/oapi-codegen/runtime/issues/97
func TestBindStyledParameter_ByteSlice(t *testing.T) {
expected := []byte("test")

tests := []struct {
name string
style string
explode bool
value string
}{
{"simple/no-explode", "simple", false, "dGVzdA=="},
{"simple/explode", "simple", true, "dGVzdA=="},
{"label/no-explode", "label", false, ".dGVzdA=="},
{"label/explode", "label", true, ".dGVzdA=="},
{"matrix/no-explode", "matrix", false, ";data=dGVzdA=="},
{"matrix/explode", "matrix", true, ";data=dGVzdA=="},
{"form/no-explode", "form", false, "data=dGVzdA=="},
{"form/explode", "form", true, "data=dGVzdA=="},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var dest []byte
err := BindStyledParameterWithOptions(tc.style, "data", tc.value, &dest, BindStyledParameterOptions{
ParamLocation: ParamLocationUndefined,
Explode: tc.explode,
Required: true,
Type: "string",
Format: "byte",
})
require.NoError(t, err)
assert.Equal(t, expected, dest)
})
}
}

// TestBindQueryParameter_ByteSlice tests that BindQueryParameter correctly
// handles *[]byte destinations by base64-decoding the query parameter value.
// See: https://github.com/oapi-codegen/runtime/issues/97
func TestBindQueryParameter_ByteSlice(t *testing.T) {
expected := []byte("test")

opts := BindQueryParameterOptions{Type: "string", Format: "byte"}

t.Run("form/explode/required", func(t *testing.T) {
var dest []byte
queryParams := url.Values{"data": {"dGVzdA=="}}
err := BindQueryParameterWithOptions("form", true, true, "data", queryParams, &dest, opts)
require.NoError(t, err)
assert.Equal(t, expected, dest)
})

t.Run("form/no-explode/required", func(t *testing.T) {
var dest []byte
queryParams := url.Values{"data": {"dGVzdA=="}}
err := BindQueryParameterWithOptions("form", false, true, "data", queryParams, &dest, opts)
require.NoError(t, err)
assert.Equal(t, expected, dest)
})

t.Run("form/explode/optional/present", func(t *testing.T) {
var dest *[]byte
queryParams := url.Values{"data": {"dGVzdA=="}}
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
require.NoError(t, err)
require.NotNil(t, dest)
assert.Equal(t, expected, *dest)
})

t.Run("form/explode/optional/absent", func(t *testing.T) {
var dest *[]byte
queryParams := url.Values{}
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
require.NoError(t, err)
assert.Nil(t, dest)
})

t.Run("form/explode/optional/empty", func(t *testing.T) {
var dest []byte
queryParams := url.Values{"data": {""}}
err := BindQueryParameterWithOptions("form", true, false, "data", queryParams, &dest, opts)
require.NoError(t, err)
assert.Equal(t, []byte{}, dest)
})
}

// MockBinder is just an independent version of Binder that has the Bind implemented
type MockBinder struct {
time.Time
Expand Down
27 changes: 27 additions & 0 deletions bindstring.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ import (
// know the destination type each place that we use this, is to generate code
// to read each specific type.
func BindStringToObject(src string, dst interface{}) error {
return BindStringToObjectWithOptions(src, dst, BindStringToObjectOptions{})
}

// BindStringToObjectOptions defines optional arguments for BindStringToObjectWithOptions.
type BindStringToObjectOptions struct {
// Type is the OpenAPI type of the parameter (e.g. "string", "integer").
Type string
// Format is the OpenAPI format of the parameter (e.g. "byte", "date-time").
// When set to "byte" and the destination is []byte, the source string is
// base64-decoded rather than treated as a generic slice.
Format string
}

// BindStringToObjectWithOptions takes a string, and attempts to assign it to the destination
// interface via whatever type conversion is necessary, with additional options.
func BindStringToObjectWithOptions(src string, dst interface{}, opts BindStringToObjectOptions) error {
var err error

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

switch t.Kind() {
case reflect.Slice:
if opts.Format == "byte" && isByteSlice(t) {
decoded, decErr := base64Decode(src)
if decErr != nil {
return fmt.Errorf("error binding string parameter: %w", decErr)
}
v.SetBytes(decoded)
return nil
}
// Non-binary slices fall through to the default error case.
fallthrough
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
var val int64
val, err = strconv.ParseInt(src, 10, 64)
Expand Down
48 changes: 48 additions & 0 deletions bindstring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
package runtime

import (
"encoding/base64"
"fmt"
"math"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

}

// TestBindStringToObject_ByteSlice tests that BindStringToObject correctly handles
// *[]byte destinations by base64-decoding the input string, rather than failing
// or treating []byte as a generic slice.
// See: https://github.com/oapi-codegen/runtime/issues/97
func TestBindStringToObject_ByteSlice(t *testing.T) {
opts := BindStringToObjectOptions{Type: "string", Format: "byte"}

t.Run("valid base64 with padding", func(t *testing.T) {
var dest []byte
err := BindStringToObjectWithOptions("dGVzdA==", &dest, opts)
require.NoError(t, err)
assert.Equal(t, []byte("test"), dest)
})

t.Run("valid base64 without padding", func(t *testing.T) {
var dest []byte
err := BindStringToObjectWithOptions("dGVzdA", &dest, opts)
require.NoError(t, err)
assert.Equal(t, []byte("test"), dest)
})

t.Run("URL-safe base64", func(t *testing.T) {
// "<<??>>+" in standard base64 is "PDw/Pz4+" but URL-safe uses "PDw_Pz4-"
input := "PDw_Pz4-"
var dest []byte
err := BindStringToObjectWithOptions(input, &dest, opts)
require.NoError(t, err)
expected, decErr := base64.RawURLEncoding.DecodeString("PDw_Pz4-")
require.NoError(t, decErr)
assert.Equal(t, expected, dest)
})

t.Run("empty string", func(t *testing.T) {
var dest []byte
err := BindStringToObjectWithOptions("", &dest, opts)
require.NoError(t, err)
assert.Equal(t, []byte{}, dest)
})

t.Run("invalid base64", func(t *testing.T) {
var dest []byte
err := BindStringToObjectWithOptions("!!!not-base64!!!", &dest, opts)
assert.Error(t, err)
})
}
52 changes: 52 additions & 0 deletions paramformat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package runtime

import (
"encoding/base64"
"fmt"
"reflect"
"strings"
)

// isByteSlice reports whether t is []byte (or equivalently []uint8).
func isByteSlice(t reflect.Type) bool {
return t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8
}

// base64Decode decodes s as base64.
//
// Per OpenAPI 3.0, format: byte uses RFC 4648 Section 4 (standard alphabet,
// padded). We use padding presence to select the right decoder, rather than
// blindly cascading (which can produce corrupt output when RawStdEncoding
// silently accepts padded input and treats '=' as data).
//
// The logic:
// 1. If s contains '=' padding → standard padded decoder (Std or URL based on alphabet).
// 2. If s contains URL-safe characters ('_' or '-') → RawURLEncoding.
// 3. Otherwise → RawStdEncoding (unpadded, standard alphabet).
func base64Decode(s string) ([]byte, error) {
if s == "" {
return []byte{}, nil
}

if strings.ContainsRune(s, '=') {
// Padded input. Pick alphabet based on whether URL-safe chars are present.
if strings.ContainsAny(s, "-_") {
return base64Decode1(base64.URLEncoding, s)
}
return base64Decode1(base64.StdEncoding, s)
}

// Unpadded input. Pick alphabet based on whether URL-safe chars are present.
if strings.ContainsAny(s, "-_") {
return base64Decode1(base64.RawURLEncoding, s)
}
return base64Decode1(base64.RawStdEncoding, s)
}

func base64Decode1(enc *base64.Encoding, s string) ([]byte, error) {
b, err := enc.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("failed to base64-decode string %q: %w", s, err)
}
return b, nil
}
Loading