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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 }