From 57d5a8725dfb1ff3e7361369f8dadbcf4cc55e8a Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:33:23 +0000 Subject: [PATCH 01/14] Fix struct header parsing --- huma.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/huma.go b/huma.go index db4e9a79..aeb3e252 100644 --- a/huma.go +++ b/huma.go @@ -143,7 +143,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p name = c if f.Type == cookieType { - // Special case: this will be parsed from a string input to a + // Special case: this will be parsed from a string input to an // `http.Cookie` struct. f.Type = stringType } @@ -244,15 +244,21 @@ type headerInfo struct { func findHeaders(t reflect.Type) *findResult[*headerInfo] { return findInType(t, nil, func(sf reflect.StructField, i []int) *headerInfo { - // Ignore embedded fields + // Ignore embedded fields. if sf.Anonymous { return nil } header := sf.Tag.Get("header") if header == "" { + // Only use field name as header if this is a top-level field (depth 1). + if len(i) > 1 { + return nil + } + header = sf.Name } + timeFormat := "" if sf.Type == timeType { timeFormat = http.TimeFormat @@ -260,6 +266,7 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { timeFormat = f } } + return &headerInfo{sf, header, timeFormat} }, false, "Status", "Body") } @@ -648,6 +655,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if outputType.Kind() != reflect.Struct { panic("output must be a struct") } + outHeaders, outStatusIndex, outBodyIndex, outBodyFunc := processOutputType(outputType, &op, registry) if len(op.Errors) > 0 { @@ -661,10 +669,8 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if documenter, ok := api.(OperationDocumenter); ok { // Enables customization of OpenAPI documentation behavior for operations. documenter.DocumentOperation(&op) - } else { - if !op.Hidden { - oapi.AddOperation(&op) - } + } else if !op.Hidden { + oapi.AddOperation(&op) } resolvers := findResolvers(resolverType, inputType) @@ -1320,7 +1326,7 @@ func setRequestBodyRequired(rb *RequestBody) { rb.Required = true } -// processOutputType validates the output type, extracts possible responses and +// processOutputType validates the output type, extracts possible responses, and // defines them on the operation op. func processOutputType(outputType reflect.Type, op *Operation, registry Registry) (*findResult[*headerInfo], int, int, bool) { outStatusIndex := -1 From 7327917f6ab0b33a0cfa161dbf3049c66df20af1 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:02:21 +0000 Subject: [PATCH 02/14] Expand header parsing support We previously only iterated over struct fields if the struct belonged to a slice. We now iterate all struct fields regardless of whether it's a single object or part of a slice. --- huma.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/huma.go b/huma.go index aeb3e252..5771706f 100644 --- a/huma.go +++ b/huma.go @@ -251,11 +251,21 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { header := sf.Tag.Get("header") if header == "" { - // Only use field name as header if this is a top-level field (depth 1). + // Only use field name as header if this is a top-level field (depth 1) + // and it's not a struct (which we recurse into). if len(i) > 1 { return nil } + fieldType := sf.Type + if fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + + if fieldType.Kind() == reflect.Struct && fieldType != timeType { + return nil + } + header = sf.Name } @@ -268,7 +278,7 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { } return &headerInfo{sf, header, timeFormat} - }, false, "Status", "Body") + }, true, "Status", "Body") } type findResultPath[T comparable] struct { From 61cc5d0419304595c30ba1f9c5fe6fa20cb3c48f Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:15:09 +0000 Subject: [PATCH 03/14] Omit hidden headers from docs --- huma.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/huma.go b/huma.go index 5771706f..6ae4f08b 100644 --- a/huma.go +++ b/huma.go @@ -1412,28 +1412,50 @@ func processOutputType(outputType reflect.Type, op *Operation, registry Registry Description: http.StatusText(op.DefaultStatus), } } + outHeaders := findHeaders(outputType) for _, entry := range outHeaders.Paths { + v := entry.Value + + // Check if this field or any parent is hidden. + hidden := false + currentType := outputType + for _, idx := range entry.Path { + currentType = deref(currentType) + field := currentType.Field(idx) + if boolTag(field, "hidden", false) { + hidden = true + break + } + currentType = field.Type + } + if hidden { + continue + } + // Document the header's name and type. if op.Responses[defaultStatusStr].Headers == nil { op.Responses[defaultStatusStr].Headers = map[string]*Param{} } - v := entry.Value + f := v.Field if f.Type.Kind() == reflect.Slice { f.Type = deref(f.Type.Elem()) } + if reflect.PointerTo(f.Type).Implements(fmtStringerType) { // Special case: this field will be written as a string by calling // `.String()` on the value. f.Type = stringType } + op.Responses[defaultStatusStr].Headers[v.Name] = &Header{ // We need to generate the schema from the field to get validation info // like min/max and enums. Useful to let the client know possible values. Schema: SchemaFromField(registry, f, getHint(outputType, f.Name, op.OperationID+defaultStatusStr+v.Name)), } } + return outHeaders, outStatusIndex, outBodyIndex, outBodyFunc } From 66ebe741b91ad0ae7e593691a74fc61308016c49 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:35:34 +0000 Subject: [PATCH 04/14] Update tests --- huma_test.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/huma_test.go b/huma_test.go index 1f09708a..e2495970 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1894,14 +1894,20 @@ Content-Type: text/plain { Name: "response-headers", Register: func(t *testing.T, api huma.API) { + type NestedHeaders struct { + ContentType string `header:"Content-Type"` + XCustom string `header:"X-Custom"` + } + type Resp struct { - Str string `header:"str"` - Int int `header:"int"` - Uint uint `header:"uint"` - Float float64 `header:"float"` - Bool bool `header:"bool"` - Date time.Time `header:"date"` - Empty string `header:"empty"` + Str string `header:"str"` + Int int `header:"int"` + Uint uint `header:"uint"` + Float float64 `header:"float"` + Bool bool `header:"bool"` + Date time.Time `header:"date"` + Empty string `header:"empty"` + Headers NestedHeaders } huma.Register(api, huma.Operation{ @@ -1915,8 +1921,25 @@ Content-Type: text/plain resp.Float = 3.45 resp.Bool = true resp.Date = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) + resp.Headers.ContentType = "application/json" + resp.Headers.XCustom = "custom-value" return resp, nil }) + + // Assert headers are documented in OpenAPI spec. + headers := api.OpenAPI().Paths["/response-headers"].Get.Responses["204"].Headers + assert.NotNil(t, headers["str"]) + assert.NotNil(t, headers["int"]) + assert.NotNil(t, headers["uint"]) + assert.NotNil(t, headers["float"]) + assert.NotNil(t, headers["bool"]) + assert.NotNil(t, headers["date"]) + assert.NotNil(t, headers["empty"]) + assert.NotNil(t, headers["Content-Type"]) + assert.NotNil(t, headers["X-Custom"]) + + // Assert the nested struct itself is not documented as a header. + assert.Nil(t, headers["Headers"]) }, Method: http.MethodGet, URL: "/response-headers", @@ -1929,6 +1952,51 @@ Content-Type: text/plain assert.Equal(t, "true", resp.Header().Get("Bool")) assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("Date")) assert.Empty(t, resp.Header().Values("Empty")) + assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) + assert.Equal(t, "custom-value", resp.Header().Get("X-Custom")) + }, + }, + { + Name: "response-headers-hidden", + Register: func(t *testing.T, api huma.API) { + type HiddenHeaders struct { + MyHeader string `header:"X-My-Header"` + OtherHeader string `header:"X-Other-Header"` + } + + type Resp struct { + *HiddenHeaders `hidden:"true"` + Body struct { + Message string `json:"message"` + } + } + + huma.Register(api, huma.Operation{ + Method: http.MethodGet, + Path: "/response-headers-hidden", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{ + HiddenHeaders: &HiddenHeaders{ + MyHeader: "my-value", + OtherHeader: "other-value", + }, + } + resp.Body.Message = "Hello" + return resp, nil + }) + + // The headers should NOT appear in the OpenAPI documentation. + headers := api.OpenAPI().Paths["/response-headers-hidden"].Get.Responses["200"].Headers + assert.Nil(t, headers["X-My-Header"], "hidden header should not appear in OpenAPI docs") + assert.Nil(t, headers["X-Other-Header"], "hidden header should not appear in OpenAPI docs") + }, + Method: http.MethodGet, + URL: "/response-headers-hidden", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + // The headers SHOULD still be sent in the response at runtime. + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "my-value", resp.Header().Get("X-My-Header")) + assert.Equal(t, "other-value", resp.Header().Get("X-Other-Header")) }, }, { From 1d8372f5cf387e74124c1dc64c815c54e133d81d Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:48:32 +0000 Subject: [PATCH 05/14] Improve tests --- huma_test.go | 145 +++++++++++++++++++++++---------------------------- 1 file changed, 66 insertions(+), 79 deletions(-) diff --git a/huma_test.go b/huma_test.go index e2495970..3a4da234 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1895,19 +1895,15 @@ Content-Type: text/plain Name: "response-headers", Register: func(t *testing.T, api huma.API) { type NestedHeaders struct { - ContentType string `header:"Content-Type"` - XCustom string `header:"X-Custom"` + NestedWithTag string `header:"X-Nested-With-Tag"` + NestedWithoutTag string // No header tag - should NOT be set as a header. } type Resp struct { - Str string `header:"str"` - Int int `header:"int"` - Uint uint `header:"uint"` - Float float64 `header:"float"` - Bool bool `header:"bool"` - Date time.Time `header:"date"` - Empty string `header:"empty"` - Headers NestedHeaders + WithTag string `header:"X-With-Tag"` + WithoutTag string // No header tag - SHOULD be set as a header using field name. + LastModified time.Time // No header tag - SHOULD be set as a header using field name. + Nested NestedHeaders } huma.Register(api, huma.Operation{ @@ -1915,58 +1911,61 @@ Content-Type: text/plain Path: "/response-headers", }, func(ctx context.Context, input *struct{}) (*Resp, error) { resp := &Resp{} - resp.Str = "str" - resp.Int = 1 - resp.Uint = 2 - resp.Float = 3.45 - resp.Bool = true - resp.Date = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - resp.Headers.ContentType = "application/json" - resp.Headers.XCustom = "custom-value" + resp.WithTag = "with-tag-value" + resp.WithoutTag = "without-tag-value" + resp.LastModified = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) + resp.Nested.NestedWithTag = "nested-with-tag-value" + resp.Nested.NestedWithoutTag = "should-not-be-header" return resp, nil }) - // Assert headers are documented in OpenAPI spec. headers := api.OpenAPI().Paths["/response-headers"].Get.Responses["204"].Headers - assert.NotNil(t, headers["str"]) - assert.NotNil(t, headers["int"]) - assert.NotNil(t, headers["uint"]) - assert.NotNil(t, headers["float"]) - assert.NotNil(t, headers["bool"]) - assert.NotNil(t, headers["date"]) - assert.NotNil(t, headers["empty"]) - assert.NotNil(t, headers["Content-Type"]) - assert.NotNil(t, headers["X-Custom"]) - - // Assert the nested struct itself is not documented as a header. - assert.Nil(t, headers["Headers"]) + + // Surface-level fields should be documented (with or without tag). + assert.NotNil(t, headers["X-With-Tag"]) + assert.NotNil(t, headers["WithoutTag"]) + assert.NotNil(t, headers["LastModified"]) + + // Nested fields with explicit header tag should be documented. + assert.NotNil(t, headers["X-Nested-With-Tag"]) + + // Nested fields without header tag should NOT be documented. + assert.Nil(t, headers["NestedWithoutTag"]) + + // The nested struct itself should NOT be documented as a header. + assert.Nil(t, headers["Nested"]) }, Method: http.MethodGet, URL: "/response-headers", Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { assert.Equal(t, http.StatusNoContent, resp.Code) - assert.Equal(t, "str", resp.Header().Get("Str")) - assert.Equal(t, "1", resp.Header().Get("Int")) - assert.Equal(t, "2", resp.Header().Get("Uint")) - assert.Equal(t, "3.45", resp.Header().Get("Float")) - assert.Equal(t, "true", resp.Header().Get("Bool")) - assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("Date")) - assert.Empty(t, resp.Header().Values("Empty")) - assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) - assert.Equal(t, "custom-value", resp.Header().Get("X-Custom")) + + // Surface-level fields should be set (with or without tag). + assert.Equal(t, "with-tag-value", resp.Header().Get("X-With-Tag")) + assert.Equal(t, "without-tag-value", resp.Header().Get("WithoutTag")) + assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("LastModified")) + + // Nested fields with explicit header tag should be set. + assert.Equal(t, "nested-with-tag-value", resp.Header().Get("X-Nested-With-Tag")) + + // Nested fields without header tag should NOT be set. + assert.Empty(t, resp.Header().Values("NestedWithoutTag")) }, }, { Name: "response-headers-hidden", Register: func(t *testing.T, api huma.API) { type HiddenHeaders struct { - MyHeader string `header:"X-My-Header"` - OtherHeader string `header:"X-Other-Header"` + HiddenWithTag string `header:"X-Hidden-With-Tag"` + HiddenWithoutTag string // No header tag - should NOT be set as a header. } type Resp struct { - *HiddenHeaders `hidden:"true"` - Body struct { + *HiddenHeaders `hidden:"true"` + VisibleWithTag string `header:"X-Visible-With-Tag"` + VisibleWithoutTag string // No header tag - SHOULD be set as a header using field name. + LastModified time.Time // No header tag - SHOULD be set as a header using field name. + Body struct { Message string `json:"message"` } } @@ -1977,55 +1976,43 @@ Content-Type: text/plain }, func(ctx context.Context, input *struct{}) (*Resp, error) { resp := &Resp{ HiddenHeaders: &HiddenHeaders{ - MyHeader: "my-value", - OtherHeader: "other-value", + HiddenWithTag: "hidden-with-tag-value", + HiddenWithoutTag: "should-not-be-header", }, } + resp.VisibleWithTag = "visible-with-tag-value" + resp.VisibleWithoutTag = "visible-without-tag-value" + resp.LastModified = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) resp.Body.Message = "Hello" return resp, nil }) - // The headers should NOT appear in the OpenAPI documentation. headers := api.OpenAPI().Paths["/response-headers-hidden"].Get.Responses["200"].Headers - assert.Nil(t, headers["X-My-Header"], "hidden header should not appear in OpenAPI docs") - assert.Nil(t, headers["X-Other-Header"], "hidden header should not appear in OpenAPI docs") + + // Hidden headers should NOT appear in OpenAPI documentation. + assert.Nil(t, headers["X-Hidden-With-Tag"], "hidden header with tag should not appear in OpenAPI docs") + assert.Nil(t, headers["HiddenWithoutTag"], "hidden header without tag should not appear in OpenAPI docs") + + // Visible surface-level fields should appear in OpenAPI documentation. + assert.NotNil(t, headers["X-Visible-With-Tag"], "visible header with tag should appear in OpenAPI docs") + assert.NotNil(t, headers["VisibleWithoutTag"], "visible header without tag should appear in OpenAPI docs") + assert.NotNil(t, headers["LastModified"], "visible time header should appear in OpenAPI docs") }, Method: http.MethodGet, URL: "/response-headers-hidden", Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { - // The headers SHOULD still be sent in the response at runtime. assert.Equal(t, http.StatusOK, resp.Code) - assert.Equal(t, "my-value", resp.Header().Get("X-My-Header")) - assert.Equal(t, "other-value", resp.Header().Get("X-Other-Header")) - }, - }, - { - Name: "response-cookie", - Register: func(t *testing.T, api huma.API) { - type Resp struct { - SetCookie http.Cookie `header:"Set-Cookie"` - } - huma.Register(api, huma.Operation{ - Method: http.MethodGet, - Path: "/response-cookie", - }, func(ctx context.Context, input *struct{}) (*Resp, error) { - resp := &Resp{} - resp.SetCookie = http.Cookie{ - Name: "foo", - Value: "bar", - } - return resp, nil - }) + // Hidden headers with explicit tag SHOULD still be sent at runtime. + assert.Equal(t, "hidden-with-tag-value", resp.Header().Get("X-Hidden-With-Tag")) - // `http.Cookie` should be treated as a string. - assert.Equal(t, "string", api.OpenAPI().Paths["/response-cookie"].Get.Responses["204"].Headers["Set-Cookie"].Schema.Type) - }, - Method: http.MethodGet, - URL: "/response-cookie", - Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { - assert.Equal(t, http.StatusNoContent, resp.Code) - assert.Equal(t, "foo=bar", resp.Header().Get("Set-Cookie")) + // Hidden headers without tag should NOT be set. + assert.Empty(t, resp.Header().Values("HiddenWithoutTag")) + + // Visible surface-level fields should be sent at runtime. + assert.Equal(t, "visible-with-tag-value", resp.Header().Get("X-Visible-With-Tag")) + assert.Equal(t, "visible-without-tag-value", resp.Header().Get("VisibleWithoutTag")) + assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("LastModified")) }, }, { From 52f58698f765caecc7dd88082e3bb38644dffccf Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:50:54 +0000 Subject: [PATCH 06/14] Add back missing tests for header types --- huma_test.go | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/huma_test.go b/huma_test.go index 3a4da234..5c1691f5 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1900,7 +1900,13 @@ Content-Type: text/plain } type Resp struct { - WithTag string `header:"X-With-Tag"` + Str string `header:"str"` + Int int `header:"int"` + Uint uint `header:"uint"` + Float float64 `header:"float"` + Bool bool `header:"bool"` + Date time.Time `header:"date"` + Empty string `header:"empty"` WithoutTag string // No header tag - SHOULD be set as a header using field name. LastModified time.Time // No header tag - SHOULD be set as a header using field name. Nested NestedHeaders @@ -1911,9 +1917,14 @@ Content-Type: text/plain Path: "/response-headers", }, func(ctx context.Context, input *struct{}) (*Resp, error) { resp := &Resp{} - resp.WithTag = "with-tag-value" + resp.Str = "str" + resp.Int = 1 + resp.Uint = 2 + resp.Float = 3.45 + resp.Bool = true + resp.Date = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) resp.WithoutTag = "without-tag-value" - resp.LastModified = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) + resp.LastModified = time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC) resp.Nested.NestedWithTag = "nested-with-tag-value" resp.Nested.NestedWithoutTag = "should-not-be-header" return resp, nil @@ -1921,8 +1932,16 @@ Content-Type: text/plain headers := api.OpenAPI().Paths["/response-headers"].Get.Responses["204"].Headers - // Surface-level fields should be documented (with or without tag). - assert.NotNil(t, headers["X-With-Tag"]) + // Surface-level fields with explicit tags should be documented. + assert.NotNil(t, headers["str"]) + assert.NotNil(t, headers["int"]) + assert.NotNil(t, headers["uint"]) + assert.NotNil(t, headers["float"]) + assert.NotNil(t, headers["bool"]) + assert.NotNil(t, headers["date"]) + assert.NotNil(t, headers["empty"]) + + // Surface-level fields without tags should be documented using field name. assert.NotNil(t, headers["WithoutTag"]) assert.NotNil(t, headers["LastModified"]) @@ -1940,10 +1959,18 @@ Content-Type: text/plain Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { assert.Equal(t, http.StatusNoContent, resp.Code) - // Surface-level fields should be set (with or without tag). - assert.Equal(t, "with-tag-value", resp.Header().Get("X-With-Tag")) + // Surface-level fields with explicit tags should be set. + assert.Equal(t, "str", resp.Header().Get("Str")) + assert.Equal(t, "1", resp.Header().Get("Int")) + assert.Equal(t, "2", resp.Header().Get("Uint")) + assert.Equal(t, "3.45", resp.Header().Get("Float")) + assert.Equal(t, "true", resp.Header().Get("Bool")) + assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("Date")) + assert.Empty(t, resp.Header().Values("Empty")) + + // Surface-level fields without tags should be set using field name. assert.Equal(t, "without-tag-value", resp.Header().Get("WithoutTag")) - assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("LastModified")) + assert.Equal(t, "Thu, 15 Jun 2023 10:30:00 GMT", resp.Header().Get("LastModified")) // Nested fields with explicit header tag should be set. assert.Equal(t, "nested-with-tag-value", resp.Header().Get("X-Nested-With-Tag")) @@ -1982,7 +2009,7 @@ Content-Type: text/plain } resp.VisibleWithTag = "visible-with-tag-value" resp.VisibleWithoutTag = "visible-without-tag-value" - resp.LastModified = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) + resp.LastModified = time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC) resp.Body.Message = "Hello" return resp, nil }) @@ -2012,7 +2039,7 @@ Content-Type: text/plain // Visible surface-level fields should be sent at runtime. assert.Equal(t, "visible-with-tag-value", resp.Header().Get("X-Visible-With-Tag")) assert.Equal(t, "visible-without-tag-value", resp.Header().Get("VisibleWithoutTag")) - assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("LastModified")) + assert.Equal(t, "Thu, 15 Jun 2023 10:30:00 GMT", resp.Header().Get("LastModified")) }, }, { From caacb8466d25b50f39430e625f178cab7d34ac6f Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:54:57 +0000 Subject: [PATCH 07/14] Cover missing test lines --- huma_test.go | 66 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/huma_test.go b/huma_test.go index 5c1691f5..093fa609 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1899,6 +1899,11 @@ Content-Type: text/plain NestedWithoutTag string // No header tag - should NOT be set as a header. } + type NestedPtrHeaders struct { + NestedPtrWithTag string `header:"X-Nested-Ptr-With-Tag"` + NestedPtrWithoutTag string // No header tag - should NOT be set as a header. + } + type Resp struct { Str string `header:"str"` Int int `header:"int"` @@ -1907,27 +1912,36 @@ Content-Type: text/plain Bool bool `header:"bool"` Date time.Time `header:"date"` Empty string `header:"empty"` + CustomTime time.Time `header:"custom-time" timeFormat:"2006-01-02"` WithoutTag string // No header tag - SHOULD be set as a header using field name. LastModified time.Time // No header tag - SHOULD be set as a header using field name. Nested NestedHeaders + NestedPtr *NestedPtrHeaders // Pointer to nested struct. } huma.Register(api, huma.Operation{ Method: http.MethodGet, Path: "/response-headers", }, func(ctx context.Context, input *struct{}) (*Resp, error) { - resp := &Resp{} - resp.Str = "str" - resp.Int = 1 - resp.Uint = 2 - resp.Float = 3.45 - resp.Bool = true - resp.Date = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - resp.WithoutTag = "without-tag-value" - resp.LastModified = time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC) - resp.Nested.NestedWithTag = "nested-with-tag-value" - resp.Nested.NestedWithoutTag = "should-not-be-header" - return resp, nil + return &Resp{ + Str: "str", + Int: 1, + Uint: 2, + Float: 3.45, + Bool: true, + Date: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + CustomTime: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + WithoutTag: "without-tag-value", + LastModified: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + Nested: NestedHeaders{ + NestedWithTag: "nested-with-tag-value", + NestedWithoutTag: "should-not-be-header", + }, + NestedPtr: &NestedPtrHeaders{ + NestedPtrWithTag: "nested-ptr-with-tag-value", + NestedPtrWithoutTag: "should-not-be-header-ptr", + }, + }, nil }) headers := api.OpenAPI().Paths["/response-headers"].Get.Responses["204"].Headers @@ -1940,6 +1954,7 @@ Content-Type: text/plain assert.NotNil(t, headers["bool"]) assert.NotNil(t, headers["date"]) assert.NotNil(t, headers["empty"]) + assert.NotNil(t, headers["custom-time"]) // Surface-level fields without tags should be documented using field name. assert.NotNil(t, headers["WithoutTag"]) @@ -1948,11 +1963,16 @@ Content-Type: text/plain // Nested fields with explicit header tag should be documented. assert.NotNil(t, headers["X-Nested-With-Tag"]) + // Pointer nested fields with explicit header tag should be documented. + assert.NotNil(t, headers["X-Nested-Ptr-With-Tag"]) + // Nested fields without header tag should NOT be documented. assert.Nil(t, headers["NestedWithoutTag"]) + assert.Nil(t, headers["NestedPtrWithoutTag"]) // The nested struct itself should NOT be documented as a header. assert.Nil(t, headers["Nested"]) + assert.Nil(t, headers["NestedPtr"]) }, Method: http.MethodGet, URL: "/response-headers", @@ -1967,6 +1987,7 @@ Content-Type: text/plain assert.Equal(t, "true", resp.Header().Get("Bool")) assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("Date")) assert.Empty(t, resp.Header().Values("Empty")) + assert.Equal(t, "2023-06-15", resp.Header().Get("Custom-Time")) // Surface-level fields without tags should be set using field name. assert.Equal(t, "without-tag-value", resp.Header().Get("WithoutTag")) @@ -1975,8 +1996,12 @@ Content-Type: text/plain // Nested fields with explicit header tag should be set. assert.Equal(t, "nested-with-tag-value", resp.Header().Get("X-Nested-With-Tag")) + // Pointer nested fields with explicit header tag should be set. + assert.Equal(t, "nested-ptr-with-tag-value", resp.Header().Get("X-Nested-Ptr-With-Tag")) + // Nested fields without header tag should NOT be set. assert.Empty(t, resp.Header().Values("NestedWithoutTag")) + assert.Empty(t, resp.Header().Values("NestedPtrWithoutTag")) }, }, { @@ -2001,17 +2026,20 @@ Content-Type: text/plain Method: http.MethodGet, Path: "/response-headers-hidden", }, func(ctx context.Context, input *struct{}) (*Resp, error) { - resp := &Resp{ + return &Resp{ HiddenHeaders: &HiddenHeaders{ HiddenWithTag: "hidden-with-tag-value", HiddenWithoutTag: "should-not-be-header", }, - } - resp.VisibleWithTag = "visible-with-tag-value" - resp.VisibleWithoutTag = "visible-without-tag-value" - resp.LastModified = time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC) - resp.Body.Message = "Hello" - return resp, nil + VisibleWithTag: "visible-with-tag-value", + VisibleWithoutTag: "visible-without-tag-value", + LastModified: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + Body: struct { + Message string `json:"message"` + }{ + Message: "Hello", + }, + }, nil }) headers := api.OpenAPI().Paths["/response-headers-hidden"].Get.Responses["200"].Headers From 16f0fe5daf83263500b46da41b4fcf2642bcb881 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:34:31 +0000 Subject: [PATCH 08/14] Support recursive inputs --- huma.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/huma.go b/huma.go index 6ae4f08b..b8dcc800 100644 --- a/huma.go +++ b/huma.go @@ -210,7 +210,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p } return pfi - }, false, "Body") + }, true, "Body") } func findResolvers(resolverType, t reflect.Type) *findResult[bool] { @@ -1422,6 +1422,12 @@ func processOutputType(outputType reflect.Type, op *Operation, registry Registry currentType := outputType for _, idx := range entry.Path { currentType = deref(currentType) + + // Skip slice and map types to get to the element type. + for currentType.Kind() == reflect.Slice || currentType.Kind() == reflect.Map { + currentType = deref(currentType.Elem()) + } + field := currentType.Field(idx) if boolTag(field, "hidden", false) { hidden = true From da5c6d41fcae54af7210aececdc3cd3d35863a67 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:51:23 +0000 Subject: [PATCH 09/14] Update tests --- huma_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/huma_test.go b/huma_test.go index 093fa609..0f8738c6 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1904,6 +1904,18 @@ Content-Type: text/plain NestedPtrWithoutTag string // No header tag - should NOT be set as a header. } + // NEW: slice element types must use unique header names so they don't + // overwrite the non-slice headers at runtime. + type NestedHeadersSliceElem struct { + NestedWithTag string `header:"X-Nested-With-Tag-Slice"` + NestedWithoutTag string + } + + type NestedPtrHeadersSliceElem struct { + NestedPtrWithTag string `header:"X-Nested-Ptr-With-Tag-Slice"` + NestedPtrWithoutTag string + } + type Resp struct { Str string `header:"str"` Int int `header:"int"` @@ -1917,6 +1929,10 @@ Content-Type: text/plain LastModified time.Time // No header tag - SHOULD be set as a header using field name. Nested NestedHeaders NestedPtr *NestedPtrHeaders // Pointer to nested struct. + + // NEW: slice paths to cover slice/map element-type unwrapping. + NestedSlice []NestedHeadersSliceElem + NestedPtrSlice []*NestedPtrHeadersSliceElem } huma.Register(api, huma.Operation{ @@ -1941,6 +1957,20 @@ Content-Type: text/plain NestedPtrWithTag: "nested-ptr-with-tag-value", NestedPtrWithoutTag: "should-not-be-header-ptr", }, + + // NEW: one element each for deterministic runtime assertions + NestedSlice: []NestedHeadersSliceElem{ + { + NestedWithTag: "nested-slice-with-tag-value", + NestedWithoutTag: "should-not-be-header-slice", + }, + }, + NestedPtrSlice: []*NestedPtrHeadersSliceElem{ + { + NestedPtrWithTag: "nested-ptr-slice-with-tag-value", + NestedPtrWithoutTag: "should-not-be-header-ptr-slice", + }, + }, }, nil }) @@ -1973,6 +2003,14 @@ Content-Type: text/plain // The nested struct itself should NOT be documented as a header. assert.Nil(t, headers["Nested"]) assert.Nil(t, headers["NestedPtr"]) + + // NEW: Slice element fields with explicit header tags should be documented. + assert.NotNil(t, headers["X-Nested-With-Tag-Slice"]) + assert.NotNil(t, headers["X-Nested-Ptr-With-Tag-Slice"]) + + // NEW: Slice element fields without header tags should NOT be documented. + assert.Nil(t, headers["NestedWithoutTag"]) + assert.Nil(t, headers["NestedPtrWithoutTag"]) }, Method: http.MethodGet, URL: "/response-headers", @@ -2002,6 +2040,10 @@ Content-Type: text/plain // Nested fields without header tag should NOT be set. assert.Empty(t, resp.Header().Values("NestedWithoutTag")) assert.Empty(t, resp.Header().Values("NestedPtrWithoutTag")) + + // NEW: slice element fields should be set (unique header names). + assert.Equal(t, "nested-slice-with-tag-value", resp.Header().Get("X-Nested-With-Tag-Slice")) + assert.Equal(t, "nested-ptr-slice-with-tag-value", resp.Header().Get("X-Nested-Ptr-With-Tag-Slice")) }, }, { @@ -2012,8 +2054,18 @@ Content-Type: text/plain HiddenWithoutTag string // No header tag - should NOT be set as a header. } + // NEW: slice element type w/ unique header name so assertions remain stable. + type HiddenHeadersSliceElem struct { + HiddenWithTag string `header:"X-Hidden-With-Tag-Slice"` + HiddenWithoutTag string + } + type Resp struct { - *HiddenHeaders `hidden:"true"` + *HiddenHeaders `hidden:"true"` + + // NEW: hidden slice field to exercise hidden-walk across slice -> elem. + HiddenSlice []HiddenHeadersSliceElem `hidden:"true"` + VisibleWithTag string `header:"X-Visible-With-Tag"` VisibleWithoutTag string // No header tag - SHOULD be set as a header using field name. LastModified time.Time // No header tag - SHOULD be set as a header using field name. @@ -2031,6 +2083,12 @@ Content-Type: text/plain HiddenWithTag: "hidden-with-tag-value", HiddenWithoutTag: "should-not-be-header", }, + HiddenSlice: []HiddenHeadersSliceElem{ + { + HiddenWithTag: "hidden-slice-with-tag-value", + HiddenWithoutTag: "should-not-be-header-slice", + }, + }, VisibleWithTag: "visible-with-tag-value", VisibleWithoutTag: "visible-without-tag-value", LastModified: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), @@ -2048,6 +2106,9 @@ Content-Type: text/plain assert.Nil(t, headers["X-Hidden-With-Tag"], "hidden header with tag should not appear in OpenAPI docs") assert.Nil(t, headers["HiddenWithoutTag"], "hidden header without tag should not appear in OpenAPI docs") + // NEW: hidden slice element header should NOT appear in OpenAPI docs. + assert.Nil(t, headers["X-Hidden-With-Tag-Slice"], "hidden slice header with tag should not appear in OpenAPI docs") + // Visible surface-level fields should appear in OpenAPI documentation. assert.NotNil(t, headers["X-Visible-With-Tag"], "visible header with tag should appear in OpenAPI docs") assert.NotNil(t, headers["VisibleWithoutTag"], "visible header without tag should appear in OpenAPI docs") @@ -2064,6 +2125,9 @@ Content-Type: text/plain // Hidden headers without tag should NOT be set. assert.Empty(t, resp.Header().Values("HiddenWithoutTag")) + // NEW: hidden slice element header with explicit tag SHOULD still be sent at runtime. + assert.Equal(t, "hidden-slice-with-tag-value", resp.Header().Get("X-Hidden-With-Tag-Slice")) + // Visible surface-level fields should be sent at runtime. assert.Equal(t, "visible-with-tag-value", resp.Header().Get("X-Visible-With-Tag")) assert.Equal(t, "visible-without-tag-value", resp.Header().Get("VisibleWithoutTag")) From e79cf0fbb0c009702472b12446faac249e88e8b0 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:12:28 +0000 Subject: [PATCH 10/14] Restore missing test --- huma_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/huma_test.go b/huma_test.go index 0f8738c6..1b6ea101 100644 --- a/huma_test.go +++ b/huma_test.go @@ -2134,6 +2134,35 @@ Content-Type: text/plain assert.Equal(t, "Thu, 15 Jun 2023 10:30:00 GMT", resp.Header().Get("LastModified")) }, }, + { + Name: "response-cookie", + Register: func(t *testing.T, api huma.API) { + type Resp struct { + SetCookie http.Cookie `header:"Set-Cookie"` + } + + huma.Register(api, huma.Operation{ + Method: http.MethodGet, + Path: "/response-cookie", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{} + resp.SetCookie = http.Cookie{ + Name: "foo", + Value: "bar", + } + return resp, nil + }) + + // `http.Cookie` should be treated as a string. + assert.Equal(t, "string", api.OpenAPI().Paths["/response-cookie"].Get.Responses["204"].Headers["Set-Cookie"].Schema.Type) + }, + Method: http.MethodGet, + URL: "/response-cookie", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Equal(t, "foo=bar", resp.Header().Get("Set-Cookie")) + }, + }, { Name: "response-cookies", Register: func(t *testing.T, api huma.API) { From 2d5d9b31a8b3d98369be40710589e146292527d8 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:26:46 +0000 Subject: [PATCH 11/14] Use new `baseType` func --- huma.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/huma.go b/huma.go index a87491d1..9fe23f14 100644 --- a/huma.go +++ b/huma.go @@ -257,11 +257,7 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { return nil } - fieldType := sf.Type - if fieldType.Kind() == reflect.Pointer { - fieldType = fieldType.Elem() - } - + fieldType := baseType(sf.Type) if fieldType.Kind() == reflect.Struct && fieldType != timeType { return nil } From 5be6d8ecce48c53ee8fcd122441736fa4615d9d7 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:17:56 +0100 Subject: [PATCH 12/14] Preserve embedded-field header promotion findHeaders' depth check (len(i) > 1) dropped headers for fields promoted via embedded structs that had no explicit header tag, a silent behavior change from prior releases and inconsistent with Go field promotion (a promoted field stopped behaving like a top-level field). Replace the depth check with isPromotedField, which auto-names a header from the field name only for surface-level fields: literal top-level fields and fields reachable purely through embedded structs. Named nested struct fields still require an explicit header tag (the new feature). Add a regression test covering all three cases. --- huma.go | 30 +++++++++++++++++++++++++--- huma_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/huma.go b/huma.go index 9fe23f14..4547d62d 100644 --- a/huma.go +++ b/huma.go @@ -242,6 +242,27 @@ type headerInfo struct { TimeFormat string } +// isPromotedField reports whether the field identified by the index path is +// reachable from the root type purely through embedded (anonymous) structs, so +// Go promotes it as if it were declared at the top level. Such fields keep the +// legacy behavior of being auto-named as a header from the field name; named +// (non-embedded) nested struct fields must opt in with an explicit `header` +// tag instead. +func isPromotedField(root reflect.Type, path []int) bool { + t := baseType(root) + for _, idx := range path[:len(path)-1] { + if t.Kind() != reflect.Struct || idx >= t.NumField() { + return false + } + f := t.Field(idx) + if !f.Anonymous { + return false + } + t = baseType(f.Type) + } + return true +} + func findHeaders(t reflect.Type) *findResult[*headerInfo] { return findInType(t, nil, func(sf reflect.StructField, i []int) *headerInfo { // Ignore embedded fields. @@ -251,12 +272,15 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { header := sf.Tag.Get("header") if header == "" { - // Only use field name as header if this is a top-level field (depth 1) - // and it's not a struct (which we recurse into). - if len(i) > 1 { + // Only auto-name a header from the field name for "surface level" + // fields: literal top-level fields and fields promoted via embedded + // structs. Named nested struct fields must use an explicit `header` + // tag, which is handled by recursion above. + if !isPromotedField(t, i) { return nil } + // Never name a header after a struct we recurse into. fieldType := baseType(sf.Type) if fieldType.Kind() == reflect.Struct && fieldType != timeType { return nil diff --git a/huma_test.go b/huma_test.go index 0094a1a1..cfe1e4f9 100644 --- a/huma_test.go +++ b/huma_test.go @@ -2051,13 +2051,13 @@ Content-Type: text/plain Register: func(t *testing.T, api huma.API) { type HiddenHeaders struct { HiddenWithTag string `header:"X-Hidden-With-Tag"` - HiddenWithoutTag string // No header tag - should NOT be set as a header. + HiddenWithoutTag string // No header tag - promoted via embedding, so set using field name. } // Slice element type w/ unique header name so assertions remain stable. type HiddenHeadersSliceElem struct { HiddenWithTag string `header:"X-Hidden-With-Tag-Slice"` - HiddenWithoutTag string // No header tag - should be set as header using field name. + HiddenWithoutTag string // No header tag - nested (not promoted), so NOT set as a header. } type Resp struct { @@ -2081,7 +2081,7 @@ Content-Type: text/plain return &Resp{ HiddenHeaders: &HiddenHeaders{ HiddenWithTag: "hidden-with-tag-value", - HiddenWithoutTag: "should-not-be-header", + HiddenWithoutTag: "should-be-header", }, HiddenSlice: []HiddenHeadersSliceElem{ { @@ -2122,8 +2122,10 @@ Content-Type: text/plain // Hidden headers with explicit tag SHOULD still be sent at runtime. assert.Equal(t, "hidden-with-tag-value", resp.Header().Get("X-Hidden-With-Tag")) - // Hidden headers without tag should NOT be set. - assert.Empty(t, resp.Header().Values("HiddenWithoutTag")) + // Hidden headers without tag are promoted via embedding, so they + // are still sent at runtime using the field name (but stay out of + // the OpenAPI docs above because the embedded struct is hidden). + assert.Equal(t, "should-be-header", resp.Header().Get("HiddenWithoutTag")) // Hidden slice element header with explicit tag SHOULD still be sent at runtime. assert.Equal(t, "hidden-slice-with-tag-value", resp.Header().Get("X-Hidden-With-Tag-Slice")) @@ -3516,3 +3518,47 @@ func TestGenerateFuncsPanicWithDescriptiveMessage(t *testing.T) { }) } + +// TestResponseHeaderPromotion locks in the rule that a response field without +// an explicit `header` tag is auto-named as a header from its field name only +// when it is "surface level": a literal top-level field or a field promoted via +// an embedded struct. Genuinely nested (named) struct fields must opt in with an +// explicit `header` tag. This mirrors Go's field-promotion semantics so embedded +// fields behave identically to top-level fields. +func TestResponseHeaderPromotion(t *testing.T) { + _, api := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0")) + + type Promoted struct { + PromotedNoTag string // promoted via embedding -> header "PromotedNoTag" + } + type Nested struct { + NestedNoTag string // named nested, no tag -> NOT a header + NestedTagged string `header:"X-Nested-Tagged"` + } + type Resp struct { + Promoted // embedded + TopNoTag string // top-level -> header "TopNoTag" + Inner Nested // named nested struct + Body struct{ OK bool } + } + + huma.Get(api, "/promotion", func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{TopNoTag: "top"} + resp.PromotedNoTag = "promoted" + resp.Inner = Nested{NestedNoTag: "nested", NestedTagged: "tagged"} + return resp, nil + }) + + headers := api.OpenAPI().Paths["/promotion"].Get.Responses["200"].Headers + assert.NotNil(t, headers["TopNoTag"], "top-level untagged field should be a header") + assert.NotNil(t, headers["PromotedNoTag"], "embedded (promoted) untagged field should be a header") + assert.NotNil(t, headers["X-Nested-Tagged"], "named nested field with tag should be a header") + assert.Nil(t, headers["NestedNoTag"], "named nested field without tag should NOT be a header") + assert.Nil(t, headers["Inner"], "a struct we recurse into should not be a header named after itself") + + resp := api.Get("/promotion") + assert.Equal(t, "top", resp.Header().Get("TopNoTag")) + assert.Equal(t, "promoted", resp.Header().Get("PromotedNoTag")) + assert.Equal(t, "tagged", resp.Header().Get("X-Nested-Tagged")) + assert.Empty(t, resp.Header().Values("NestedNoTag")) +} From 21f1c4b6bda1adad18942c891f6870ae4bfcba9a Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:09:28 +0100 Subject: [PATCH 13/14] Populate params on pointer-nested input structs With params recursion enabled, a param declared on a pointer-nested input struct (e.g. Filters *Filters with a query tag) was documented in the OpenAPI spec but never populated at runtime: the intermediate pointer is nil and the traversal skipped it, so the advertised param could never be received. Add findResult.EveryAlloc, which allocates nil pointers along the path so nested fields can be set. A pointer it allocates is reset to nil when no value below it was set, so an absent optional group stays nil instead of becoming an empty struct. Use it for both the main and multipart input param population paths. Add tests covering value- and pointer-nested population and the absent-group-stays-nil case. --- huma.go | 83 +++++++++++++++++++++++++++++++++++++++++++++------- huma_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/huma.go b/huma.go index 4547d62d..94ca4d85 100644 --- a/huma.go +++ b/huma.go @@ -345,6 +345,65 @@ func (r *findResult[T]) Every(v reflect.Value, f func(reflect.Value, T)) { } } +// everyAlloc behaves like every, but allocates nil pointers encountered along +// the path so nested input fields can be populated. A pointer this call +// allocated is reset to nil when nothing below it was set, so an optional nested +// group that received no values stays nil rather than becoming an empty struct. +// The callback reports whether it set a value; everyAlloc returns whether +// anything below the current node was set. +func (r *findResult[T]) everyAlloc(current reflect.Value, path []int, v T, f func(reflect.Value, T) bool) bool { + if len(path) == 0 { + return f(current, v) + } + + var allocated reflect.Value + if current.Kind() == reflect.Pointer { + if current.IsNil() { + if !current.CanSet() { + return false + } + current.Set(reflect.New(current.Type().Elem())) + allocated = current + } + current = current.Elem() + } + + if current.Kind() == reflect.Invalid { + return false + } + + set := false + switch current.Kind() { + case reflect.Struct: + set = r.everyAlloc(current.Field(path[0]), path[1:], v, f) + case reflect.Slice: + for j := 0; j < current.Len(); j++ { + if r.everyAlloc(current.Index(j), path, v, f) { + set = true + } + } + case reflect.Map: + for _, k := range current.MapKeys() { + if r.everyAlloc(current.MapIndex(k), path, v, f) { + set = true + } + } + default: + panic("unsupported") + } + + if allocated.IsValid() && !set { + allocated.Set(reflect.Zero(allocated.Type())) + } + return set +} + +func (r *findResult[T]) EveryAlloc(v reflect.Value, f func(reflect.Value, T) bool) { + for i := range r.Paths { + r.everyAlloc(v, r.Paths[i].Path, r.Paths[i].Value, f) + } +} + func jsonName(field reflect.StructField) string { name := strings.ToLower(field.Name) if jsonName := field.Tag.Get("json"); jsonName != "" { @@ -724,10 +783,10 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) var cookies map[string]*http.Cookie v := reflect.ValueOf(&input).Elem() - inputParams.Every(v, func(f reflect.Value, p *paramFieldInfo) { + inputParams.EveryAlloc(v, func(f reflect.Value, p *paramFieldInfo) bool { f = reflect.Indirect(f) if f.Kind() == reflect.Invalid { - return + return false } pb.Reset() @@ -746,7 +805,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) // Special case: http.Cookie type, meaning we want the entire parsed // cookie struct, not just the value. f.Set(reflect.ValueOf(c).Elem()) - return + return true } } @@ -769,7 +828,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if !op.SkipValidateParams && p.Required { res.Add(pb, "", "required "+p.Loc+" parameter is missing") } - return + return false } pv = setDeepObjectValue(pb, res, receiver, value) } else { @@ -780,13 +839,13 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) // Path params are always required. res.Add(pb, "", "required "+p.Loc+" parameter is missing") } - return + return false } var err error pv, err = parseInto(ctx, receiver, value, nil, *p) if err != nil { res.Add(pb, value, err.Error()) - return + return false } } @@ -797,6 +856,8 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if !op.SkipValidateParams { Validate(oapi.Components.Schemas, p.Schema, pb, ModeWriteToServer, pv, res) } + + return true }) // Read input body if defined. @@ -825,10 +886,10 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) rawBodyInputParams := findParams(oapi.Components.Schemas, &op, rawBodyDataT) formValueParser = func(val reflect.Value) { - rawBodyInputParams.Every(val, func(f reflect.Value, p *paramFieldInfo) { + rawBodyInputParams.EveryAlloc(val, func(f reflect.Value, p *paramFieldInfo) bool { f = reflect.Indirect(f) if f.Kind() == reflect.Invalid { - return + return false } pb.Reset() @@ -841,14 +902,14 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if !op.SkipValidateParams && p.Required && !isFile { res.Add(pb, "", "required "+p.Loc+" parameter is missing") } - return + return false } // Validation should fail if multiple values are // provided but the type of f is not a slice. if len(value) > 1 && f.Type().Kind() != reflect.Slice { res.Add(pb, value, "expected at most one value, but received multiple values") - return + return false } pv, err := parseInto(ctx, f, value[0], value, *p) if err != nil { @@ -858,6 +919,8 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if !op.SkipValidateParams { Validate(oapi.Components.Schemas, p.Schema, pb, ModeWriteToServer, pv, res) } + + return true }) } } diff --git a/huma_test.go b/huma_test.go index cfe1e4f9..c652df7c 100644 --- a/huma_test.go +++ b/huma_test.go @@ -3562,3 +3562,71 @@ func TestResponseHeaderPromotion(t *testing.T) { assert.Equal(t, "tagged", resp.Header().Get("X-Nested-Tagged")) assert.Empty(t, resp.Header().Values("NestedNoTag")) } + +// TestNestedInputParamPopulation verifies that params declared on nested input +// structs are populated at request time, including pointer-nested structs (the +// pointer is allocated as needed). An optional pointer group that receives no +// values is left nil rather than being allocated as an empty struct. +func TestNestedInputParamPopulation(t *testing.T) { + _, api := humatest.New(t, huma.DefaultConfig("Test", "1.0.0")) + + type ValueGroup struct { + Foo string `query:"foo"` + Bar string `header:"X-Bar"` + } + type PtrGroup struct { + Baz string `query:"baz"` + } + type Input struct { + Top string `query:"top"` + Value ValueGroup + Ptr *PtrGroup + } + + var got Input + huma.Register(api, huma.Operation{ + OperationID: "nested", + Method: http.MethodGet, + Path: "/nested", + }, func(ctx context.Context, in *Input) (*struct{}, error) { + got = *in + return &struct{}{}, nil + }) + + // Nested params (value and pointer) should be documented. + params := api.OpenAPI().Paths["/nested"].Get.Parameters + names := map[string]bool{} + for _, p := range params { + names[p.In+":"+p.Name] = true + } + assert.True(t, names["query:foo"], "value-nested query param should be documented") + assert.True(t, names["header:X-Bar"], "value-nested header param should be documented") + assert.True(t, names["query:baz"], "pointer-nested query param should be documented") + + t.Run("populated", func(t *testing.T) { + got = Input{} + req := httptest.NewRequest(http.MethodGet, "/nested?top=T&foo=F&baz=B", nil) + req.Header.Set("X-Bar", "BAR") + w := httptest.NewRecorder() + api.Adapter().ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "T", got.Top) + assert.Equal(t, "F", got.Value.Foo) + assert.Equal(t, "BAR", got.Value.Bar) + if assert.NotNil(t, got.Ptr, "pointer-nested group should be allocated and populated") { + assert.Equal(t, "B", got.Ptr.Baz) + } + }) + + t.Run("absent pointer group stays nil", func(t *testing.T) { + got = Input{} + req := httptest.NewRequest(http.MethodGet, "/nested?top=T", nil) + w := httptest.NewRecorder() + api.Adapter().ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "T", got.Top) + assert.Nil(t, got.Ptr, "absent optional pointer group should remain nil") + }) +} From f7f7e18d323197dd8180fd010a3ad20295219456 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:54:06 +0100 Subject: [PATCH 14/14] Cover everyAlloc slice/map/panic branches The public input path always populates a freshly-zeroed input, so slice and map elements along a param path are empty and the slice/map branches in everyAlloc never iterate at runtime, leaving them uncovered. Add a white-box test that drives everyAlloc directly over populated slices/maps, an allocated-and-kept pointer, an allocated-then-rolled-back pointer, a reused pointer, and an unsupported kind. --- huma_internal_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 huma_internal_test.go diff --git a/huma_internal_test.go b/huma_internal_test.go new file mode 100644 index 00000000..1ee1f873 --- /dev/null +++ b/huma_internal_test.go @@ -0,0 +1,111 @@ +package huma + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestEveryAlloc exercises findResult.everyAlloc directly. The public input +// path always operates on a freshly-zeroed input value, so slice/map elements +// along a param path are empty and never iterated at runtime. These branches +// exist for parity with every and to gracefully handle param paths that pass +// through slices/maps, so they're covered here with populated containers. +func TestEveryAlloc(t *testing.T) { + type leaf struct { + Val string + } + + t.Run("allocates pointer and keeps it when a value is set", func(t *testing.T) { + type container struct { + Ptr *leaf + } + var c container + r := &findResult[int]{} + set := r.everyAlloc(reflect.ValueOf(&c).Elem(), []int{0, 0}, 1, func(v reflect.Value, _ int) bool { + v.SetString("set") + return true + }) + assert.True(t, set) + if assert.NotNil(t, c.Ptr) { + assert.Equal(t, "set", c.Ptr.Val) + } + }) + + t.Run("rolls an allocated pointer back to nil when nothing is set", func(t *testing.T) { + type container struct { + Ptr *leaf + } + var c container + r := &findResult[int]{} + set := r.everyAlloc(reflect.ValueOf(&c).Elem(), []int{0, 0}, 1, func(v reflect.Value, _ int) bool { + return false + }) + assert.False(t, set) + assert.Nil(t, c.Ptr, "pointer the call allocated should be reset to nil") + }) + + t.Run("reuses an already-allocated pointer without rolling it back", func(t *testing.T) { + type container struct { + Ptr *leaf + } + c := container{Ptr: &leaf{Val: "existing"}} + r := &findResult[int]{} + set := r.everyAlloc(reflect.ValueOf(&c).Elem(), []int{0, 0}, 1, func(v reflect.Value, _ int) bool { + return false + }) + assert.False(t, set) + // We did not allocate the pointer, so it must be left untouched. + if assert.NotNil(t, c.Ptr) { + assert.Equal(t, "existing", c.Ptr.Val) + } + }) + + t.Run("recurses into slice elements", func(t *testing.T) { + type container struct { + Items []leaf + } + c := container{Items: []leaf{{}, {}}} + r := &findResult[int]{} + count := 0 + set := r.everyAlloc(reflect.ValueOf(&c).Elem(), []int{0, 0}, 1, func(v reflect.Value, _ int) bool { + v.SetString("x") + count++ + return true + }) + assert.True(t, set) + assert.Equal(t, 2, count) + assert.Equal(t, "x", c.Items[1].Val) + }) + + t.Run("recurses into map elements", func(t *testing.T) { + type container struct { + M map[string]leaf + } + c := container{M: map[string]leaf{"a": {}, "b": {}}} + r := &findResult[int]{} + count := 0 + set := r.everyAlloc(reflect.ValueOf(&c).Elem(), []int{0, 0}, 1, func(v reflect.Value, _ int) bool { + count++ + return true + }) + assert.True(t, set) + assert.Equal(t, 2, count) + }) + + t.Run("panics on an unsupported kind in the path", func(t *testing.T) { + type container struct { + N int + } + var c container + r := &findResult[int]{} + assert.PanicsWithValue(t, "unsupported", func() { + // Path descends into the int field, which is neither a struct, + // slice, map, nor pointer. + r.everyAlloc(reflect.ValueOf(&c).Elem(), []int{0, 0}, 1, func(v reflect.Value, _ int) bool { + return true + }) + }) + }) +}