FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Bind OpenAPI 3.1 multi-type union parameters into any destinations (#… · oapi-codegen/runtime@f2e468c · GitHub

Commit f2e468c

Browse files
andauthored
Bind OpenAPI 3.1 multi-type union parameters into any destinations (#154)
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 <noreply@anthropic.com>
1 parent 611503e commit f2e468c

4 files changed

Lines changed: 791 additions & 3 deletions

File tree

‎bindparam.go‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ 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"]). It is only consulted when the
85+
// destination is an empty interface (`any`): the value binds to the
86+
// first member that parses, trying boolean, integer, number, then
87+
// string, with numeric detection following the JSON number production.
88+
// The bound value's dynamic type is one of exactly bool, int64, float64,
89+
// string, or, with Format "byte", []byte (widths narrow only under the
90+
// NarrowUnionNumericFormats package variable). Concrete destinations ignore
91+
// it and keep the reflection-driven behavior; arrays of unions and
92+
// deepObject-style binding are not covered. See
93+
// BindStringToObjectOptions.Types for the full semantics and scope.
94+
Types []string
8395
// AllowReserved, when true, indicates that the parameter value may
8496
// contain RFC 3986 reserved characters without percent-encoding.
8597
AllowReserved bool
@@ -193,7 +205,11 @@ func BindStyledParameterWithOptions(style string, paramName string, value string
193205
}
194206
value = parts[0]
195207
}
196-
return BindStringToObject(value, dest)
208+
return BindStringToObjectWithOptions(value, dest, BindStringToObjectOptions{
209+
Type: opts.Type,
210+
Format: opts.Format,
211+
Types: opts.Types,
212+
})
197213
}
198214

199215
// This is a complex set of operations, but each given parameter style can be
@@ -386,6 +402,18 @@ type BindQueryParameterOptions struct {
386402
// When set to "byte" and the destination is []byte, the value is
387403
// base64-decoded rather than treated as a generic slice.
388404
Format string
405+
// Types is the OpenAPI 3.1 multi-type union member list of the parameter
406+
// (e.g. ["string", "integer"]). It is only consulted when the
407+
// destination is an empty interface (`any`): the value binds to the
408+
// first member that parses, trying boolean, integer, number, then
409+
// string, with numeric detection following the JSON number production.
410+
// The bound value's dynamic type is one of exactly bool, int64, float64,
411+
// string, or, with Format "byte", []byte (widths narrow only under the
412+
// NarrowUnionNumericFormats package variable). Concrete destinations ignore
413+
// it and keep the reflection-driven behavior; arrays of unions and
414+
// deepObject-style binding are not covered. See
415+
// BindStringToObjectOptions.Types for the full semantics and scope.
416+
Types []string
389417
// AllowReserved, when true, indicates that the parameter value may
390418
// contain RFC 3986 reserved characters without percent-encoding.
391419
AllowReserved bool
@@ -520,7 +548,11 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
520548
return nil
521549
}
522550
}
523-
err = BindStringToObject(values[0], output)
551+
err = BindStringToObjectWithOptions(values[0], output, BindStringToObjectOptions{
552+
Type: opts.Type,
553+
Format: opts.Format,
554+
Types: opts.Types,
555+
})
524556
}
525557
if err != nil {
526558
return err
@@ -552,7 +584,11 @@ func BindQueryParameterWithOptions(style string, explode bool, required bool, pa
552584
// is only meaningful for array and object types.
553585
// See: https://swagger.io/docs/specification/serialization/
554586
if k != reflect.Slice && k != reflect.Struct && k != reflect.Map {
555-
err := BindStringToObject(values[0], output)
587+
err := BindStringToObjectWithOptions(values[0], output, BindStringToObjectOptions{
588+
Type: opts.Type,
589+
Format: opts.Format,
590+
Types: opts.Types,
591+
})
556592
if err != nil {
557593
return err
558594
}

‎bindstring.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,28 @@ 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. (Type, which the runtime does not currently read,
49+
// carries no meaning when Types is set.)
50+
//
51+
// Types is only consulted when the destination is an empty interface
52+
// (`any`): the source string is bound to the first member that parses,
53+
// trying boolean, integer, number, then string (most restrictive grammar
54+
// first — the always-succeeding string member would otherwise shadow the
55+
// rest). Numeric detection uses the JSON number production (RFC 8259
56+
// section 6), so tokens like "007" and "+1" bind as strings. The bound
57+
// value's dynamic type is one of exactly bool, int64, float64, string,
58+
// or, with Format "byte", []byte; width formats (int32, float, ...) are
59+
// annotation-only unless the application opts into narrowing via the
60+
// NarrowUnionNumericFormats package variable.
61+
//
62+
// Concrete destinations ignore this field and keep the reflection-driven
63+
// behavior. Array element binding does not yet support unions, and
64+
// deepObject-style binding does not consult this field (its JSON decode
65+
// path produces float64 for all numbers).
66+
Types []string
4567
}
4668

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

‎bindunion.go‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package runtime
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// NarrowUnionNumericFormats controls whether numeric width formats narrow
10+
// the dynamic type produced when a multi-type union parameter is bound into
11+
// an `any` destination.
12+
//
13+
// When false (the default), the bound value's dynamic type is always one of
14+
// bool, int64, float64, string, or []byte, regardless of the schema's
15+
// `format`: an edit to a spec's format can never change the types a running
16+
// handler's type switch sees. When true, `format: int32` produces int32
17+
// (values outside int32 range fall through to the next union member) and
18+
// `format: float` produces float32, widening the possible dynamic types to
19+
// bool, int32, int64, float32, float64, string, and []byte. `int64` and
20+
// `double` name the defaults either way. Formats never affect concrete
21+
// (non-`any`) destinations, whose Go type was fixed at generation time.
22+
//
23+
// Note one asymmetry with concrete destinations: a concrete int32
24+
// destination rejects an out-of-range value with an overflow error, but an
25+
// `any` destination binds it to the next union member instead — typically
26+
// the string member, verbatim. Enabling narrowing to get int32 typing
27+
// therefore also accepts that silent widening; a handler that needs
28+
// out-of-range values rejected must check for the string case itself.
29+
//
30+
// Like DefaultQueryEncoder, set it once during program initialization; it is
31+
// not safe to mutate concurrently with in-flight requests. The opt-in lives
32+
// here rather than in generated code because the trade-off belongs to
33+
// whoever owns the handler's type switch: enabling it is a promise that the
34+
// application handles the narrowed types.
35+
var NarrowUnionNumericFormats bool
36+
37+
// unionMemberOrder is the order in which union member types are attempted
38+
// when binding a parameter value into an `any` destination: most restrictive
39+
// grammar first, so that the always-succeeding string member cannot shadow
40+
// the others. This is deliberately NOT the schema's declaration order — JSON
41+
// Schema defines the `type` array as an unordered set, so declaration order
42+
// carries no meaning, and any tool that normalizes a spec could otherwise
43+
// silently change binding behavior. The "null" nullability marker and
44+
// non-scalar names ("array", "object") never appear here, so they are
45+
// structurally skipped during the walk regardless of what the generator
46+
// emitted in Types.
47+
var unionMemberOrder = [4]string{"boolean", "integer", "number", "string"}
48+
49+
// bindStringToUnionMember binds src against the members of an OpenAPI 3.1
50+
// multi-type union (opts.Types), returning the value of the first member
51+
// that parses. Members are tried in unionMemberOrder, restricted to the
52+
// members actually present in opts.Types.
53+
//
54+
// Numeric detection uses the JSON number production (RFC 8259 section 6),
55+
// not strconv leniency: "007", "+1" and " 1" are not JSON numbers, so they
56+
// fall through to the string member rather than being silently
57+
// reinterpreted.
58+
//
59+
// The dynamic type of the returned value is one of exactly bool, int64,
60+
// float64, string, or — with Format "byte" — []byte. Width formats (int32,
61+
// int64, float, double) are annotation-only by default and do not narrow
62+
// the produced type: honoring them would mean an edit to a spec's `format`
63+
// silently changes the dynamic type a running handler's type switch sees,
64+
// with no compile error. Applications that want width narrowing opt in via
65+
// the NarrowUnionNumericFormats package variable. "byte" is always
66+
// load-bearing because it changes the wire decoding (base64) rather than a
67+
// width; other annotation-only formats (date-time, uuid, ...) are ignored —
68+
// per OpenAPI 3.1 semantics `format` is an annotation and must not reject a
69+
// value, so parse failure cannot discriminate members. A format whose host
70+
// type is not present in opts.Types is inert.
71+
//
72+
// Non-scalar member names ("array", "object"), the "null" nullability marker
73+
// and unknown names are skipped: styled serialization of those into `any`
74+
// has no defined meaning. If no member parses, an error naming the union's
75+
// bindable members is returned.
76+
func bindStringToUnionMember(src string, opts BindStringToObjectOptions) (any, error) {
77+
for _, name := range unionMemberOrder {
78+
if !unionHasMember(opts.Types, name) {
79+
continue
80+
}
81+
switch name {
82+
case "boolean":
83+
// JSON grammar: exactly the lowercase literals, unlike
84+
// strconv.ParseBool which also accepts "1", "t", "TRUE", etc.
85+
if src == "true" {
86+
return true, nil
87+
}
88+
if src == "false" {
89+
return false, nil
90+
}
91+
case "integer":
92+
if isJSONInteger(src) {
93+
if NarrowUnionNumericFormats && opts.Format == "int32" {
94+
if val, err := strconv.ParseInt(src, 10, 32); err == nil {
95+
return int32(val), nil
96+
}
97+
} else if val, err := strconv.ParseInt(src, 10, 64); err == nil {
98+
return val, nil
99+
}
100+
// Overflow of the (possibly narrowed) width: not
101+
// representable as this member, fall through to the next
102+
// one (number takes it as a float, string takes it
103+
// verbatim).
104+
}
105+
case "number":
106+
if isJSONNumber(src) {
107+
if NarrowUnionNumericFormats && opts.Format == "float" {
108+
if val, err := strconv.ParseFloat(src, 32); err == nil {
109+
return float32(val), nil
110+
}
111+
} else if val, err := strconv.ParseFloat(src, 64); err == nil {
112+
return val, nil
113+
}
114+
// Out of range for the (possibly narrowed) width: fall
115+
// through.
116+
}
117+
case "string":
118+
if opts.Format == "byte" {
119+
// Consistent with the concrete []byte destination: a
120+
// declared base64 wire encoding that doesn't decode is an
121+
// error, not a silent fallback to the raw string.
122+
// base64Decode's error already names the offending value.
123+
return base64Decode(src)
124+
}
125+
return src, nil
126+
}
127+
}
128+
129+
// Name only the bindable members in the error: the generator is expected
130+
// to strip the "null" nullability marker before emitting Types, but the
131+
// runtime and generator version independently, so don't rely on it.
132+
members := make([]string, 0, len(opts.Types))
133+
for _, name := range opts.Types {
134+
if name != "null" {
135+
members = append(members, name)
136+
}
137+
}
138+
if len(members) == 0 {
139+
// Degenerate input (e.g. Types: ["null"]): say so instead of
140+
// printing "type union []", which would read as a runtime bug.
141+
return nil, fmt.Errorf("value '%s' can not bind: type union has no bindable members (declared %v)", src, opts.Types)
142+
}
143+
return nil, fmt.Errorf("value '%s' does not match any member of type union %v", src, members)
144+
}
145+
146+
// unionHasMember reports whether name appears in types. A linear scan: the
147+
// list has at most a handful of entries and this runs per parameter per
148+
// request, so avoiding a map allocation matters more than big-O.
149+
func unionHasMember(types []string, name string) bool {
150+
for _, t := range types {
151+
if t == name {
152+
return true
153+
}
154+
}
155+
return false
156+
}
157+
158+
// isJSONNumber reports whether s is a number under JSON grammar (RFC 8259):
159+
// an optional leading '-', an integer part with no leading zeros, and
160+
// optional fraction and exponent parts. No '+' sign, no whitespace, no hex.
161+
func isJSONNumber(s string) bool {
162+
i := 0
163+
if i < len(s) && s[i] == '-' {
164+
i++
165+
}
166+
// Integer part: "0", or a nonzero digit followed by digits.
167+
if i >= len(s) {
168+
return false
169+
}
170+
switch {
171+
case s[i] == '0':
172+
i++
173+
case s[i] >= '1' && s[i] <= '9':
174+
i++
175+
for i < len(s) && isDigit(s[i]) {
176+
i++
177+
}
178+
default:
179+
return false
180+
}
181+
// Fraction part.
182+
if i < len(s) && s[i] == '.' {
183+
i++
184+
if i >= len(s) || !isDigit(s[i]) {
185+
return false
186+
}
187+
for i < len(s) && isDigit(s[i]) {
188+
i++
189+
}
190+
}
191+
// Exponent part.
192+
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
193+
i++
194+
if i < len(s) && (s[i] == '+' || s[i] == '-') {
195+
i++
196+
}
197+
if i >= len(s) || !isDigit(s[i]) {
198+
return false
199+
}
200+
for i < len(s) && isDigit(s[i]) {
201+
i++
202+
}
203+
}
204+
return i == len(s)
205+
}
206+
207+
// isJSONInteger reports whether s is an integer token under JSON grammar: a
208+
// JSON number with no fraction or exponent part. This deliberately rejects
209+
// strconv leniencies like "007" or "+1", which would silently change the
210+
// value ("007" binds as the string "007", not the integer 7).
211+
func isJSONInteger(s string) bool {
212+
return isJSONNumber(s) && !strings.ContainsAny(s, ".eE")
213+
}
214+
215+
func isDigit(c byte) bool {
216+
return c >= '0' && c <= '9'
217+
}

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL