Skip to content

Commit f2aa995

Browse files
mromaszewiczclaude
andcommitted
Bind OpenAPI 3.1 multi-type union parameters into any destinations
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 JSON number grammar, so "007" and "+1" stay strings instead of being reinterpreted, and the bound value matches what the same token would produce inside a JSON document. Format applies only to its host member and only where it changes decoding: int32/int64 and float/double select numeric widths, byte base64-decodes the string member; annotation-only formats are ignored per 3.1 semantics. Non-scalar members and the "null" marker are skipped. Closes #153 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d8c6443 commit f2aa995

4 files changed

Lines changed: 554 additions & 3 deletions

File tree

bindparam.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ type BindStyledParameterOptions struct {
8080
// When set to "byte" and the destination is []byte, the value is
8181
// base64-decoded rather than treated as a generic slice.
8282
Format string
83+
// Types is the OpenAPI 3.1 multi-type union member list of the parameter
84+
// (e.g. ["string", "integer"]). A "null" entry — the 3.1 nullability
85+
// marker, not a union member — is ignored, whether or not the generator
86+
// already stripped it. When non-empty it takes precedence over
87+
// Type. It is only consulted when the destination is an empty interface
88+
// (`any`): the value binds to the first member that parses, trying
89+
// boolean, integer, number, then string, with numeric detection
90+
// following JSON number grammar. Concrete destinations ignore it and
91+
// keep the reflection-driven behavior. See BindStringToObjectOptions.
92+
Types []string
8393
// AllowReserved, when true, indicates that the parameter value may
8494
// contain RFC 3986 reserved characters without percent-encoding.
8595
AllowReserved bool
@@ -193,7 +203,11 @@ func BindStyledParameterWithOptions(style string, paramName string, value string
193203
}
194204
value = parts[0]
195205
}
196-
return BindStringToObject(value, dest)
206+
return BindStringToObjectWithOptions(value, dest, BindStringToObjectOptions{
207+
Type: opts.Type,
208+
Format: opts.Format,
209+
Types: opts.Types,
210+
})
197211
}
198212

199213
// This is a complex set of operations, but each given parameter style can be
@@ -386,6 +400,16 @@ type BindQueryParameterOptions struct {
386400
// When set to "byte" and the destination is []byte, the value is
387401
// base64-decoded rather than treated as a generic slice.
388402
Format string
403+
// Types is the OpenAPI 3.1 multi-type union member list of the parameter
404+
// (e.g. ["string", "integer"]). A "null" entry — the 3.1 nullability
405+
// marker, not a union member — is ignored, whether or not the generator
406+
// already stripped it. When non-empty it takes precedence over
407+
// Type. It is only consulted when the destination is an empty interface
408+
// (`any`): the value binds to the first member that parses, trying
409+
// boolean, integer, number, then string, with numeric detection
410+
// following JSON number grammar. Concrete destinations ignore it and
411+
// keep the reflection-driven behavior. See BindStringToObjectOptions.
412+
Types []string
389413
// AllowReserved, when true, indicates that the parameter value may
390414
// contain RFC 3986 reserved characters without percent-encoding.
391415
AllowReserved bool
@@ -520,7 +544,11 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
520544
return nil
521545
}
522546
}
523-
err = BindStringToObject(values[0], output)
547+
err = BindStringToObjectWithOptions(values[0], output, BindStringToObjectOptions{
548+
Type: opts.Type,
549+
Format: opts.Format,
550+
Types: opts.Types,
551+
})
524552
}
525553
if err != nil {
526554
return err
@@ -552,7 +580,11 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
552580
// is only meaningful for array and object types.
553581
// See: https://swagger.io/docs/specification/serialization/
554582
if k != reflect.Slice && k != reflect.Struct && k != reflect.Map {
555-
err := BindStringToObject(values[0], output)
583+
err := BindStringToObjectWithOptions(values[0], output, BindStringToObjectOptions{
584+
Type: opts.Type,
585+
Format: opts.Format,
586+
Types: opts.Types,
587+
})
556588
if err != nil {
557589
return err
558590
}

bindstring.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ type BindStringToObjectOptions struct {
4242
// When set to "byte" and the destination is []byte, the source string is
4343
// base64-decoded rather than treated as a generic slice.
4444
Format string
45+
// Types is the OpenAPI 3.1 multi-type union member list of the parameter
46+
// (e.g. ["string", "integer"]). A "null" entry — the 3.1 nullability
47+
// marker, not a union member — is ignored, whether or not the generator
48+
// already stripped it. When non-empty it takes precedence over
49+
// Type. It is only consulted when the destination is an empty interface
50+
// (`any`): the source string is bound to the first member that parses,
51+
// trying boolean, integer, number, then string (most restrictive grammar
52+
// first — the always-succeeding string member would otherwise shadow the
53+
// rest). Numeric detection follows JSON number grammar, so the bound
54+
// value matches what the same token would produce inside a JSON
55+
// document. Concrete destinations ignore this field and keep the
56+
// reflection-driven behavior.
57+
Types []string
4558
}
4659

4760
// BindStringToObjectWithOptions takes a string, and attempts to assign it to the destination
@@ -190,6 +203,22 @@ func BindStringToObjectWithOptions(src string, dst interface{}, opts BindStringT
190203
// We fall through to the error case below if we haven't handled the
191204
// destination type above.
192205
fallthrough
206+
case reflect.Interface:
207+
// An interface destination normally can't be bound: there is no
208+
// type information to parse with, so it falls to the error below.
209+
// The exception is an empty interface (`any`) destination for a
210+
// declared OpenAPI 3.1 multi-type union — opts.Types names the
211+
// member types, and the value binds to the first member that
212+
// parses. See bindStringToUnionMember for the exact semantics.
213+
if t.Kind() == reflect.Interface && t.NumMethod() == 0 && len(opts.Types) > 0 {
214+
bound, bindErr := bindStringToUnionMember(src, opts)
215+
if bindErr != nil {
216+
return fmt.Errorf("error binding string parameter: %w", bindErr)
217+
}
218+
v.Set(reflect.ValueOf(bound))
219+
return nil
220+
}
221+
fallthrough
193222
case reflect.Map:
194223
// A bool-keyed map (such as nullable.Nullable[T], which is
195224
// map[bool]T) is treated as a nullable wrapper: bind src into a

bindunion.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package runtime
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// unionMemberOrder is the order in which union member types are attempted
10+
// when binding a parameter value into an `any` destination: most restrictive
11+
// grammar first, so that the always-succeeding string member cannot shadow
12+
// the others. This is deliberately NOT the schema's declaration order — JSON
13+
// Schema defines the `type` array as an unordered set, so declaration order
14+
// carries no meaning, and any tool that normalizes a spec could otherwise
15+
// silently change binding behavior.
16+
var unionMemberOrder = []string{"boolean", "integer", "number", "string"}
17+
18+
// bindStringToUnionMember binds src against the members of an OpenAPI 3.1
19+
// multi-type union (opts.Types), returning the value of the first member
20+
// that parses. Members are tried in unionMemberOrder, restricted to the
21+
// members actually present in opts.Types.
22+
//
23+
// Numeric detection uses JSON number grammar (RFC 8259), not strconv
24+
// leniency: "007", "+1" and " 1" are not JSON numbers, so they fall through
25+
// to the string member rather than being silently reinterpreted. The result
26+
// is the value the same token would produce inside a JSON document, which is
27+
// the documented mental model for multi-type unions bound into `any`.
28+
//
29+
// opts.Format applies only to the member it is defined for, and only where
30+
// it changes decoding: "int32"/"int64" select the integer width, "float"/
31+
// "double" the floating-point width, and "byte" base64-decodes the string
32+
// member into []byte. Annotation-only formats (date-time, uuid, ...) are
33+
// ignored — per OpenAPI 3.1 semantics `format` is an annotation and must not
34+
// reject a value, so parse failure cannot discriminate members. A format
35+
// whose host type is not present in opts.Types is inert.
36+
//
37+
// Non-scalar member names ("array", "object"), the "null" nullability marker
38+
// and unknown names are skipped: styled serialization of those into `any`
39+
// has no defined meaning. If no member parses, an error naming the union is
40+
// returned.
41+
func bindStringToUnionMember(src string, opts BindStringToObjectOptions) (any, error) {
42+
members := make(map[string]bool, len(opts.Types))
43+
for _, name := range opts.Types {
44+
// The generator is expected to strip the "null" nullability marker
45+
// before emitting Types, but the runtime and generator version
46+
// independently, so don't rely on it — drop "null" here too.
47+
// Nullability is not a union member: there is no null literal in
48+
// styled parameter serialization to bind.
49+
if name == "null" {
50+
continue
51+
}
52+
members[name] = true
53+
}
54+
55+
for _, name := range unionMemberOrder {
56+
if !members[name] {
57+
continue
58+
}
59+
switch name {
60+
case "boolean":
61+
// JSON grammar: exactly the lowercase literals, unlike
62+
// strconv.ParseBool which also accepts "1", "t", "TRUE", etc.
63+
if src == "true" {
64+
return true, nil
65+
}
66+
if src == "false" {
67+
return false, nil
68+
}
69+
case "integer":
70+
if isJSONInteger(src) {
71+
bits := 64
72+
if opts.Format == "int32" {
73+
bits = 32
74+
}
75+
if val, err := strconv.ParseInt(src, 10, bits); err == nil {
76+
if bits == 32 {
77+
return int32(val), nil
78+
}
79+
return val, nil
80+
}
81+
// Overflow: not representable as this member, fall
82+
// through to the next one (number takes it as a float,
83+
// string takes it verbatim).
84+
}
85+
case "number":
86+
if isJSONNumber(src) {
87+
bits := 64
88+
if opts.Format == "float" {
89+
bits = 32
90+
}
91+
if val, err := strconv.ParseFloat(src, bits); err == nil {
92+
if bits == 32 {
93+
return float32(val), nil
94+
}
95+
return val, nil
96+
}
97+
// Out of range for the width: fall through.
98+
}
99+
case "string":
100+
if opts.Format == "byte" {
101+
// Consistent with the concrete []byte destination: a
102+
// declared base64 wire encoding that doesn't decode is an
103+
// error, not a silent fallback to the raw string.
104+
decoded, err := base64Decode(src)
105+
if err != nil {
106+
return nil, fmt.Errorf("error decoding base64 value '%s': %w", src, err)
107+
}
108+
return decoded, nil
109+
}
110+
return src, nil
111+
}
112+
}
113+
114+
return nil, fmt.Errorf("value '%s' does not match any member of type union %v", src, opts.Types)
115+
}
116+
117+
// isJSONNumber reports whether s is a number under JSON grammar (RFC 8259):
118+
// an optional leading '-', an integer part with no leading zeros, and
119+
// optional fraction and exponent parts. No '+' sign, no whitespace, no hex.
120+
func isJSONNumber(s string) bool {
121+
i := 0
122+
if i < len(s) && s[i] == '-' {
123+
i++
124+
}
125+
// Integer part: "0", or a nonzero digit followed by digits.
126+
if i >= len(s) {
127+
return false
128+
}
129+
switch {
130+
case s[i] == '0':
131+
i++
132+
case s[i] >= '1' && s[i] <= '9':
133+
i++
134+
for i < len(s) && isDigit(s[i]) {
135+
i++
136+
}
137+
default:
138+
return false
139+
}
140+
// Fraction part.
141+
if i < len(s) && s[i] == '.' {
142+
i++
143+
if i >= len(s) || !isDigit(s[i]) {
144+
return false
145+
}
146+
for i < len(s) && isDigit(s[i]) {
147+
i++
148+
}
149+
}
150+
// Exponent part.
151+
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
152+
i++
153+
if i < len(s) && (s[i] == '+' || s[i] == '-') {
154+
i++
155+
}
156+
if i >= len(s) || !isDigit(s[i]) {
157+
return false
158+
}
159+
for i < len(s) && isDigit(s[i]) {
160+
i++
161+
}
162+
}
163+
return i == len(s)
164+
}
165+
166+
// isJSONInteger reports whether s is an integer token under JSON grammar: a
167+
// JSON number with no fraction or exponent part. This deliberately rejects
168+
// strconv leniencies like "007" or "+1", which would silently change the
169+
// value ("007" binds as the string "007", not the integer 7).
170+
func isJSONInteger(s string) bool {
171+
return isJSONNumber(s) && !strings.ContainsAny(s, ".eE")
172+
}
173+
174+
func isDigit(c byte) bool {
175+
return c >= '0' && c <= '9'
176+
}

0 commit comments

Comments
 (0)