@@ -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