diff --git a/deepobject.go b/deepobject.go index ceda01f..f33a020 100644 --- a/deepobject.go +++ b/deepobject.go @@ -83,6 +83,17 @@ func MarshalDeepObject(i interface{}, paramName string) (string, error) { // can then walk the generic object structure to produce a deepObject. This // isn't efficient and it would be more efficient to reflect on our own, // but it's complicated, error-prone code. + + // Handle concrete-typed nil maps and nil slices via + // reflection. Without this, json.Marshal encodes them as "null", which + // round-trips to a nil interface{} and emits an undesired "param[]=null" pair. + v := reflect.ValueOf(i) + switch v.Kind() { + case reflect.Map, reflect.Slice: + if v.IsNil() { + return "", nil + } + } buf, err := json.Marshal(i) if err != nil { return "", fmt.Errorf("failed to marshal input to JSON: %w", err) diff --git a/deepobject_test.go b/deepobject_test.go index 1320e1e..41230c1 100644 --- a/deepobject_test.go +++ b/deepobject_test.go @@ -495,6 +495,52 @@ func TestDeepObject_InterfaceDestination(t *testing.T) { assert.Equal(t, want, *pdst) } +// TestMarshalDeepObject_NilAndEmpty verifies that nil and empty nilable types +// (maps and slices, both directly and via pointer) produce the empty string +// from MarshalDeepObject. An absent/empty parameter must not add any +// query-string contribution. +func TestMarshalDeepObject_NilAndEmpty(t *testing.T) { + type testCase struct { + name string + input interface{} + } + cases := []testCase{ + // interface-typed nil / empty — handled by the pre-check in MarshalDeepObject + {"nil map[string]interface{}", (map[string]interface{})(nil)}, + {"empty map[string]interface{}", map[string]interface{}{}}, + {"nil []interface{}", ([]interface{})(nil)}, + {"empty []interface{}", []interface{}{}}, + // concrete-typed nil / empty maps + {"nil map[string]string", (map[string]string)(nil)}, + {"empty map[string]string", map[string]string{}}, + {"nil map[string]int", (map[string]int)(nil)}, + {"empty map[string]int", map[string]int{}}, + // concrete-typed nil / empty slices + {"nil []string", ([]string)(nil)}, + {"empty []string", []string{}}, + {"nil []int", ([]int)(nil)}, + {"empty []int", []int{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := MarshalDeepObject(tc.input, "p") + require.NoError(t, err) + assert.Equal(t, "", got, "expected empty string for nil/empty nilable type") + }) + } + for _, tc := range []testCase{ + // nil pointers to maps and slices + {"nil *map[string]string", (*map[string]string)(nil)}, + {"nil *[]string", (*[]string)(nil)}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := MarshalDeepObject(tc.input, "p") + require.NoError(t, err) + assert.Equal(t, "p[]=null", got, "nil pointers are still serialized as null") + }) + } +} + // TestDeepObject_InterfaceRoundTrip verifies that a map[string]interface{} // serialized by MarshalDeepObject binds back to an equal value. func TestDeepObject_InterfaceRoundTrip(t *testing.T) {