Skip to content

Commit a00222d

Browse files
Honor ForceSendFields for query parameters (#1728)
**About failing integration tests:** this is not a false positive but the issue is at the server level. The owning team is addressing it. If not resolve in time, I will simply temporarily comment these tests. ## Summary Query parameters listed in `ForceSendFields` are now sent on the wire even when they hold a zero value, matching the behavior that already exists for JSON request bodies. This fixes cases like the pipelines Delete API, where setting `Cascade` to `false` and adding it to `ForceSendFields` did not actually send `cascade=false` on the wire. ## Why Query parameters are serialized from the request struct's `url` tag by the go-querystring library. That tag uses `omitempty`, so a zero-valued scalar field (for example a `false` bool, a `0` int, or an empty string) is dropped from the URL. `ForceSendFields` was designed to opt out of exactly this behavior, but it was only ever wired into the `marshal` package, which handles JSON bodies. go-querystring has no knowledge of `ForceSendFields`, so adding a query field to it had no effect. (Path parameters are not affected: they are tagged `url:"-"` and substituted directly into the URL path, so they never flow through this serialization.) A user reported this for the pipelines Delete API: `Cascade` defaults to `true` server-side, so a caller who wants to delete a pipeline without cascading must send `cascade=false` explicitly. Adding `Cascade` to `ForceSendFields` looked like the right way to do that (it is how the same situation is handled for JSON bodies), but the parameter was silently dropped and the server applied its default. This affected every query parameter across all services, not just `cascade`, including fields nested inside request sub-structs. ## How is this tested? Added two table-driven tests in `httpclient/request_test.go`. `TestMakeQueryStringForceSendFields` covers the top-level cases: zero values dropped without `ForceSendFields`, a `false` bool sent when forced, non-zero values still sent without forcing, multiple forced fields, forcing not overriding an explicitly set value, and unknown forced field names being ignored. `TestMakeQueryStringForceSendFieldsNested` covers nested sub-structs: a nested struct honoring its own `ForceSendFields`, non-nil and nil pointers to nested structs, and doubly nested fields. Also verified end-to-end by calling the real pipelines Delete and `iam.CheckPolicy` APIs through a fake HTTP transport and confirming the forced zero values appear in the request URL. The full `httpclient` test suite passes. --------- Signed-off-by: Ubuntu <renaud.hartert@databricks.com>
1 parent b3086eb commit a00222d

4 files changed

Lines changed: 239 additions & 0 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44

55
### Breaking Changes
66

7+
* Query parameters listed in `ForceSendFields` are now sent on the wire when they hold a zero value. Previously `ForceSendFields` had no effect on query parameters (only JSON body fields were honored), so such a parameter was silently omitted; it is now serialized with its explicit value (for example `cascade=false`). Callers that unknowingly relied on the previous no-op behavior will now send the parameter.
8+
79
### New Features and Improvements
810

911
### Bug Fixes
1012

13+
* Honor `ForceSendFields` for query parameters, including fields nested inside request sub-structs. A zero-valued scalar field (for example a `false` bool such as `cascade` on the pipelines Delete request) listed in `ForceSendFields` is now sent on the wire instead of being dropped by the `url` tag's `omitempty`, matching the existing behavior for JSON request bodies.
14+
1115
### Documentation
1216

1317
### Internal Changes

httpclient/request.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ func makeQueryString(data interface{}) (string, error) {
137137
if err != nil {
138138
return "", fmt.Errorf("cannot create query string: %w", err)
139139
}
140+
// go-querystring honors the `url` tag's omitempty option but knows
141+
// nothing about ForceSendFields, so a zero-valued field (e.g. a false
142+
// bool) listed in ForceSendFields would be dropped. Re-add those
143+
// fields so query parameters follow the same ForceSendFields semantics
144+
// already used for JSON bodies.
145+
addForceSendQueryParams(inputVal, params, "")
140146
// Query parameters may be nested, but the keys generated by
141147
// query.Values use the "[" and "]" characters to represent nesting.
142148
// Replace all instances of "[" with "." and "]" with empty string
@@ -156,6 +162,92 @@ func makeQueryString(data interface{}) (string, error) {
156162
return "", fmt.Errorf("unsupported query string data: %#v", data)
157163
}
158164

165+
// addForceSendQueryParams re-adds query parameters that go-querystring dropped
166+
// because of the `url` tag's omitempty option but which the struct's
167+
// ForceSendFields asks to send anyway. This mirrors the ForceSendFields
168+
// handling for JSON bodies in the marshal package, including for nested
169+
// structs: each struct level honors its own ForceSendFields.
170+
//
171+
// prefix is the go-querystring key prefix for the fields of structVal ("" at
172+
// the top level). Nested keys use the same "parent[child]" bracket notation
173+
// that go-querystring emits, so the caller's proto-compat rewrite ("[" -> ".",
174+
// "]" -> "") applies uniformly to forced and non-forced parameters alike.
175+
//
176+
// Only basic (scalar) types are forced, matching marshal. Nil pointers and
177+
// slices are never forced (a nil pointer means "not set"; marshal also skips
178+
// nil/empty slices). Slice elements are not recursed into.
179+
func addForceSendQueryParams(structVal reflect.Value, params url.Values, prefix string) {
180+
structVal = reflect.Indirect(structVal)
181+
if structVal.Kind() != reflect.Struct {
182+
return
183+
}
184+
structType := structVal.Type()
185+
186+
// Collect this struct level's ForceSendFields (Go field names).
187+
forced := map[string]bool{}
188+
if f := structVal.FieldByName("ForceSendFields"); f.IsValid() {
189+
if names, ok := f.Interface().([]string); ok {
190+
for _, name := range names {
191+
forced[name] = true
192+
}
193+
}
194+
}
195+
196+
for i := 0; i < structType.NumField(); i++ {
197+
field := structType.Field(i)
198+
if !field.IsExported() {
199+
continue
200+
}
201+
// Reuse the first segment of the `url` tag as the parameter name;
202+
// skip fields that are not query parameters (no tag or "-").
203+
paramName, _, _ := strings.Cut(field.Tag.Get("url"), ",")
204+
if paramName == "" || paramName == "-" {
205+
continue
206+
}
207+
key := paramName
208+
if prefix != "" {
209+
key = prefix + "[" + paramName + "]"
210+
}
211+
value := structVal.Field(i)
212+
213+
// Recurse into nested structs (and non-nil pointers to structs) so a
214+
// nested struct's own ForceSendFields is honored too. reflect.Indirect
215+
// of a nil pointer yields an invalid Value, so nil pointers stop here.
216+
if reflect.Indirect(value).Kind() == reflect.Struct {
217+
addForceSendQueryParams(value, params, key)
218+
continue
219+
}
220+
221+
// Force-send a dropped zero-valued scalar, leaving any value
222+
// go-querystring already encoded untouched.
223+
if !forced[field.Name] || !isForceableQueryKind(value.Kind()) {
224+
continue
225+
}
226+
if _, exists := params[key]; exists {
227+
continue
228+
}
229+
// go-querystring formats these kinds with fmt.Sprint, so this keeps
230+
// the encoding identical to a non-zero value.
231+
params.Set(key, fmt.Sprint(value.Interface()))
232+
}
233+
}
234+
235+
// isForceableQueryKind reports whether a value of the given kind may be
236+
// force-sent as a query parameter. Mirrors the basic-type rule used by the
237+
// marshal package for JSON bodies.
238+
func isForceableQueryKind(k reflect.Kind) bool {
239+
switch k {
240+
case reflect.Bool,
241+
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
242+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
243+
reflect.Float32, reflect.Float64,
244+
reflect.String:
245+
return true
246+
default:
247+
return false
248+
}
249+
}
250+
159251
func EncodeMultiSegmentPathParameter(p string) string {
160252
segments := strings.Split(p, "/")
161253
b := strings.Builder{}

httpclient/request_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,143 @@ func TestMakeRequestBodyQueryUnsupported(t *testing.T) {
107107
require.EqualError(t, err, "unsupported query string data: true")
108108
}
109109

110+
func TestMakeQueryStringForceSendFields(t *testing.T) {
111+
type req struct {
112+
Cascade bool `json:"-" url:"cascade,omitempty"`
113+
Force bool `json:"-" url:"force,omitempty"`
114+
Name string `json:"-" url:"name,omitempty"`
115+
116+
ForceSendFields []string `json:"-" url:"-"`
117+
}
118+
119+
cases := []struct {
120+
name string
121+
in req
122+
want string
123+
}{
124+
{
125+
name: "zero value dropped without ForceSendFields",
126+
in: req{Cascade: false},
127+
want: "",
128+
},
129+
{
130+
name: "false bool sent when forced",
131+
in: req{Cascade: false, ForceSendFields: []string{"Cascade"}},
132+
want: "cascade=false",
133+
},
134+
{
135+
name: "non-zero value still sent without forcing",
136+
in: req{Cascade: true},
137+
want: "cascade=true",
138+
},
139+
{
140+
name: "multiple forced fields, keys sorted by Encode",
141+
in: req{ForceSendFields: []string{"Cascade", "Force"}},
142+
want: "cascade=false&force=false",
143+
},
144+
{
145+
name: "forcing does not override an explicitly set value",
146+
in: req{Cascade: true, ForceSendFields: []string{"Cascade"}},
147+
want: "cascade=true",
148+
},
149+
{
150+
name: "unknown forced field is ignored",
151+
in: req{ForceSendFields: []string{"DoesNotExist"}},
152+
want: "",
153+
},
154+
}
155+
156+
for _, tc := range cases {
157+
t.Run(tc.name, func(t *testing.T) {
158+
got, err := makeQueryString(tc.in)
159+
if err != nil {
160+
t.Fatalf("makeQueryString returned error: %v", err)
161+
}
162+
if got != tc.want {
163+
t.Errorf("makeQueryString() = %q, want %q", got, tc.want)
164+
}
165+
})
166+
}
167+
}
168+
169+
func TestMakeQueryStringForceSendFieldsNested(t *testing.T) {
170+
type inner struct {
171+
Flag bool `json:"-" url:"flag,omitempty"`
172+
Num int64 `json:"-" url:"num,omitempty"`
173+
174+
ForceSendFields []string `json:"-" url:"-"`
175+
}
176+
type deep struct {
177+
Inner inner `json:"-" url:"inner"`
178+
179+
ForceSendFields []string `json:"-" url:"-"`
180+
}
181+
type outer struct {
182+
Inner inner `json:"-" url:"inner"`
183+
PtrInner *inner `json:"-" url:"ptr_inner,omitempty"`
184+
Deep deep `json:"-" url:"deep"`
185+
186+
ForceSendFields []string `json:"-" url:"-"`
187+
}
188+
189+
cases := []struct {
190+
name string
191+
in outer
192+
want string
193+
}{
194+
{
195+
name: "nested struct honors its own ForceSendFields",
196+
in: outer{Inner: inner{ForceSendFields: []string{"Flag"}}},
197+
want: "inner.flag=false",
198+
},
199+
{
200+
name: "nested zero field dropped without forcing",
201+
in: outer{Inner: inner{Flag: false}},
202+
want: "",
203+
},
204+
{
205+
name: "nested non-zero value still sent",
206+
in: outer{Inner: inner{Num: 7}},
207+
want: "inner.num=7",
208+
},
209+
{
210+
name: "non-nil pointer to nested struct is honored",
211+
in: outer{PtrInner: &inner{ForceSendFields: []string{"Flag"}}},
212+
want: "ptr_inner.flag=false",
213+
},
214+
{
215+
name: "nil pointer to nested struct adds nothing",
216+
in: outer{ForceSendFields: []string{"PtrInner"}},
217+
want: "",
218+
},
219+
{
220+
name: "doubly nested struct honors deepest ForceSendFields",
221+
in: outer{Deep: deep{Inner: inner{ForceSendFields: []string{"Num"}}}},
222+
want: "deep.inner.num=0",
223+
},
224+
{
225+
name: "mixed top-level and nested forcing",
226+
in: outer{
227+
Inner: inner{ForceSendFields: []string{"Flag", "Num"}},
228+
ForceSendFields: nil,
229+
},
230+
want: "inner.flag=false&inner.num=0",
231+
},
232+
}
233+
234+
for _, tc := range cases {
235+
t.Run(tc.name, func(t *testing.T) {
236+
got, err := makeQueryString(tc.in)
237+
if err != nil {
238+
t.Fatalf("makeQueryString returned error: %v", err)
239+
}
240+
if got != tc.want {
241+
t.Errorf("makeQueryString() = %q, want %q", got, tc.want)
242+
}
243+
})
244+
}
245+
}
246+
110247
func TestWithTokenSource(t *testing.T) {
111248
client := NewApiClient(ClientConfig{
112249
Transport: hc(func(r *http.Request) (*http.Response, error) {

internal/sharing_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import (
1515
)
1616

1717
func TestUcAccProviders(t *testing.T) {
18+
// TODO: Re-enable once the sharing server issue is resolved. ListSharesAll
19+
// fails with DS_FAILED_REQUEST_TO_OPEN_DS_SERVER against the public Delta
20+
// Sharing server (sharing.delta.io). The failure originates server-side,
21+
// not in the SDK, and the owning team is addressing it.
22+
t.Skip("temporarily disabled: server-side DS_FAILED_REQUEST_TO_OPEN_DS_SERVER from sharing.delta.io")
23+
1824
ctx, w := ucwsTest(t)
1925

2026
// this is a publicly available test token

0 commit comments

Comments
 (0)