From 567fbb31760f1e04dac4de01857bec9457475952 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 15 Aug 2026 21:41:52 -0700 Subject: [PATCH] Bind OpenAPI 3.1 multi-type union parameters into any destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parameter declared with a 3.1 multi-type union (type: [string, integer]) generates an `any` destination, which the binder rejected unconditionally: "can not bind to destination of type: interface". The binder is destination-driven, and an interface destination carries no information. Add a Types field to BindStyledParameterOptions, BindQueryParameterOptions and BindStringToObjectOptions carrying the union's member list. It is only consulted when the destination is an empty interface, so concrete destinations keep the reflection-driven path unchanged. The value binds to the first member that parses, in specificity order (boolean, integer, number, string) rather than declaration order: JSON Schema defines the type array as an unordered set, and the always-succeeding string member would otherwise shadow the rest. Numeric detection follows the JSON number production (RFC 8259), so "007" and "+1" stay strings instead of being reinterpreted. The bound value's dynamic type is one of exactly bool, int64, float64, string, or []byte. Format "byte" is the one load-bearing format (it changes the wire decoding, base64-decoding the string member); width formats (int32/int64, float/double) and annotation-only formats (date-time, uuid, ...) are ignored by default so that a spec edit to `format` can never silently change the dynamic type a running handler's type switch sees. Applications that want width narrowing opt in via the NarrowUnionNumericFormats package variable — the DefaultQueryEncoder pattern — which makes format int32 produce int32 and format float produce float32, with out-of-range values falling through to the next member. The "null" nullability marker is ignored wherever it appears, whether or not the generator stripped it. Arrays of unions and deepObject-style binding are documented as out of scope. Closes #153 Co-Authored-By: Claude Fable 5 --- bindparam.go | 42 +++- bindstring.go | 38 ++++ bindunion.go | 217 ++++++++++++++++++++ bindunion_test.go | 497 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 bindunion.go create mode 100644 bindunion_test.go diff --git a/bindparam.go b/bindparam.go index 10e0aa6..ecf2bc3 100644 --- a/bindparam.go +++ b/bindparam.go @@ -80,6 +80,18 @@ type BindStyledParameterOptions struct { // When set to "byte" and the destination is []byte, the value is // base64-decoded rather than treated as a generic slice. Format string + // Types is the OpenAPI 3.1 multi-type union member list of the parameter + // (e.g. ["string", "integer"]). It is only consulted when the + // destination is an empty interface (`any`): the value binds to the + // first member that parses, trying boolean, integer, number, then + // string, with numeric detection following the JSON number production. + // The bound value's dynamic type is one of exactly bool, int64, float64, + // string, or, with Format "byte", []byte (widths narrow only under the + // NarrowUnionNumericFormats package variable). Concrete destinations ignore + // it and keep the reflection-driven behavior; arrays of unions and + // deepObject-style binding are not covered. See + // BindStringToObjectOptions.Types for the full semantics and scope. + Types []string // AllowReserved, when true, indicates that the parameter value may // contain RFC 3986 reserved characters without percent-encoding. AllowReserved bool @@ -193,7 +205,11 @@ func BindStyledParameterWithOptions(style string, paramName string, value string } value = parts[0] } - return BindStringToObject(value, dest) + return BindStringToObjectWithOptions(value, dest, BindStringToObjectOptions{ + Type: opts.Type, + Format: opts.Format, + Types: opts.Types, + }) } // This is a complex set of operations, but each given parameter style can be @@ -386,6 +402,18 @@ type BindQueryParameterOptions struct { // When set to "byte" and the destination is []byte, the value is // base64-decoded rather than treated as a generic slice. Format string + // Types is the OpenAPI 3.1 multi-type union member list of the parameter + // (e.g. ["string", "integer"]). It is only consulted when the + // destination is an empty interface (`any`): the value binds to the + // first member that parses, trying boolean, integer, number, then + // string, with numeric detection following the JSON number production. + // The bound value's dynamic type is one of exactly bool, int64, float64, + // string, or, with Format "byte", []byte (widths narrow only under the + // NarrowUnionNumericFormats package variable). Concrete destinations ignore + // it and keep the reflection-driven behavior; arrays of unions and + // deepObject-style binding are not covered. See + // BindStringToObjectOptions.Types for the full semantics and scope. + Types []string // AllowReserved, when true, indicates that the parameter value may // contain RFC 3986 reserved characters without percent-encoding. AllowReserved bool @@ -520,7 +548,11 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa return nil } } - err = BindStringToObject(values[0], output) + err = BindStringToObjectWithOptions(values[0], output, BindStringToObjectOptions{ + Type: opts.Type, + Format: opts.Format, + Types: opts.Types, + }) } if err != nil { return err @@ -552,7 +584,11 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa // is only meaningful for array and object types. // See: https://swagger.io/docs/specification/serialization/ if k != reflect.Slice && k != reflect.Struct && k != reflect.Map { - err := BindStringToObject(values[0], output) + err := BindStringToObjectWithOptions(values[0], output, BindStringToObjectOptions{ + Type: opts.Type, + Format: opts.Format, + Types: opts.Types, + }) if err != nil { return err } diff --git a/bindstring.go b/bindstring.go index ad4271e..593af85 100644 --- a/bindstring.go +++ b/bindstring.go @@ -42,6 +42,28 @@ type BindStringToObjectOptions struct { // When set to "byte" and the destination is []byte, the source string is // base64-decoded rather than treated as a generic slice. Format string + // Types is the OpenAPI 3.1 multi-type union member list of the parameter + // (e.g. ["string", "integer"]). A "null" entry — the 3.1 nullability + // marker, not a union member — is ignored, whether or not the generator + // already stripped it. (Type, which the runtime does not currently read, + // carries no meaning when Types is set.) + // + // Types is only consulted when the destination is an empty interface + // (`any`): the source string is bound to the first member that parses, + // trying boolean, integer, number, then string (most restrictive grammar + // first — the always-succeeding string member would otherwise shadow the + // rest). Numeric detection uses the JSON number production (RFC 8259 + // section 6), so tokens like "007" and "+1" bind as strings. The bound + // value's dynamic type is one of exactly bool, int64, float64, string, + // or, with Format "byte", []byte; width formats (int32, float, ...) are + // annotation-only unless the application opts into narrowing via the + // NarrowUnionNumericFormats package variable. + // + // Concrete destinations ignore this field and keep the reflection-driven + // behavior. Array element binding does not yet support unions, and + // deepObject-style binding does not consult this field (its JSON decode + // path produces float64 for all numbers). + Types []string } // BindStringToObjectWithOptions takes a string, and attempts to assign it to the destination @@ -190,6 +212,22 @@ func BindStringToObjectWithOptions(src string, dst interface{}, opts BindStringT // We fall through to the error case below if we haven't handled the // destination type above. fallthrough + case reflect.Interface: + // An interface destination normally can't be bound: there is no + // type information to parse with, so it falls to the error below. + // The exception is an empty interface (`any`) destination for a + // declared OpenAPI 3.1 multi-type union — opts.Types names the + // member types, and the value binds to the first member that + // parses. See bindStringToUnionMember for the exact semantics. + if t.Kind() == reflect.Interface && t.NumMethod() == 0 && len(opts.Types) > 0 { + bound, bindErr := bindStringToUnionMember(src, opts) + if bindErr != nil { + return fmt.Errorf("error binding string parameter: %w", bindErr) + } + v.Set(reflect.ValueOf(bound)) + return nil + } + fallthrough case reflect.Map: // A bool-keyed map (such as nullable.Nullable[T], which is // map[bool]T) is treated as a nullable wrapper: bind src into a diff --git a/bindunion.go b/bindunion.go new file mode 100644 index 0000000..fe81536 --- /dev/null +++ b/bindunion.go @@ -0,0 +1,217 @@ +package runtime + +import ( + "fmt" + "strconv" + "strings" +) + +// NarrowUnionNumericFormats controls whether numeric width formats narrow +// the dynamic type produced when a multi-type union parameter is bound into +// an `any` destination. +// +// When false (the default), the bound value's dynamic type is always one of +// bool, int64, float64, string, or []byte, regardless of the schema's +// `format`: an edit to a spec's format can never change the types a running +// handler's type switch sees. When true, `format: int32` produces int32 +// (values outside int32 range fall through to the next union member) and +// `format: float` produces float32, widening the possible dynamic types to +// bool, int32, int64, float32, float64, string, and []byte. `int64` and +// `double` name the defaults either way. Formats never affect concrete +// (non-`any`) destinations, whose Go type was fixed at generation time. +// +// Note one asymmetry with concrete destinations: a concrete int32 +// destination rejects an out-of-range value with an overflow error, but an +// `any` destination binds it to the next union member instead — typically +// the string member, verbatim. Enabling narrowing to get int32 typing +// therefore also accepts that silent widening; a handler that needs +// out-of-range values rejected must check for the string case itself. +// +// Like DefaultQueryEncoder, set it once during program initialization; it is +// not safe to mutate concurrently with in-flight requests. The opt-in lives +// here rather than in generated code because the trade-off belongs to +// whoever owns the handler's type switch: enabling it is a promise that the +// application handles the narrowed types. +var NarrowUnionNumericFormats bool + +// unionMemberOrder is the order in which union member types are attempted +// when binding a parameter value into an `any` destination: most restrictive +// grammar first, so that the always-succeeding string member cannot shadow +// the others. This is deliberately NOT the schema's declaration order — JSON +// Schema defines the `type` array as an unordered set, so declaration order +// carries no meaning, and any tool that normalizes a spec could otherwise +// silently change binding behavior. The "null" nullability marker and +// non-scalar names ("array", "object") never appear here, so they are +// structurally skipped during the walk regardless of what the generator +// emitted in Types. +var unionMemberOrder = [4]string{"boolean", "integer", "number", "string"} + +// bindStringToUnionMember binds src against the members of an OpenAPI 3.1 +// multi-type union (opts.Types), returning the value of the first member +// that parses. Members are tried in unionMemberOrder, restricted to the +// members actually present in opts.Types. +// +// Numeric detection uses the JSON number production (RFC 8259 section 6), +// not strconv leniency: "007", "+1" and " 1" are not JSON numbers, so they +// fall through to the string member rather than being silently +// reinterpreted. +// +// The dynamic type of the returned value is one of exactly bool, int64, +// float64, string, or — with Format "byte" — []byte. Width formats (int32, +// int64, float, double) are annotation-only by default and do not narrow +// the produced type: honoring them would mean an edit to a spec's `format` +// silently changes the dynamic type a running handler's type switch sees, +// with no compile error. Applications that want width narrowing opt in via +// the NarrowUnionNumericFormats package variable. "byte" is always +// load-bearing because it changes the wire decoding (base64) rather than a +// width; other annotation-only formats (date-time, uuid, ...) are ignored — +// per OpenAPI 3.1 semantics `format` is an annotation and must not reject a +// value, so parse failure cannot discriminate members. A format whose host +// type is not present in opts.Types is inert. +// +// Non-scalar member names ("array", "object"), the "null" nullability marker +// and unknown names are skipped: styled serialization of those into `any` +// has no defined meaning. If no member parses, an error naming the union's +// bindable members is returned. +func bindStringToUnionMember(src string, opts BindStringToObjectOptions) (any, error) { + for _, name := range unionMemberOrder { + if !unionHasMember(opts.Types, name) { + continue + } + switch name { + case "boolean": + // JSON grammar: exactly the lowercase literals, unlike + // strconv.ParseBool which also accepts "1", "t", "TRUE", etc. + if src == "true" { + return true, nil + } + if src == "false" { + return false, nil + } + case "integer": + if isJSONInteger(src) { + if NarrowUnionNumericFormats && opts.Format == "int32" { + if val, err := strconv.ParseInt(src, 10, 32); err == nil { + return int32(val), nil + } + } else if val, err := strconv.ParseInt(src, 10, 64); err == nil { + return val, nil + } + // Overflow of the (possibly narrowed) width: not + // representable as this member, fall through to the next + // one (number takes it as a float, string takes it + // verbatim). + } + case "number": + if isJSONNumber(src) { + if NarrowUnionNumericFormats && opts.Format == "float" { + if val, err := strconv.ParseFloat(src, 32); err == nil { + return float32(val), nil + } + } else if val, err := strconv.ParseFloat(src, 64); err == nil { + return val, nil + } + // Out of range for the (possibly narrowed) width: fall + // through. + } + case "string": + if opts.Format == "byte" { + // Consistent with the concrete []byte destination: a + // declared base64 wire encoding that doesn't decode is an + // error, not a silent fallback to the raw string. + // base64Decode's error already names the offending value. + return base64Decode(src) + } + return src, nil + } + } + + // Name only the bindable members in the error: the generator is expected + // to strip the "null" nullability marker before emitting Types, but the + // runtime and generator version independently, so don't rely on it. + members := make([]string, 0, len(opts.Types)) + for _, name := range opts.Types { + if name != "null" { + members = append(members, name) + } + } + if len(members) == 0 { + // Degenerate input (e.g. Types: ["null"]): say so instead of + // printing "type union []", which would read as a runtime bug. + return nil, fmt.Errorf("value '%s' can not bind: type union has no bindable members (declared %v)", src, opts.Types) + } + return nil, fmt.Errorf("value '%s' does not match any member of type union %v", src, members) +} + +// unionHasMember reports whether name appears in types. A linear scan: the +// list has at most a handful of entries and this runs per parameter per +// request, so avoiding a map allocation matters more than big-O. +func unionHasMember(types []string, name string) bool { + for _, t := range types { + if t == name { + return true + } + } + return false +} + +// isJSONNumber reports whether s is a number under JSON grammar (RFC 8259): +// an optional leading '-', an integer part with no leading zeros, and +// optional fraction and exponent parts. No '+' sign, no whitespace, no hex. +func isJSONNumber(s string) bool { + i := 0 + if i < len(s) && s[i] == '-' { + i++ + } + // Integer part: "0", or a nonzero digit followed by digits. + if i >= len(s) { + return false + } + switch { + case s[i] == '0': + i++ + case s[i] >= '1' && s[i] <= '9': + i++ + for i < len(s) && isDigit(s[i]) { + i++ + } + default: + return false + } + // Fraction part. + if i < len(s) && s[i] == '.' { + i++ + if i >= len(s) || !isDigit(s[i]) { + return false + } + for i < len(s) && isDigit(s[i]) { + i++ + } + } + // Exponent part. + if i < len(s) && (s[i] == 'e' || s[i] == 'E') { + i++ + if i < len(s) && (s[i] == '+' || s[i] == '-') { + i++ + } + if i >= len(s) || !isDigit(s[i]) { + return false + } + for i < len(s) && isDigit(s[i]) { + i++ + } + } + return i == len(s) +} + +// isJSONInteger reports whether s is an integer token under JSON grammar: a +// JSON number with no fraction or exponent part. This deliberately rejects +// strconv leniencies like "007" or "+1", which would silently change the +// value ("007" binds as the string "007", not the integer 7). +func isJSONInteger(s string) bool { + return isJSONNumber(s) && !strings.ContainsAny(s, ".eE") +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} diff --git a/bindunion_test.go b/bindunion_test.go new file mode 100644 index 0000000..cb3053b --- /dev/null +++ b/bindunion_test.go @@ -0,0 +1,497 @@ +package runtime + +import ( + "net/url" + "testing" + + "github.com/oapi-codegen/nullable" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Binding a value into an `any` destination with a declared multi-type union +// picks the first member that parses, in specificity order (boolean, +// integer, number, string) — not schema declaration order, where the +// always-succeeding string member would shadow the rest. +func TestBindStringToObject_UnionMemberSelection(t *testing.T) { + testCases := []struct { + name string + src string + opts BindStringToObjectOptions + want any + wantErr string + }{ + { + name: "integer member wins over string for integer token", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"string", "integer"}}, + want: int64(123), + }, + { + name: "number member takes fractional token", + src: "1.5", + opts: BindStringToObjectOptions{Types: []string{"string", "number"}}, + want: float64(1.5), + }, + { + name: "integer wins over number for integer token", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"number", "integer", "string"}}, + want: int64(123), + }, + { + name: "number takes exponent token when integer present", + src: "1e2", + opts: BindStringToObjectOptions{Types: []string{"integer", "number", "string"}}, + want: float64(100), + }, + { + name: "boolean member wins for JSON boolean literal", + src: "true", + opts: BindStringToObjectOptions{Types: []string{"string", "boolean"}}, + want: true, + }, + { + name: "non-token falls to string member", + src: "abc", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}}, + want: "abc", + }, + { + name: "empty string binds to string member", + src: "", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}}, + want: "", + }, + { + name: "negative integer token", + src: "-42", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}}, + want: int64(-42), + }, + // JSON grammar, not strconv leniency: these are not JSON numbers, + // so they must not be silently reinterpreted. + { + name: "leading zeros stay a string", + src: "007", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}}, + want: "007", + }, + { + name: "plus sign stays a string", + src: "+1", + opts: BindStringToObjectOptions{Types: []string{"integer", "number", "string"}}, + want: "+1", + }, + { + name: "surrounding space stays a string", + src: " 1", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}}, + want: " 1", + }, + { + name: "uppercase TRUE is not a JSON boolean", + src: "TRUE", + opts: BindStringToObjectOptions{Types: []string{"boolean", "string"}}, + want: "TRUE", + }, + { + name: "int64 overflow falls through to string", + src: "99999999999999999999", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}}, + want: "99999999999999999999", + }, + { + name: "int64 overflow falls through to number", + src: "99999999999999999999", + opts: BindStringToObjectOptions{Types: []string{"integer", "number"}}, + want: float64(99999999999999999999), + }, + // Width formats are annotation-only: the dynamic type surface stays + // bool/int64/float64/string/[]byte no matter what format says, so a + // spec edit to `format` can never break a running type switch. + { + name: "format int32 does not narrow the integer member", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "int32"}, + want: int64(123), + }, + { + name: "format int32 does not reject values beyond int32 range", + src: "3000000000", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "int32"}, + want: int64(3000000000), + }, + { + name: "format float does not narrow the number member", + src: "1.5", + opts: BindStringToObjectOptions{Types: []string{"number", "string"}, Format: "float"}, + want: float64(1.5), + }, + { + name: "format byte base64-decodes the string member", + src: "MTIz", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "byte"}, + want: []byte("123"), + }, + { + name: "format byte does not touch the integer member", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "byte"}, + want: int64(123), + }, + { + name: "format byte with invalid base64 is an error", + src: "not!!base64", + opts: BindStringToObjectOptions{Types: []string{"string"}, Format: "byte"}, + wantErr: "failed to base64-decode", + }, + { + name: "annotation-only format is ignored", + src: "2024-01-01T00:00:00Z", + opts: BindStringToObjectOptions{Types: []string{"string", "number"}, Format: "date-time"}, + want: "2024-01-01T00:00:00Z", + }, + // Defensive handling of member names. + { + name: "null marker is ignored even if not stripped", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"null", "string", "integer"}}, + want: int64(123), + }, + { + name: "unstripped nullable single type binds as that type", + src: "abc", + opts: BindStringToObjectOptions{Types: []string{"string", "null"}}, + want: "abc", + }, + { + name: "null alone is not a bindable member", + src: "abc", + opts: BindStringToObjectOptions{Types: []string{"null"}}, + wantErr: "type union has no bindable members (declared [null])", + }, + { + name: "non-scalar members are skipped", + src: "a,b,c", + opts: BindStringToObjectOptions{Types: []string{"array", "string"}}, + want: "a,b,c", + }, + { + name: "no member parses", + src: "abc", + opts: BindStringToObjectOptions{Types: []string{"integer", "boolean"}}, + wantErr: "does not match any member of type union", + }, + { + name: "only non-scalar members", + src: "abc", + opts: BindStringToObjectOptions{Types: []string{"array", "object"}}, + wantErr: "does not match any member of type union", + }, + { + name: "error names only bindable members, null filtered out", + src: "abc", + opts: BindStringToObjectOptions{Types: []string{"null", "integer"}}, + wantErr: "type union [integer]", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var dest any + err := BindStringToObjectWithOptions(tc.src, &dest, tc.opts) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, dest) + }) + } +} + +// An interface destination without a declared union keeps the historical +// error, so nothing changes for existing generated code. +func TestBindStringToObject_InterfaceWithoutTypesStillErrors(t *testing.T) { + var dest any + err := BindStringToObject("123", &dest) + assert.ErrorContains(t, err, "can not bind to destination of type: interface") + + err = BindStringToObjectWithOptions("123", &dest, BindStringToObjectOptions{Type: "string"}) + assert.ErrorContains(t, err, "can not bind to destination of type: interface") +} + +// Types is only consulted for interface destinations: a concrete destination +// keeps the reflection-driven path even when Types is set. +func TestBindStringToObject_ConcreteDestinationIgnoresTypes(t *testing.T) { + var s string + err := BindStringToObjectWithOptions("123", &s, BindStringToObjectOptions{Types: []string{"string", "integer"}}) + require.NoError(t, err) + assert.Equal(t, "123", s) +} + +// A nullable wrapper around `any` (nullable.Nullable[any] is map[bool]any) +// routes through the bool-keyed-map path and binds the inner value via the +// union walk. +func TestBindStringToObject_NullableAnyUnion(t *testing.T) { + var dest nullable.Nullable[any] + err := BindStringToObjectWithOptions("123", &dest, BindStringToObjectOptions{Types: []string{"string", "integer"}}) + require.NoError(t, err) + got, err := dest.Get() + require.NoError(t, err) + assert.Equal(t, int64(123), got) +} + +// End-to-end through the styled binder, as generated server code calls it +// for path and header parameters. +func TestBindStyledParameterWithOptions_Union(t *testing.T) { + opts := BindStyledParameterOptions{ + ParamLocation: ParamLocationPath, + Required: true, + Types: []string{"string", "integer"}, + ValueIsUnescaped: true, + } + + var id any + require.NoError(t, BindStyledParameterWithOptions("simple", "id", "42", &id, opts)) + assert.Equal(t, int64(42), id) + + require.NoError(t, BindStyledParameterWithOptions("simple", "id", "abc", &id, opts)) + assert.Equal(t, "abc", id) + + // label style strips its prefix before binding. + require.NoError(t, BindStyledParameterWithOptions("label", "id", ".42", &id, opts)) + assert.Equal(t, int64(42), id) +} + +// End-to-end through the query binder, exploded and non-exploded, required +// and optional. +func TestBindQueryParameterWithOptions_Union(t *testing.T) { + opts := BindQueryParameterOptions{Types: []string{"string", "number", "boolean"}} + queryParams := url.Values{ + "filter": {"1.5"}, + "flag": {"true"}, + "note": {"hello"}, + } + + var filter any + require.NoError(t, BindQueryParameterWithOptions("form", true, false, "filter", queryParams, &filter, opts)) + assert.Equal(t, float64(1.5), filter) + + var flag any + require.NoError(t, BindQueryParameterWithOptions("form", true, true, "flag", queryParams, &flag, opts)) + assert.Equal(t, true, flag) + + var note any + require.NoError(t, BindQueryParameterWithOptions("form", false, false, "note", queryParams, ¬e, opts)) + assert.Equal(t, "hello", note) + + // An absent optional parameter leaves the destination untouched. + var missing any + require.NoError(t, BindQueryParameterWithOptions("form", true, false, "absent", queryParams, &missing, opts)) + assert.Nil(t, missing) + + // An absent required parameter is still an error. + var absent any + err := BindQueryParameterWithOptions("form", true, true, "absent", queryParams, &absent, opts) + var requiredErr *RequiredParameterError + assert.ErrorAs(t, err, &requiredErr) +} + +// Opting in via the NarrowUnionNumericFormats package variable makes width +// formats load-bearing: int32/float narrow the produced dynamic type, and +// values outside the narrowed range fall through to the next member. Not +// t.Parallel(): mutates package-global state, like DefaultQueryEncoder. +func TestBindStringToObject_UnionNarrowingOptIn(t *testing.T) { + NarrowUnionNumericFormats = true + t.Cleanup(func() { NarrowUnionNumericFormats = false }) + + testCases := []struct { + name string + src string + opts BindStringToObjectOptions + want any + }{ + { + name: "format int32 narrows the integer member", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "int32"}, + want: int32(123), + }, + { + name: "int32 overflow falls through to string", + src: "3000000000", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "int32"}, + want: "3000000000", + }, + { + name: "int32 overflow falls through to number", + src: "3000000000", + opts: BindStringToObjectOptions{Types: []string{"integer", "number"}, Format: "int32"}, + want: float64(3000000000), + }, + { + name: "format float narrows the number member", + src: "1.5", + opts: BindStringToObjectOptions{Types: []string{"number", "string"}, Format: "float"}, + want: float32(1.5), + }, + { + name: "float32 overflow falls through to string", + src: "1e40", + opts: BindStringToObjectOptions{Types: []string{"number", "string"}, Format: "float"}, + want: "1e40", + }, + { + name: "format int64 names the default width", + src: "123", + opts: BindStringToObjectOptions{Types: []string{"integer", "string"}, Format: "int64"}, + want: int64(123), + }, + { + name: "format double names the default width", + src: "1.5", + opts: BindStringToObjectOptions{Types: []string{"number", "string"}, Format: "double"}, + want: float64(1.5), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var dest any + err := BindStringToObjectWithOptions(tc.src, &dest, tc.opts) + require.NoError(t, err) + assert.Equal(t, tc.want, dest) + }) + } +} + +// A non-empty interface destination never takes the union path, with or +// without Types: only empty interfaces (`any`) qualify. +func TestBindStringToObject_TypedInterfaceWithTypesStillErrors(t *testing.T) { + var dest interface{ Foo() } + err := BindStringToObjectWithOptions("42", &dest, BindStringToObjectOptions{Types: []string{"string", "integer"}}) + assert.ErrorContains(t, err, "can not bind to destination of type: interface") +} + +// A named empty interface is still an empty interface: the NumMethod()==0 +// guard admits it to the union walk exactly like a literal `any`. +func TestBindStringToObject_NamedEmptyInterfaceUnion(t *testing.T) { + type myAny interface{} + var dest myAny + err := BindStringToObjectWithOptions("42", &dest, BindStringToObjectOptions{Types: []string{"string", "integer"}}) + require.NoError(t, err) + assert.Equal(t, int64(42), dest) +} + +// Non-interface kinds that flow through the Array -> Struct -> Interface -> +// Map -> default fallthrough chain keep their historical errors even when +// Types is set. This locks the chain ordering in bindstring.go's switch. +func TestBindStringToObject_NonInterfaceKindsWithTypesStillError(t *testing.T) { + opts := BindStringToObjectOptions{Types: []string{"string", "integer"}} + + var arr [2]int + assert.ErrorContains(t, BindStringToObjectWithOptions("42", &arr, opts), + "can not bind to destination of type: array") + + var st struct{ X int } + assert.ErrorContains(t, BindStringToObjectWithOptions("42", &st, opts), + "can not bind to destination of type: struct") + + var m map[string]string + assert.ErrorContains(t, BindStringToObjectWithOptions("42", &m, opts), + "can not bind to destination of type: map") +} + +// Styled binding still unescapes values by default (ValueIsUnescaped=false), +// and unescaping happens before member selection — percent-encoding can flip +// which member wins. +func TestBindStyledParameterWithOptions_UnionUnescapesFirst(t *testing.T) { + opts := BindStyledParameterOptions{ + ParamLocation: ParamLocationPath, + Required: true, + Types: []string{"string", "integer"}, + } + + // "4%32" path-unescapes to "42", so the integer member wins. + var v any + require.NoError(t, BindStyledParameterWithOptions("simple", "id", "4%32", &v, opts)) + assert.Equal(t, int64(42), v) + + // Headers are never unescaped: "4%32" stays a string. + hOpts := opts + hOpts.ParamLocation = ParamLocationHeader + require.NoError(t, BindStyledParameterWithOptions("simple", "X-Id", "4%32", &v, hOpts)) + assert.Equal(t, "4%32", v) +} + +// Matrix style strips its ;name= prefix before the union walk, like label +// strips its dot. +func TestBindStyledParameterWithOptions_UnionMatrixStyle(t *testing.T) { + opts := BindStyledParameterOptions{ + ParamLocation: ParamLocationPath, + Required: true, + Types: []string{"string", "integer"}, + ValueIsUnescaped: true, + } + var v any + require.NoError(t, BindStyledParameterWithOptions("matrix", "id", ";id=42", &v, opts)) + assert.Equal(t, int64(42), v) +} + +// Optional query parameters generated without x-go-type-skip-optional-pointer +// arrive as **any (pointer to the struct's *any field). The extra-indirect +// path must allocate and bind through it. +func TestBindQueryParameterWithOptions_UnionOptionalPointer(t *testing.T) { + opts := BindQueryParameterOptions{Types: []string{"string", "integer"}} + queryParams := url.Values{"filter": {"42"}} + + var filter *any + require.NoError(t, BindQueryParameterWithOptions("form", true, false, "filter", queryParams, &filter, opts)) + require.NotNil(t, filter) + assert.Equal(t, int64(42), *filter) + + // Absent optional parameter leaves the pointer nil. + var absent *any + require.NoError(t, BindQueryParameterWithOptions("form", true, false, "absent", queryParams, &absent, opts)) + assert.Nil(t, absent) +} + +// A parameter present with an empty value binds the empty string when a +// string member exists, and errors when it does not. +func TestBindQueryParameterWithOptions_UnionEmptyValue(t *testing.T) { + queryParams := url.Values{"p": {""}} + + var withString any + require.NoError(t, BindQueryParameterWithOptions("form", true, true, "p", queryParams, &withString, + BindQueryParameterOptions{Types: []string{"integer", "string"}})) + assert.Equal(t, "", withString) + + var withoutString any + err := BindQueryParameterWithOptions("form", true, false, "p", queryParams, &withoutString, + BindQueryParameterOptions{Types: []string{"integer"}}) + assert.ErrorContains(t, err, "does not match any member of type union") +} + +func TestIsJSONNumber(t *testing.T) { + valid := []string{"0", "-0", "1", "-1", "123", "1.5", "-1.5", "0.5", "1e2", "1E2", "1e+2", "1e-2", "1.5e10", "0.0"} + for _, s := range valid { + assert.True(t, isJSONNumber(s), "expected %q to be a JSON number", s) + } + invalid := []string{"", "-", "+1", "01", "007", "-01", ".5", "1.", "1e", "1e+", "0x10", "1 ", " 1", "1,000", "NaN", "Infinity", "--1", "1..5", "1.5.5"} + for _, s := range invalid { + assert.False(t, isJSONNumber(s), "expected %q to NOT be a JSON number", s) + } +} + +func TestIsJSONInteger(t *testing.T) { + valid := []string{"0", "-0", "1", "-1", "123", "-123"} + for _, s := range valid { + assert.True(t, isJSONInteger(s), "expected %q to be a JSON integer", s) + } + invalid := []string{"", "1.5", "1e2", "01", "+1", "1.0", "-", "abc"} + for _, s := range invalid { + assert.False(t, isJSONInteger(s), "expected %q to NOT be a JSON integer", s) + } +}