Skip to content

Commit 09a43f0

Browse files
mromaszewiczclaude
andauthored
feat: improve parameter handling (#109)
* feat: improve parameter handling Add AllowReserved support for OpenAPI parameter serialization Add the AllowReserved field to StyleParamOptions, BindStyledParameterOptions, and BindQueryParameterOptions. When set to true for query parameters, RFC 3986 reserved characters are left unencoded in the serialized output, per the OpenAPI 3.x allowReserved specification. Changes: - Add AllowReserved bool to all parameter option structs - Implement escapeQueryAllowReserved for custom escaping that preserves reserved characters while still encoding spaces and control characters - Thread allowReserved through internal helpers (stylePrimitive, styleSlice, styleStruct, styleMap, processFieldDict) - Fix styleStruct recursive call to use StyleParamWithOptions instead of StyleParamWithLocation, which was silently dropping options Partially resolves oapi-codegen/oapi-codegen#1342 Prerequisite for oapi-codegen/oapi-codegen#2183 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: encode parameter names in styled output for RFC 3986 compliance Add escapeParameterName helper that percent-encodes parameter names in the prefix/separator construction of styleSlice, stylePrimitive, and processFieldDict. This ensures characters like [] in parameter names (e.g. user_ids[]) are properly encoded as %5B%5D in query strings, per RFC 3986. Note: MarshalDeepObject handles its own serialization independently and does not yet encode parameter names in bracket notation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 99b941f commit 09a43f0

3 files changed

Lines changed: 232 additions & 32 deletions

File tree

bindparam.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ type BindStyledParameterOptions struct {
6969
// When set to "byte" and the destination is []byte, the value is
7070
// base64-decoded rather than treated as a generic slice.
7171
Format string
72+
// AllowReserved, when true, indicates that the parameter value may
73+
// contain RFC 3986 reserved characters without percent-encoding.
74+
AllowReserved bool
7275
}
7376

7477
// BindStyledParameterWithOptions binds a parameter as described in the Path Parameters
@@ -346,6 +349,9 @@ type BindQueryParameterOptions struct {
346349
// When set to "byte" and the destination is []byte, the value is
347350
// base64-decoded rather than treated as a generic slice.
348351
Format string
352+
// AllowReserved, when true, indicates that the parameter value may
353+
// contain RFC 3986 reserved characters without percent-encoding.
354+
AllowReserved bool
349355
}
350356

351357
// BindQueryParameterWithOptions works like BindQueryParameter with additional options.

styleparam.go

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ type StyleParamOptions struct {
7272
Format string
7373
// Required indicates whether the parameter is required.
7474
Required bool
75+
// AllowReserved, when true, prevents percent-encoding of RFC 3986
76+
// reserved characters in query parameter values. Per the OpenAPI 3.x
77+
// spec, this only applies to query parameters.
78+
AllowReserved bool
7579
}
7680

7781
// StyleParamWithOptions serializes a Go value into an OpenAPI-styled parameter
@@ -105,32 +109,32 @@ func StyleParamWithOptions(style string, explode bool, paramName string, value i
105109
return "", fmt.Errorf("error marshaling '%s' as text: %w", value, err)
106110
}
107111

108-
return stylePrimitive(style, explode, paramName, opts.ParamLocation, string(b))
112+
return stylePrimitive(style, explode, paramName, opts.ParamLocation, opts.AllowReserved, string(b))
109113
}
110114
}
111115

112116
switch t.Kind() {
113117
case reflect.Slice:
114118
if opts.Format == "byte" && isByteSlice(t) {
115119
encoded := base64.StdEncoding.EncodeToString(v.Bytes())
116-
return stylePrimitive(style, explode, paramName, opts.ParamLocation, encoded)
120+
return stylePrimitive(style, explode, paramName, opts.ParamLocation, opts.AllowReserved, encoded)
117121
}
118122
n := v.Len()
119123
sliceVal := make([]interface{}, n)
120124
for i := 0; i < n; i++ {
121125
sliceVal[i] = v.Index(i).Interface()
122126
}
123-
return styleSlice(style, explode, paramName, opts.ParamLocation, sliceVal)
127+
return styleSlice(style, explode, paramName, opts.ParamLocation, opts.AllowReserved, sliceVal)
124128
case reflect.Struct:
125-
return styleStruct(style, explode, paramName, opts.ParamLocation, value)
129+
return styleStruct(style, explode, paramName, opts.ParamLocation, opts.AllowReserved, value)
126130
case reflect.Map:
127-
return styleMap(style, explode, paramName, opts.ParamLocation, value)
131+
return styleMap(style, explode, paramName, opts.ParamLocation, opts.AllowReserved, value)
128132
default:
129-
return stylePrimitive(style, explode, paramName, opts.ParamLocation, value)
133+
return stylePrimitive(style, explode, paramName, opts.ParamLocation, opts.AllowReserved, value)
130134
}
131135
}
132136

133-
func styleSlice(style string, explode bool, paramName string, paramLocation ParamLocation, values []interface{}) (string, error) {
137+
func styleSlice(style string, explode bool, paramName string, paramLocation ParamLocation, allowReserved bool, values []interface{}) (string, error) {
134138
if style == "deepObject" {
135139
if !explode {
136140
return "", errors.New("deepObjects must be exploded")
@@ -141,6 +145,8 @@ func styleSlice(style string, explode bool, paramName string, paramLocation Para
141145
var prefix string
142146
var separator string
143147

148+
escapedName := escapeParameterName(paramName, paramLocation)
149+
144150
switch style {
145151
case "simple":
146152
separator = ","
@@ -152,28 +158,28 @@ func styleSlice(style string, explode bool, paramName string, paramLocation Para
152158
separator = ","
153159
}
154160
case "matrix":
155-
prefix = fmt.Sprintf(";%s=", paramName)
161+
prefix = fmt.Sprintf(";%s=", escapedName)
156162
if explode {
157163
separator = prefix
158164
} else {
159165
separator = ","
160166
}
161167
case "form":
162-
prefix = fmt.Sprintf("%s=", paramName)
168+
prefix = fmt.Sprintf("%s=", escapedName)
163169
if explode {
164170
separator = "&" + prefix
165171
} else {
166172
separator = ","
167173
}
168174
case "spaceDelimited":
169-
prefix = fmt.Sprintf("%s=", paramName)
175+
prefix = fmt.Sprintf("%s=", escapedName)
170176
if explode {
171177
separator = "&" + prefix
172178
} else {
173179
separator = " "
174180
}
175181
case "pipeDelimited":
176-
prefix = fmt.Sprintf("%s=", paramName)
182+
prefix = fmt.Sprintf("%s=", escapedName)
177183
if explode {
178184
separator = "&" + prefix
179185
} else {
@@ -189,7 +195,7 @@ func styleSlice(style string, explode bool, paramName string, paramLocation Para
189195
parts := make([]string, len(values))
190196
for i, v := range values {
191197
part, err = primitiveToString(v)
192-
part = escapeParameterString(part, paramLocation)
198+
part = escapeParameterString(part, paramLocation, allowReserved)
193199
parts[i] = part
194200
if err != nil {
195201
return "", fmt.Errorf("error formatting '%s': %w", paramName, err)
@@ -236,9 +242,9 @@ func marshalKnownTypes(value interface{}) (string, bool) {
236242
return "", false
237243
}
238244

239-
func styleStruct(style string, explode bool, paramName string, paramLocation ParamLocation, value interface{}) (string, error) {
245+
func styleStruct(style string, explode bool, paramName string, paramLocation ParamLocation, allowReserved bool, value interface{}) (string, error) {
240246
if timeVal, ok := marshalKnownTypes(value); ok {
241-
styledVal, err := stylePrimitive(style, explode, paramName, paramLocation, timeVal)
247+
styledVal, err := stylePrimitive(style, explode, paramName, paramLocation, allowReserved, timeVal)
242248
if err != nil {
243249
return "", fmt.Errorf("failed to style time: %w", err)
244250
}
@@ -266,7 +272,10 @@ func styleStruct(style string, explode bool, paramName string, paramLocation Par
266272
if err != nil {
267273
return "", fmt.Errorf("failed to unmarshal JSON: %w", err)
268274
}
269-
s, err := StyleParamWithLocation(style, explode, paramName, paramLocation, i2)
275+
s, err := StyleParamWithOptions(style, explode, paramName, i2, StyleParamOptions{
276+
ParamLocation: paramLocation,
277+
AllowReserved: allowReserved,
278+
})
270279
if err != nil {
271280
return "", fmt.Errorf("error style JSON structure: %w", err)
272281
}
@@ -305,10 +314,10 @@ func styleStruct(style string, explode bool, paramName string, paramLocation Par
305314
fieldDict[fieldName] = str
306315
}
307316

308-
return processFieldDict(style, explode, paramName, paramLocation, fieldDict)
317+
return processFieldDict(style, explode, paramName, paramLocation, allowReserved, fieldDict)
309318
}
310319

311-
func styleMap(style string, explode bool, paramName string, paramLocation ParamLocation, value interface{}) (string, error) {
320+
func styleMap(style string, explode bool, paramName string, paramLocation ParamLocation, allowReserved bool, value interface{}) (string, error) {
312321
if style == "deepObject" {
313322
if !explode {
314323
return "", errors.New("deepObjects must be exploded")
@@ -325,29 +334,31 @@ func styleMap(style string, explode bool, paramName string, paramLocation ParamL
325334
}
326335
fieldDict[fieldName.String()] = str
327336
}
328-
return processFieldDict(style, explode, paramName, paramLocation, fieldDict)
337+
return processFieldDict(style, explode, paramName, paramLocation, allowReserved, fieldDict)
329338
}
330339

331-
func processFieldDict(style string, explode bool, paramName string, paramLocation ParamLocation, fieldDict map[string]string) (string, error) {
340+
func processFieldDict(style string, explode bool, paramName string, paramLocation ParamLocation, allowReserved bool, fieldDict map[string]string) (string, error) {
332341
var parts []string
333342

334343
// This works for everything except deepObject. We'll handle that one
335344
// separately.
336345
if style != "deepObject" {
337346
if explode {
338347
for _, k := range sortedKeys(fieldDict) {
339-
v := escapeParameterString(fieldDict[k], paramLocation)
348+
v := escapeParameterString(fieldDict[k], paramLocation, allowReserved)
340349
parts = append(parts, k+"="+v)
341350
}
342351
} else {
343352
for _, k := range sortedKeys(fieldDict) {
344-
v := escapeParameterString(fieldDict[k], paramLocation)
353+
v := escapeParameterString(fieldDict[k], paramLocation, allowReserved)
345354
parts = append(parts, k)
346355
parts = append(parts, v)
347356
}
348357
}
349358
}
350359

360+
escapedName := escapeParameterName(paramName, paramLocation)
361+
351362
var prefix string
352363
var separator string
353364

@@ -367,13 +378,13 @@ func processFieldDict(style string, explode bool, paramName string, paramLocatio
367378
prefix = ";"
368379
} else {
369380
separator = ","
370-
prefix = fmt.Sprintf(";%s=", paramName)
381+
prefix = fmt.Sprintf(";%s=", escapedName)
371382
}
372383
case "form":
373384
if explode {
374385
separator = "&"
375386
} else {
376-
prefix = fmt.Sprintf("%s=", paramName)
387+
prefix = fmt.Sprintf("%s=", escapedName)
377388
separator = ","
378389
}
379390
case "deepObject":
@@ -383,7 +394,7 @@ func processFieldDict(style string, explode bool, paramName string, paramLocatio
383394
}
384395
for _, k := range sortedKeys(fieldDict) {
385396
v := fieldDict[k]
386-
part := fmt.Sprintf("%s[%s]=%s", paramName, k, v)
397+
part := fmt.Sprintf("%s[%s]=%s", escapedName, k, v)
387398
parts = append(parts, part)
388399
}
389400
separator = "&"
@@ -395,25 +406,27 @@ func processFieldDict(style string, explode bool, paramName string, paramLocatio
395406
return prefix + strings.Join(parts, separator), nil
396407
}
397408

398-
func stylePrimitive(style string, explode bool, paramName string, paramLocation ParamLocation, value interface{}) (string, error) {
409+
func stylePrimitive(style string, explode bool, paramName string, paramLocation ParamLocation, allowReserved bool, value interface{}) (string, error) {
399410
strVal, err := primitiveToString(value)
400411
if err != nil {
401412
return "", err
402413
}
403414

415+
escapedName := escapeParameterName(paramName, paramLocation)
416+
404417
var prefix string
405418
switch style {
406419
case "simple":
407420
case "label":
408421
prefix = "."
409422
case "matrix":
410-
prefix = fmt.Sprintf(";%s=", paramName)
423+
prefix = fmt.Sprintf(";%s=", escapedName)
411424
case "form":
412-
prefix = fmt.Sprintf("%s=", paramName)
425+
prefix = fmt.Sprintf("%s=", escapedName)
413426
default:
414427
return "", fmt.Errorf("unsupported style '%s'", style)
415428
}
416-
return prefix + escapeParameterString(strVal, paramLocation), nil
429+
return prefix + escapeParameterString(strVal, paramLocation, allowReserved), nil
417430
}
418431

419432
// Converts a primitive value to a string. We need to do this based on the
@@ -486,16 +499,60 @@ func primitiveToString(value interface{}) (string, error) {
486499
return output, nil
487500
}
488501

489-
// escapeParameterString escapes a parameter value bas on the location of that parameter.
490-
// Query params and path params need different kinds of escaping, while header
491-
// and cookie params seem not to need escaping.
492-
func escapeParameterString(value string, paramLocation ParamLocation) string {
502+
// escapeParameterName escapes a parameter name for use in query strings and
503+
// paths. This ensures characters like [] in parameter names (e.g. user_ids[])
504+
// are properly percent-encoded per RFC 3986.
505+
func escapeParameterName(name string, paramLocation ParamLocation) string {
506+
// Parameter names should always be encoded regardless of allowReserved,
507+
// which only applies to values per the OpenAPI spec.
508+
return escapeParameterString(name, paramLocation, false)
509+
}
510+
511+
// escapeParameterString escapes a parameter value based on the location of
512+
// that parameter. Query params and path params need different kinds of
513+
// escaping, while header and cookie params seem not to need escaping.
514+
// When allowReserved is true and the location is query, RFC 3986 reserved
515+
// characters are left unencoded per the OpenAPI allowReserved specification.
516+
func escapeParameterString(value string, paramLocation ParamLocation, allowReserved bool) string {
493517
switch paramLocation {
494518
case ParamLocationQuery:
519+
if allowReserved {
520+
return escapeQueryAllowReserved(value)
521+
}
495522
return url.QueryEscape(value)
496523
case ParamLocationPath:
497524
return url.PathEscape(value)
498525
default:
499526
return value
500527
}
501528
}
529+
530+
// escapeQueryAllowReserved percent-encodes a query parameter value while
531+
// leaving RFC 3986 reserved characters (:/?#[]@!$&'()*+,;=) unencoded, as
532+
// specified by OpenAPI's allowReserved parameter option. Only characters that
533+
// are neither unreserved nor reserved are encoded (e.g., spaces, control
534+
// characters, non-ASCII).
535+
func escapeQueryAllowReserved(value string) string {
536+
// RFC 3986 reserved characters that should NOT be encoded when
537+
// allowReserved is true.
538+
const reserved = `:/?#[]@!$&'()*+,;=`
539+
540+
var buf strings.Builder
541+
for _, b := range []byte(value) {
542+
if isUnreserved(b) || strings.IndexByte(reserved, b) >= 0 {
543+
buf.WriteByte(b)
544+
} else {
545+
fmt.Fprintf(&buf, "%%%02X", b)
546+
}
547+
}
548+
return buf.String()
549+
}
550+
551+
// isUnreserved reports whether the byte is an RFC 3986 unreserved character:
552+
// ALPHA / DIGIT / "-" / "." / "_" / "~"
553+
func isUnreserved(c byte) bool {
554+
return (c >= 'A' && c <= 'Z') ||
555+
(c >= 'a' && c <= 'z') ||
556+
(c >= '0' && c <= '9') ||
557+
c == '-' || c == '.' || c == '_' || c == '~'
558+
}

0 commit comments

Comments
 (0)