Skip to content

Commit 17de1dd

Browse files
authored
Percent-encode deepObject parameter wire output (#132)
Closes: #131 MarshalDeepObject emitted raw bytes for values, map keys, and paramName. Previously masked by oapi-codegen v2.6.0's client re-encoding via url.Values.Encode(); v2.7.0 stopped re-encoding (PR #2307), exposing the bug as 400s against RFC3986-compliant servers and silent query-string injection from '&' in values. The fix passes values, each path segment, and the paramName through url.QueryEscape, matching the v2.6.0-effective behavior. Adds TestDeepObject_URLEncoding (51 sub-cases) covering reserved chars, non-ASCII, emoji, control bytes across values, map keys, and param names. Documents the unmarshal-side ambiguity for literal [ ] in keys (inherent to the deepObject format, matches qs/Rails behavior).
1 parent d2b7c4c commit 17de1dd

4 files changed

Lines changed: 432 additions & 19 deletions

File tree

README.md

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,188 @@
33
⚠️ This README may be for the latest development version, which may
44
contain unreleased changes. Please ensure you're looking at the README for the latest release version.
55

6-
This provides any runtime-specific code that the generated code that oapi-codegen generates may need, and therefore is expected to be used with [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen).
6+
This package provides helper functions for marshaling and unmarshalling HTTP
7+
parameters in headers, cookies, and query arguments in various formats, as well
8+
as functions for reading and writing form encoded data representing models.
9+
10+
You won't need to use this package directly, since it's imported be the boilerplate
11+
from `oapi-codegen`, however, you do need to use the correct version needed by
12+
the code generator, since it makes assumptions about runtime behavior.
13+
14+
## Parameter Handling
15+
16+
OpenAPI 3.x parameters are characterized by three orthogonal attributes:
17+
**style**, **location**, and **explode**. The serialized form on the wire is
18+
determined by the combination of all three.
19+
20+
### Styles
21+
22+
Parameters come in the following styles (all defined by the OpenAPI 3.x spec):
23+
24+
- **`simple`** — comma-separated values. The default for path and header
25+
parameters.
26+
- **`label`** — values prefixed with `.`, separated by `.` (explode) or `,`
27+
(no explode). Path parameters only.
28+
- **`matrix`** — values prefixed with `;name=`, repeated (explode) or
29+
comma-separated (no explode). Path parameters only.
30+
- **`form`**`name=value` pairs joined with `&`. The default for query and
31+
cookie parameters.
32+
- **`spaceDelimited`** — array elements joined by literal spaces (no
33+
explode); behaves identically to `form` when exploded. Query parameters,
34+
arrays only.
35+
- **`pipeDelimited`** — array elements joined by literal `|` (no explode);
36+
behaves identically to `form` when exploded. Query parameters, arrays
37+
only.
38+
- **`deepObject`** — nested bracket notation, e.g. `name[field]=value`.
39+
Query parameters, objects only, must be exploded.
40+
41+
### Locations
42+
43+
Each style is only valid in specific parameter locations:
44+
45+
| Location | Allowed styles |
46+
|----------|---------------|
47+
| `path` | `simple`, `label`, `matrix` |
48+
| `query` | `form`, `spaceDelimited`, `pipeDelimited`, `deepObject` |
49+
| `header` | `simple` |
50+
| `cookie` | `form` |
51+
52+
### Explode
53+
54+
Each style can be `explode: true` or `explode: false`, which changes how
55+
multi-value parameters (arrays and objects) are packed.
56+
57+
<table>
58+
<thead>
59+
<tr>
60+
<th>Style</th><th>Type</th><th><code>explode: false</code></th><th><code>explode: true</code></th>
61+
</tr>
62+
</thead>
63+
<tbody>
64+
<tr><td rowspan="3"><code>simple</code></td>
65+
<td>primitive <code>5</code></td><td><code>5</code></td><td><code>5</code></td></tr>
66+
<tr><td>array <code>[3,4,5]</code></td><td><code>3,4,5</code></td><td><code>3,4,5</code></td></tr>
67+
<tr><td>object <code>{role:"admin", firstName:"Alex"}</code></td><td><code>firstName,Alex,role,admin</code></td><td><code>firstName=Alex,role=admin</code></td></tr>
68+
69+
<tr><td rowspan="3"><code>label</code></td>
70+
<td>primitive <code>5</code></td><td><code>.5</code></td><td><code>.5</code></td></tr>
71+
<tr><td>array <code>[3,4,5]</code></td><td><code>.3,4,5</code></td><td><code>.3.4.5</code></td></tr>
72+
<tr><td>object <code>{role:"admin", firstName:"Alex"}</code></td><td><code>.firstName,Alex,role,admin</code></td><td><code>.firstName=Alex.role=admin</code></td></tr>
73+
74+
<tr><td rowspan="3"><code>matrix</code></td>
75+
<td>primitive <code>5</code></td><td><code>;id=5</code></td><td><code>;id=5</code></td></tr>
76+
<tr><td>array <code>[3,4,5]</code></td><td><code>;id=3,4,5</code></td><td><code>;id=3;id=4;id=5</code></td></tr>
77+
<tr><td>object <code>{role:"admin", firstName:"Alex"}</code></td><td><code>;id=firstName,Alex,role,admin</code></td><td><code>;firstName=Alex;role=admin</code></td></tr>
78+
79+
<tr><td rowspan="3"><code>form</code></td>
80+
<td>primitive <code>5</code></td><td><code>id=5</code></td><td><code>id=5</code></td></tr>
81+
<tr><td>array <code>[3,4,5]</code></td><td><code>id=3,4,5</code></td><td><code>id=3&amp;id=4&amp;id=5</code></td></tr>
82+
<tr><td>object <code>{role:"admin", firstName:"Alex"}</code></td><td><code>id=firstName,Alex,role,admin</code></td><td><code>firstName=Alex&amp;role=admin</code></td></tr>
83+
84+
<tr><td rowspan="3"><code>spaceDelimited</code></td>
85+
<td>primitive</td><td colspan="2"><em>not supported</em></td></tr>
86+
<tr><td>array <code>[3,4,5]</code></td><td><code>id=3 4 5</code></td><td><code>id=3&amp;id=4&amp;id=5</code></td></tr>
87+
<tr><td>object</td><td colspan="2"><em>not supported</em></td></tr>
88+
89+
<tr><td rowspan="3"><code>pipeDelimited</code></td>
90+
<td>primitive</td><td colspan="2"><em>not supported</em></td></tr>
91+
<tr><td>array <code>[3,4,5]</code></td><td><code>id=3|4|5</code></td><td><code>id=3&amp;id=4&amp;id=5</code></td></tr>
92+
<tr><td>object</td><td colspan="2"><em>not supported</em></td></tr>
93+
94+
<tr><td rowspan="3"><code>deepObject</code></td>
95+
<td>primitive</td><td colspan="2"><em>not supported</em></td></tr>
96+
<tr><td>array <code>[3,4,5]</code></td><td colspan="2"><code>id[0]=3&amp;id[1]=4&amp;id[2]=5</code> (explode required)</td></tr>
97+
<tr><td>object <code>{role:"admin", firstName:"Alex"}</code></td><td colspan="2"><code>id[firstName]=Alex&amp;id[role]=admin</code> (explode required)</td></tr>
98+
</tbody>
99+
</table>
100+
101+
> The above outputs are shown unescaped for readability. In real use, values
102+
> destined for query parameters are passed through `url.QueryEscape` (or
103+
> `url.PathEscape` for path parameters), so reserved characters and
104+
> non-ASCII bytes are percent-encoded on the wire.
105+
106+
### Parameter Limitations
107+
108+
The OpenAPI 3.x parameter styles are convenient but each has at least one
109+
sharp edge. The list below documents behaviors that surprise users and the
110+
cases where round-tripping is not possible in principle.
111+
112+
#### Encoding
113+
114+
- **Query and path values are percent-encoded.** Reserved characters
115+
(`&`, `=`, `#`, `?`, etc.) and non-ASCII bytes are escaped via
116+
`url.QueryEscape` / `url.PathEscape`. Spaces in query values are encoded
117+
as `+` (form-urlencoded convention), matching `url.Values.Encode()`.
118+
- **Header values are passed through raw.** Per RFC 7230 §3.2.6, header
119+
field values may contain visible ASCII plus space/tab; bytes ≥ `0x80` are
120+
`obs-text` and explicitly marked obsolete in RFC 9110. There is no
121+
generally-agreed mechanism for transporting non-ASCII text in arbitrary
122+
header values (RFC 8187 covers only header *parameters* like
123+
`Content-Disposition` `filename*=`). If your spec puts non-ASCII or
124+
control characters into a header parameter, the wire format is
125+
RFC-noncompliant and proxies may strip or reject the request.
126+
- **Cookie values are passed through raw.** Per RFC 6265 §4.1.1, cookie
127+
values may not contain `CTL`, whitespace, `"`, `,`, `;`, `\`, or any byte
128+
`0x80`. Most cookie libraries URL-encode by convention, but this
129+
package does not — if your spec puts spaces or non-ASCII into a cookie
130+
parameter, the value will not be RFC 6265-conformant.
131+
- **Map keys are percent-encoded for `deepObject` only.** For `simple`,
132+
`label`, `matrix`, and `form` styles, map keys are emitted raw. If your
133+
map keys are non-ASCII or contain URL-reserved characters, the wire
134+
representation will not be encoded.
135+
- **`allowReserved`** (`StyleParamOptions.AllowReserved`) is a query-only
136+
option per the OpenAPI 3.x spec, and only applies to *values*, not
137+
parameter names or map keys.
138+
139+
#### `deepObject`
140+
141+
- **Bracket notation is structural, not data.** `MarshalDeepObject`
142+
percent-encodes literal `[` and `]` inside values and map keys as `%5B`
143+
/ `%5D` on the wire. However, once a server calls `url.ParseQuery`, those
144+
escapes are decoded back to `[` and `]` — at which point a key like
145+
`p[a[b]]` is ambiguous between `{p: {a: {b: ...}}}` and
146+
`{p: {"a[b]": ...}}`. `UnmarshalDeepObject` cannot distinguish these
147+
cases and adopts the same greedy left-to-right parse used by qs (Node),
148+
Rails `Rack::Utils.parse_nested_query`, and similar libraries: every
149+
unescaped `[` opens a new nesting level. **Literal `[` and `]` inside
150+
map keys cannot be round-tripped.** Use a different separator in
151+
user-supplied keys if this matters to you.
152+
- **OpenAPI 3.x defines `deepObject` only for object schemas.** This
153+
package extends it to maps and arrays for convenience (arrays are
154+
numerically indexed: `p[0]`, `p[1]`, …), but other tooling may not
155+
accept that wire form.
156+
- **`deepObject` requires `explode: true`.** Non-exploded `deepObject` has
157+
no well-defined wire format; an error is returned.
158+
159+
#### `spaceDelimited` / `pipeDelimited`
160+
161+
- **Array-only.** Per the OpenAPI spec, these styles only apply to arrays
162+
of primitives. Passing a primitive or object returns an error.
163+
- **Exploded form degenerates to `form`.** When `explode: true`, the
164+
separator becomes `&` (not space or pipe), so the output is
165+
byte-identical to `form` exploded. The space/pipe separator only takes
166+
effect when `explode: false`. This is per the OpenAPI spec, but it
167+
surprises many users.
168+
- **Unexploded `spaceDelimited` is RFC-fragile.** Literal spaces in a
169+
query string are tolerated by most parsers but are not RFC 3986-
170+
conformant; `+` would be the form-urlencoded canonical form for space,
171+
but `spaceDelimited` is defined to use literal `%20`-equivalent space
172+
(the value bytes themselves are then encoded normally).
173+
174+
#### Type-conversion edge cases
175+
176+
- **`null`** in a struct field marshals to the literal string `"null"` in
177+
`deepObject` output. There is no distinct OpenAPI representation for
178+
absent vs. JSON-null in query parameters.
179+
- **`time.Time` and `types.Date`** are formatted via RFC 3339 and
180+
`2006-01-02` respectively when used as primitives in any style. If you
181+
want a different format, wrap the field in a type that implements
182+
`encoding.TextMarshaler`.
183+
- **`types.UUID`** stringifies to the canonical hyphenated 36-character
184+
form; query/path location escaping is a no-op (UUIDs are in the
185+
unreserved set).
186+
- **`json.Marshaler` is consulted for structs**, then the result is
187+
re-decoded with `UseNumber()` and re-styled. Numbers therefore retain
188+
their original precision, but the round-trip through JSON means struct
189+
field tags are honored (not raw Go field names).
190+

deepobject.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,24 @@ func marshalDeepObject(in interface{}, path []string) ([]string, error) {
5454
default:
5555
// Now, for a concrete value, we will turn the path elements
5656
// into a deepObject style set of subscripts. [a, b, c] turns into
57-
// [a][b][c]
58-
prefix := "[" + strings.Join(path, "][") + "]"
57+
// [a][b][c]. Path segments may originate from user-controlled map
58+
// keys, so each segment is percent-encoded; the literal '[' and ']'
59+
// remain as structural delimiters.
60+
encoded := make([]string, len(path))
61+
for i, p := range path {
62+
encoded[i] = url.QueryEscape(p)
63+
}
64+
prefix := "[" + strings.Join(encoded, "][") + "]"
5965

6066
var value string
6167
if t == nil {
6268
value = "null"
6369
} else {
64-
value = fmt.Sprintf("%v", t)
70+
value = url.QueryEscape(fmt.Sprintf("%v", t))
6571
}
6672

6773
result = []string{
68-
prefix + fmt.Sprintf("=%s", value),
74+
prefix + "=" + value,
6975
}
7076
}
7177
return result, nil
@@ -93,9 +99,12 @@ func MarshalDeepObject(i interface{}, paramName string) (string, error) {
9399
return "", fmt.Errorf("error traversing JSON structure: %w", err)
94100
}
95101

96-
// Prefix the param name to each subscripted field.
102+
// Prefix the param name to each subscripted field. The param name is
103+
// percent-encoded to keep the wire output ASCII-clean even if the spec
104+
// declares a non-identifier parameter name.
105+
encodedParamName := url.QueryEscape(paramName)
97106
for i := range fields {
98-
fields[i] = paramName + fields[i]
107+
fields[i] = encodedParamName + fields[i]
99108
}
100109
return strings.Join(fields, "&"), nil
101110
}
@@ -135,6 +144,24 @@ func makeFieldOrValue(paths [][]string, values []string) fieldOrValue {
135144
return f
136145
}
137146

147+
// UnmarshalDeepObject decodes deepObject-style query parameters (e.g.
148+
// `filter[name]=alice&filter[role]=admin`) into dst, using paramName as the
149+
// outer prefix.
150+
//
151+
// Encoding: MarshalDeepObject percent-encodes values, map keys, and the
152+
// param name itself, so any byte sequence — including non-ASCII text and URL
153+
// reserved characters in values or in map keys — round-trips correctly. The
154+
// '[' and ']' characters that appear at the top level of each fragment are
155+
// always structural; literal brackets inside data are encoded as %5B/%5D on
156+
// the wire.
157+
//
158+
// Known limitation: literal '[' or ']' inside map keys cannot be round-
159+
// tripped. After url.ParseQuery decodes %5B/%5D back to '['/']', the parser
160+
// cannot distinguish a structural bracket from a literal bracket that was
161+
// part of a key (e.g. `p[a[b]]` is ambiguous between `{p: {a: {b: ...}}}`
162+
// and `{p: {"a[b]": ...}}`). This matches the behavior of every other
163+
// deepObject implementation (qs, Rails, PHP); the deepObject style itself
164+
// does not define an escape mechanism for brackets inside keys.
138165
func UnmarshalDeepObject(dst interface{}, paramName string, params url.Values) error {
139166
return unmarshalDeepObject(dst, paramName, params, false)
140167
}

0 commit comments

Comments
 (0)