From a799e2531204cce5fbd9037e7fcd8494d7efc892 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Mon, 23 Feb 2026 23:52:03 -0800 Subject: [PATCH] fix: support non-indexed deepObject array unmarshaling (#22) OpenAPI 3.0's deepObject style has undefined behavior for arrays. Two conventions exist in the wild: - Indexed: p[vals][0]=a&p[vals][1]=b (oapi-codegen's current format) - Non-indexed: p[vals]=a&p[vals]=b (Swagger UI / Rails convention) Swagger UI generates the non-indexed format, which previously failed with "[field] has multiple values". This change makes UnmarshalDeepObject accept both formats by detecting repeated query parameter keys and expanding them into synthetic indexed entries (e.g. [vals][0], [vals][1]) before feeding them into the existing tree-construction and assignment logic. Marshaling (MarshalDeepObject) intentionally remains unchanged and continues to emit the indexed format. The indexed format is unambiguous, already consumed correctly by all known implementations, and is what oapi-codegen's own generated clients expect. Changing it to the non-indexed format would be a breaking change for consumers that rely on the current wire format, with no practical benefit since both formats are now accepted on the unmarshaling side. Co-Authored-By: Claude Opus 4.6 --- deepobject.go | 13 +++++++++---- deepobject_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/deepobject.go b/deepobject.go index 2742076..c8498fa 100644 --- a/deepobject.go +++ b/deepobject.go @@ -136,11 +136,16 @@ func UnmarshalDeepObject(dst interface{}, paramName string, params url.Values) e if strings.HasPrefix(pName, searchStr) { // trim the parameter name from the full name. pName = pName[len(paramName):] - fieldNames = append(fieldNames, pName) - if len(pValues) != 1 { - return fmt.Errorf("%s has multiple values", pName) + if len(pValues) == 1 { + fieldNames = append(fieldNames, pName) + fieldValues = append(fieldValues, pValues[0]) + } else { + // Non-indexed array format: expand repeated keys into indexed entries + for i, value := range pValues { + fieldNames = append(fieldNames, pName+"["+strconv.Itoa(i)+"]") + fieldValues = append(fieldValues, value) + } } - fieldValues = append(fieldValues, pValues[0]) } } diff --git a/deepobject_test.go b/deepobject_test.go index 2646ad4..12c86f9 100644 --- a/deepobject_test.go +++ b/deepobject_test.go @@ -170,3 +170,38 @@ func TestDeepObject_ArrayOfObjects(t *testing.T) { assert.Equal(t, "second", dstArray[1].Name) assert.Equal(t, "value2", dstArray[1].Value) } + +func TestDeepObject_NonIndexedArray(t *testing.T) { + t.Run("primitive string array", func(t *testing.T) { + params := url.Values{} + params.Add("p[vals]", "a") + params.Add("p[vals]", "b") + + type Obj struct { + Vals []string `json:"vals"` + } + + var dst Obj + err := UnmarshalDeepObject(&dst, "p", params) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b"}, dst.Vals) + }) + + t.Run("object with mixed scalar and non-indexed array", func(t *testing.T) { + params := url.Values{} + params.Set("p[op]", "eq") + params.Add("p[vals]", "a") + params.Add("p[vals]", "b") + + type Filter struct { + Op string `json:"op"` + Vals []string `json:"vals"` + } + + var dst Filter + err := UnmarshalDeepObject(&dst, "p", params) + require.NoError(t, err) + assert.Equal(t, "eq", dst.Op) + assert.Equal(t, []string{"a", "b"}, dst.Vals) + }) +}