From 334a9ead6ea59d2036adf3ff84a92b34a5e1042d Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Tue, 14 Jul 2026 21:20:49 -0700 Subject: [PATCH] Allow customizing default query encoder Addresses https://github.com/oapi-codegen/oapi-codegen/issues/2051 Go's net/url encodes a space in query strings as '+' (the form-urlencoded convention), which is not RFC 3986 compliant. Some servers (e.g. OData endpoints expecting ?filter=name%20eq%20'x') reject '+'-encoded spaces with 400 Bad Request, while others require '+'. Since neither encoding is universally correct, this makes the behavior configurable rather than changing the default. Introduce a QueryEncoder interface (a single EscapeQueryValue method) with two built-in implementations in a new encoder.go: NetURLQueryEncoder (the default, '+' for space, preserving current behavior) and RFC3986QueryEncoder ('%20' for space). A package-level DefaultQueryEncoder (mirroring http.DefaultClient) lets callers opt in during program initialization. Names and map keys are escaped as values with allowReserved=false, which allowReserved never applies to. All query escaping in styleparam.go and deepobject.go now routes through DefaultQueryEncoder, so both styled and deepObject query parameters honor the choice. Path escaping and x-www-form-urlencoded request bodies are left unchanged, where '+' for a space is the correct, media-type-defined behavior. Documented under the README "Encoding" section; tests in encoder_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 35 +++++++++++++- deepobject.go | 6 +-- encoder.go | 77 ++++++++++++++++++++++++++++++ encoder_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++++ styleparam.go | 5 +- 5 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 encoder.go create mode 100644 encoder_test.go diff --git a/README.md b/README.md index 2baa02d..82b8649 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,43 @@ cases where round-tripping is not possible in principle. #### Encoding +##### Configuring query encoding + +By default, spaces in query strings are encoded as `+`, following Go's +`net/url` (the form-urlencoded convention used by browsers). This is accepted +by most servers, but it is *not* RFC 3986 compliant — some servers (for +example, OData endpoints expecting `?filter=name%20eq%20'x'`) reject `+` for a +space with `400 Bad Request`. + +Generated clients route all query escaping through the package-level +`DefaultQueryEncoder`, so you can control this behavior. Two encoders ship out +of the box: + +- `NetURLQueryEncoder` — the default; encodes a space as `+`. +- `RFC3986QueryEncoder` — encodes a space as `%20` for strict RFC 3986 + compliance. + +To opt into RFC 3986 encoding, set the default once during program +initialization (it is not safe to mutate concurrently with in-flight requests, +the same contract as `http.DefaultClient`): + +```go +func init() { + runtime.DefaultQueryEncoder = runtime.RFC3986QueryEncoder{} +} +``` + +You may also supply your own `QueryEncoder` implementation (a single +`EscapeQueryValue` method). The encoder governs query parameter values, names, +and map keys (including `deepObject`); it does **not** affect path escaping or +`application/x-www-form-urlencoded` request bodies, where `+` for a space is the +correct, media-type-defined behavior. + - **Query and path values are percent-encoded.** Reserved characters (`&`, `=`, `#`, `?`, etc.) and non-ASCII bytes are escaped via `url.QueryEscape` / `url.PathEscape`. Spaces in query values are encoded - as `+` (form-urlencoded convention), matching `url.Values.Encode()`. + as `+` (form-urlencoded convention), matching `url.Values.Encode()`, + unless `DefaultQueryEncoder` is changed (see above). - **Header values are passed through raw.** Per RFC 7230 §3.2.6, header field values may contain visible ASCII plus space/tab; bytes ≥ `0x80` are `obs-text` and explicitly marked obsolete in RFC 9110. There is no diff --git a/deepobject.go b/deepobject.go index 13ab902..b65e96f 100644 --- a/deepobject.go +++ b/deepobject.go @@ -59,7 +59,7 @@ func marshalDeepObject(in interface{}, path []string) ([]string, error) { // remain as structural delimiters. encoded := make([]string, len(path)) for i, p := range path { - encoded[i] = url.QueryEscape(p) + encoded[i] = DefaultQueryEncoder.EscapeQueryValue(p, false) } prefix := "[" + strings.Join(encoded, "][") + "]" @@ -67,7 +67,7 @@ func marshalDeepObject(in interface{}, path []string) ([]string, error) { if t == nil { value = "null" } else { - value = url.QueryEscape(fmt.Sprintf("%v", t)) + value = DefaultQueryEncoder.EscapeQueryValue(fmt.Sprintf("%v", t), false) } result = []string{ @@ -102,7 +102,7 @@ func MarshalDeepObject(i interface{}, paramName string) (string, error) { // Prefix the param name to each subscripted field. The param name is // percent-encoded to keep the wire output ASCII-clean even if the spec // declares a non-identifier parameter name. - encodedParamName := url.QueryEscape(paramName) + encodedParamName := DefaultQueryEncoder.EscapeQueryValue(paramName, false) for i := range fields { fields[i] = encodedParamName + fields[i] } diff --git a/encoder.go b/encoder.go new file mode 100644 index 0000000..791935b --- /dev/null +++ b/encoder.go @@ -0,0 +1,77 @@ +// Copyright 2019 DeepMap, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package runtime + +import ( + "net/url" + "strings" +) + +// QueryEncoder escapes values destined for a URL query string. It governs +// query parameter values, names, and map keys; it does NOT affect path +// escaping or application/x-www-form-urlencoded request bodies, where encoding +// a space as '+' is the correct, media-type-defined behavior. +// +// Names and map keys are escaped as values with allowReserved=false, since +// allowReserved applies only to values per the OpenAPI spec. +// +// Generated clients route all query escaping through DefaultQueryEncoder. +// Provide a custom implementation, or use one of the built-ins +// (NetURLQueryEncoder, RFC3986QueryEncoder), to control how query strings are +// encoded on the wire. +type QueryEncoder interface { + // EscapeQueryValue escapes a query parameter value. When allowReserved is + // true, RFC 3986 reserved characters are left unencoded, per OpenAPI's + // allowReserved option (which applies to values only). + EscapeQueryValue(value string, allowReserved bool) string +} + +// NetURLQueryEncoder preserves Go's net/url behavior, encoding a space as '+'. +// This matches the application/x-www-form-urlencoded convention that browsers +// use for HTML forms. It is the default encoder, so existing generated clients +// are unaffected. Note that many, but not all, servers accept '+' as a space. +type NetURLQueryEncoder struct{} + +func (NetURLQueryEncoder) EscapeQueryValue(value string, allowReserved bool) string { + if allowReserved { + // The allowReserved encoding is already RFC 3986 compliant (space is + // encoded as %20), so it is identical for both built-in encoders. + return escapeQueryAllowReserved(value) + } + return url.QueryEscape(value) +} + +// RFC3986QueryEncoder encodes a space as %20 rather than '+', for strict RFC +// 3986 compliance. Some servers (for example, OData endpoints that expect +// filters such as ?filter=name%20eq%20'x') reject '+'-encoded spaces with 400 +// Bad Request. Set runtime.DefaultQueryEncoder to an RFC3986QueryEncoder to opt +// in. +// +// The +->%20 rewrite is lossless: url.QueryEscape encodes a literal '+' in the +// input as %2B, so any '+' remaining in its output can only be a space. +type RFC3986QueryEncoder struct{} + +func (RFC3986QueryEncoder) EscapeQueryValue(value string, allowReserved bool) string { + if allowReserved { + return escapeQueryAllowReserved(value) + } + return strings.ReplaceAll(url.QueryEscape(value), "+", "%20") +} + +// DefaultQueryEncoder is the QueryEncoder used by all generated clients to +// escape query parameters. It defaults to NetURLQueryEncoder for backwards +// compatibility. Set it once during program initialization; like +// http.DefaultClient, it is not safe to mutate concurrently with in-flight +// requests. +var DefaultQueryEncoder QueryEncoder = NetURLQueryEncoder{} diff --git a/encoder_test.go b/encoder_test.go new file mode 100644 index 0000000..88158a2 --- /dev/null +++ b/encoder_test.go @@ -0,0 +1,122 @@ +// Copyright 2019 DeepMap, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultQueryEncoder(t *testing.T) { + // The default encoder must not have been changed by any other test. + assert.IsType(t, NetURLQueryEncoder{}, DefaultQueryEncoder, + "DefaultQueryEncoder should default to NetURLQueryEncoder") + + // withQueryEncoder temporarily installs enc, restoring the previous + // encoder when the test finishes. + withQueryEncoder := func(t *testing.T, enc QueryEncoder) { + t.Helper() + prev := DefaultQueryEncoder + DefaultQueryEncoder = enc + t.Cleanup(func() { DefaultQueryEncoder = prev }) + } + + t.Run("net/url encoder keeps + for spaces (default)", func(t *testing.T) { + withQueryEncoder(t, NetURLQueryEncoder{}) + + result, err := StyleParamWithLocation("form", false, "filter", ParamLocationQuery, "name eq 'x'") + assert.NoError(t, err) + assert.EqualValues(t, "filter=name+eq+%27x%27", result) + }) + + t.Run("RFC 3986 encoder uses %20 for spaces", func(t *testing.T) { + withQueryEncoder(t, RFC3986QueryEncoder{}) + + result, err := StyleParamWithLocation("form", false, "filter", ParamLocationQuery, "name eq 'x'") + assert.NoError(t, err) + assert.EqualValues(t, "filter=name%20eq%20%27x%27", result) + }) + + t.Run("RFC 3986 encoder leaves literal + intact", func(t *testing.T) { + withQueryEncoder(t, RFC3986QueryEncoder{}) + + // A real '+' becomes %2B; only spaces become %20. This confirms the + // +->%20 rewrite does not corrupt genuine plus characters. + result, err := StyleParamWithLocation("form", false, "ts", ParamLocationQuery, "2020-01-01 22:00:00+02:00") + assert.NoError(t, err) + assert.EqualValues(t, "ts=2020-01-01%2022%3A00%3A00%2B02%3A00", result) + }) + + t.Run("encoder applies to exploded array values", func(t *testing.T) { + withQueryEncoder(t, RFC3986QueryEncoder{}) + + result, err := StyleParamWithLocation("form", true, "q", ParamLocationQuery, []string{"a b", "c d"}) + assert.NoError(t, err) + assert.EqualValues(t, "q=a%20b&q=c%20d", result) + }) + + t.Run("encoder applies to param names", func(t *testing.T) { + withQueryEncoder(t, RFC3986QueryEncoder{}) + + result, err := StyleParamWithLocation("form", false, "my filter", ParamLocationQuery, "v") + assert.NoError(t, err) + assert.EqualValues(t, "my%20filter=v", result) + }) + + t.Run("allowReserved is RFC 3986 regardless of encoder", func(t *testing.T) { + // The allowReserved path already emits %20 for spaces, so both + // encoders agree. + for _, enc := range []QueryEncoder{NetURLQueryEncoder{}, RFC3986QueryEncoder{}} { + withQueryEncoder(t, enc) + result, err := StyleParamWithOptions("form", false, "q", "hello world", StyleParamOptions{ + ParamLocation: ParamLocationQuery, + AllowReserved: true, + }) + assert.NoError(t, err) + assert.EqualValues(t, "q=hello%20world", result) + } + }) + + t.Run("encoder does not affect path params", func(t *testing.T) { + withQueryEncoder(t, RFC3986QueryEncoder{}) + + // Path params always use url.PathEscape, which already encodes a + // space as %20 and is unaffected by the query encoder. + result, err := StyleParamWithLocation("simple", false, "id", ParamLocationPath, "a b") + assert.NoError(t, err) + assert.EqualValues(t, "a%20b", result) + }) +} + +// TestDeepObject_QueryEncoder verifies that MarshalDeepObject routes value, +// key, and param-name escaping through DefaultQueryEncoder, so the RFC 3986 +// encoder produces %20 for spaces instead of '+'. +func TestDeepObject_QueryEncoder(t *testing.T) { + prev := DefaultQueryEncoder + DefaultQueryEncoder = RFC3986QueryEncoder{} + t.Cleanup(func() { DefaultQueryEncoder = prev }) + + src := map[string]interface{}{ + "full name": "Ada Lovelace", + } + + marshaled, err := MarshalDeepObject(src, "my filter") + require.NoError(t, err) + + // Param name, map key, and value all use %20 for their spaces; the + // structural '[' and ']' delimiters remain literal. + assert.Equal(t, "my%20filter[full%20name]=Ada%20Lovelace", marshaled) +} diff --git a/styleparam.go b/styleparam.go index c65e1c0..0e3ca89 100644 --- a/styleparam.go +++ b/styleparam.go @@ -525,10 +525,7 @@ func escapeParameterName(name string, paramLocation ParamLocation) string { func escapeParameterString(value string, paramLocation ParamLocation, allowReserved bool) string { switch paramLocation { case ParamLocationQuery: - if allowReserved { - return escapeQueryAllowReserved(value) - } - return url.QueryEscape(value) + return DefaultQueryEncoder.EscapeQueryValue(value, allowReserved) case ParamLocationPath: return url.PathEscape(value) default: