Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions deepobject.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ 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, "][") + "]"

var value string
if t == nil {
value = "null"
} else {
value = url.QueryEscape(fmt.Sprintf("%v", t))
value = DefaultQueryEncoder.EscapeQueryValue(fmt.Sprintf("%v", t), false)
}

result = []string{
Expand Down Expand Up @@ -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]
}
Expand Down
77 changes: 77 additions & 0 deletions encoder.go
Original file line number Diff line number Diff line change
@@ -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{}
122 changes: 122 additions & 0 deletions encoder_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 1 addition & 4 deletions styleparam.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down