Repository: danielgtaylor/huma
Pull request #711 (2025-01-23 08:35:02)

feat: param query support deepObject style
```go
// GreetingOutput represents the greeting operation response.
type GreetingOutput struct {
	Body struct {
		Person Person            `json:"person"`
		Map    map[string]string `json:"map"`
	}
}

type Person struct {
	Name     string `json:"name"`
	Age      int    `json:"age,omitempty" default:"20"`
	Birthday string `json:"birthday,omitempty"`
}

func main() {
	// Create a new router & API
	router := chi.NewMux()
	api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))

	// Register GET /greeting
	huma.Get(api, "/greeting", func(ctx context.Context, input *struct {
		Person Person            `query:"person,deepObject"`
		Map    map[string]string `query:"map,deepObject"`
	}) (*GreetingOutput, error) {
		out := &GreetingOutput{}
		out.Body.Person = input.Person
		out.Body.Map = input.Map
		return out, nil
	})

	// Start the server!
	log.Println("http://127.0.0.1:8888/docs")
	http.ListenAndServe("127.0.0.1:8888", router)
}

```

```sh
curl --request GET \
  --url 'http://127.0.0.1:8888/greeting?person%5Bname%5D=foo&map%5Ba%5D=foo&map%5Bb%5D=foo2' \
  --header 'Accept: application/json, application/problem+json'
```

### Response
```json
{
  "$schema": "http://127.0.0.1:8888/schemas/GreetingOutputBody.json",
  "person": {
    "name": "foo",
    "age": 20
  },
  "map": {
    "a": "foo",
    "b": "foo2"
  }
}

```



Patch:
diff --git a/huma.go b/huma.go
index 301ec51..029beaf 100644
--- a/huma.go
+++ b/huma.go
@@ -82,6 +82,8 @@ type StreamResponse struct {
 	Body func(ctx Context)
 }
 
+const styleDeepObject = "deepObject"
+
 type paramFieldInfo struct {
 	Type       reflect.Type
 	Name       string
@@ -90,6 +92,7 @@ type paramFieldInfo struct {
 	Default    string
 	TimeFormat string
 	Explode    bool
+	Style      string
 	Schema     *Schema
 }
 
@@ -125,6 +128,9 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
 			if slices.Contains(split[1:], "explode") {
 				pfi.Explode = true
 			}
+			if slices.Contains(split[1:], styleDeepObject) {
+				pfi.Style = styleDeepObject
+			}
 			explode = &pfi.Explode
 		} else if h := f.Tag.Get("header"); h != "" {
 			pfi.Loc = "header"
@@ -199,6 +205,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
 				Required:    pfi.Required,
 				Schema:      pfi.Schema,
 				Example:     example,
+				Style:       pfi.Style,
 			})
 		}
 
@@ -691,28 +698,48 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 				}
 			}
 
-			value := getParamValue(*p, ctx, cookies)
-			if value == "" {
-				if !op.SkipValidateParams && p.Required {
-					// Path params are always required.
-					res.Add(pb, "", "required "+p.Loc+" parameter is missing")
-				}
-				return
-			}
-
 			var receiver = f
 			if f.Addr().Type().Implements(reflect.TypeFor[ParamWrapper]()) {
 				receiver = f.Addr().Interface().(ParamWrapper).Receiver()
 			}
 
-			pv, err := parseInto(ctx, receiver, value, nil, *p)
-			if err != nil {
-				res.Add(pb, value, err.Error())
-				return
+			var pv any
+			var isSet bool
+			if p.Loc == "query" && p.Style == styleDeepObject {
+				// Deep object style is a special case where we need to parse the
+				// query parameter into a struct. We do this by parsing the query
+				// parameter into a map, then iterating over the map and setting
+				// the fields on the struct.
+				u := ctx.URL()
+				value := parseDeepObjectQuery(u.Query(), p.Name)
+				isSet = len(value) > 0
+				if len(value) == 0 {
+					if !op.SkipValidateParams && p.Required {
+						res.Add(pb, "", "required "+p.Loc+" parameter is missing")
+					}
+					return
+				}
+				pv = setDeepObjectValue(pb, res, receiver, value)
+			} else {
+				value := getParamValue(*p, ctx, cookies)
+				isSet = value != ""
+				if value == "" {
+					if !op.SkipValidateParams && p.Required {
+						// Path params are always required.
+						res.Add(pb, "", "required "+p.Loc+" parameter is missing")
+					}
+					return
+				}
+				var err error
+				pv, err = parseInto(ctx, receiver, value, nil, *p)
+				if err != nil {
+					res.Add(pb, value, err.Error())
+					return
+				}
 			}
 
 			if f.Addr().Type().Implements(reflect.TypeFor[ParamReactor]()) {
-				f.Addr().Interface().(ParamReactor).OnParamSet(value != "", pv)
+				f.Addr().Interface().(ParamReactor).OnParamSet(isSet, pv)
 			}
 
 			if !op.SkipValidateParams {
@@ -965,6 +992,111 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 	})))
 }
 
+func parseDeepObjectQuery(query url.Values, name string) map[string]string {
+	result := make(map[string]string)
+
+	for key, values := range query {
+		if strings.Contains(key, "[") {
+			// Nested object
+			keys := strings.Split(key, "[")
+			if keys[0] != name {
+				continue
+			}
+			k := strings.Trim(keys[1], "]")
+			result[k] = values[0]
+		}
+	}
+	return result
+}
+
+func setDeepObjectValue(pb *PathBuffer, res *ValidateResult, f reflect.Value, data map[string]string) map[string]any {
+	t := f.Type()
+	result := make(map[string]any)
+	switch t.Kind() {
+	case reflect.Map:
+		if t.Key().Kind() != reflect.String {
+			panic("unsupported map key type")
+		}
+		f.Set(reflect.MakeMap(t))
+		for k, v := range data {
+			key := reflect.New(t.Key()).Elem()
+			key.SetString(k)
+			value := reflect.New(t.Elem()).Elem()
+			if err := setFieldValue(value, v); err != nil {
+				pb.Push(k)
+				res.Add(pb, v, err.Error())
+				pb.Pop()
+			} else {
+				f.SetMapIndex(key, value)
+				result[k] = value.Interface()
+			}
+		}
+	case reflect.Struct:
+		for i := 0; i < t.NumField(); i++ {
+			field := t.Field(i)
+			// Get the field name
+			fieldName := field.Name
+
+			if name := jsonName(field); name != "" {
+				fieldName = name
+			}
+
+			fv := f.Field(i)
+			if val, ok := data[fieldName]; ok {
+				if err := setFieldValue(fv, val); err != nil {
+					pb.Push(fieldName)
+					res.Add(pb, val, err.Error())
+					pb.Pop()
+				} else {
+					result[fieldName] = fv.Interface()
+				}
+			} else {
+				if val := field.Tag.Get("default"); val != "" {
+					setFieldValue(fv, val)
+					result[fieldName] = fv.Interface()
+				}
+			}
+		}
+	}
+	return result
+}
+
+func setFieldValue(f reflect.Value, value string) error {
+	switch f.Kind() {
+	case reflect.String:
+		f.SetString(value)
+	case reflect.Interface:
+		f.Set(reflect.ValueOf(value))
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		v, err := strconv.ParseInt(value, 10, 64)
+		if err != nil {
+			return errors.New("invalid integer")
+		}
+		f.SetInt(v)
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		v, err := strconv.ParseUint(value, 10, 64)
+		if err != nil {
+			return errors.New("invalid integer")
+		}
+		f.SetUint(v)
+	case reflect.Float32, reflect.Float64:
+		v, err := strconv.ParseFloat(value, 64)
+		if err != nil {
+			return errors.New("invalid float")
+		}
+		f.SetFloat(v)
+	case reflect.Bool:
+		v, err := strconv.ParseBool(value)
+		if err != nil {
+			return errors.New("invalid boolean")
+		}
+		f.SetBool(v)
+	default:
+		return errors.New("unsupported type")
+	}
+	return nil
+}
+
 // ParamWrapper is an interface that can be implemented by a wrapping type
 // to expose a field into which request parameters may be parsed.
 // Must have pointer receiver.


Test patch:
diff --git a/huma_test.go b/huma_test.go
index cd21f79..e83b0bf 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -605,6 +605,120 @@ func TestFeatures(t *testing.T) {
 			URL:    "/test?param=42",
 			Method: http.MethodGet,
 		},
+		{
+			Name: "param-deepObject-struct",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/test",
+				}, func(ctx context.Context, i *struct {
+					Test struct {
+						Int     int     `json:"int"`
+						Uint    uint    `json:"uint"`
+						Float   float64 `json:"float"`
+						Bool    bool    `json:"bool"`
+						String  string  `json:"string"`
+						Any     any     `json:"any"`
+						Default string  `json:"default,omitempty" default:"foo"`
+					} `query:"test,deepObject"`
+				}) (*struct{}, error) {
+					assert.Equal(t, 1, i.Test.Int)
+					assert.Equal(t, uint(12), i.Test.Uint)
+					assert.InDelta(t, 123.0, i.Test.Float, 1e-6)
+					assert.True(t, i.Test.Bool)
+					assert.Equal(t, "foo", i.Test.String)
+					assert.Equal(t, "foo", i.Test.Any)
+					assert.Equal(t, "foo", i.Test.Default)
+					return nil, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/test?test[int]=1&test[uint]=12&test[float]=123.0&test[bool]=true&test[string]=foo&test[any]=foo&test2[foo]=a",
+		},
+		{
+			Name: "param-deepObject-map-required",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/test",
+				}, func(ctx context.Context, i *struct {
+					Test map[string]string `query:"test,deepObject" required:"true"`
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/test",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
+				assert.Contains(t, resp.Body.String(), "required query parameter is missing")
+			},
+		},
+		{
+			Name: "param-deepObject-map",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/test",
+				}, func(ctx context.Context, i *struct {
+					Test map[string]string `query:"test,deepObject"`
+				}) (*struct{}, error) {
+					assert.Equal(t, "foo_a", i.Test["a"])
+					assert.Equal(t, "foo_b", i.Test["b"])
+					assert.Equal(t, "foo_c", i.Test["c"])
+					return nil, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/test?test[a]=foo_a&test[b]=foo_b&test[c]=foo_c",
+		},
+		{
+			Name: "param-deepObject-map-error",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/test",
+				}, func(ctx context.Context, i *struct {
+					Test map[string]int `query:"test,deepObject"`
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/test?test[a]=foo_a",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
+				assert.Contains(t, resp.Body.String(), "invalid integer")
+			},
+		},
+		{
+			Name: "param-deepObject-struct-error",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/test",
+				}, func(ctx context.Context, i *struct {
+					Test struct {
+						Int   int       `json:"int"`
+						Uint  uint      `json:"uint"`
+						Float float64   `json:"float"`
+						Bool  bool      `json:"bool"`
+						Date  time.Time `json:"date"`
+					} `query:"test,deepObject"`
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/test?test[int]=a&test[uint]=a&test[float]=a&test[bool]=a&test[date]=a",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
+				assert.Contains(t, resp.Body.String(), "invalid integer")
+				assert.Contains(t, resp.Body.String(), "invalid float")
+				assert.Contains(t, resp.Body.String(), "invalid boolean")
+				assert.Contains(t, resp.Body.String(), "unsupported type")
+			},
+		},
 		{
 			Name: "request-body",
 			Register: func(t *testing.T, api huma.API) {