diff --git a/huma.go b/huma.go index ca4dd5d4..9fe23f14 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] { @@ -251,6 +251,17 @@ 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 { + return nil + } + + fieldType := baseType(sf.Type) + if fieldType.Kind() == reflect.Struct && fieldType != timeType { + return nil + } + header = sf.Name } @@ -263,7 +274,7 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { } return &headerInfo{sf, header, timeFormat} - }, false, "Status", "Body") + }, true, "Status", "Body") } type findResultPath[T comparable] struct { @@ -650,6 +661,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 { @@ -1320,7 +1332,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 @@ -1396,6 +1408,7 @@ 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 @@ -1422,21 +1435,25 @@ func processOutputType(outputType reflect.Type, op *Operation, registry Registry if op.Responses[defaultStatusStr].Headers == nil { op.Responses[defaultStatusStr].Headers = map[string]*Param{} } + 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 } diff --git a/huma_test.go b/huma_test.go index 9ab0a74d..0094a1a1 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1894,6 +1894,28 @@ Content-Type: text/plain { Name: "response-headers", Register: func(t *testing.T, api huma.API) { + type NestedHeaders struct { + NestedWithTag string `header:"X-Nested-With-Tag"` + 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. + } + + // 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"` @@ -1905,6 +1927,12 @@ Content-Type: text/plain 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. + + // Slice paths to cover slice/map element-type unwrapping. + NestedSlice []NestedHeadersSliceElem + NestedPtrSlice []*NestedPtrHeadersSliceElem } huma.Register(api, huma.Operation{ @@ -1921,6 +1949,28 @@ Content-Type: text/plain 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", + }, + + // 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 }) @@ -1939,6 +1989,28 @@ Content-Type: text/plain // Surface-level fields without tags should be documented using field name. 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"]) + + // 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"]) + + // 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"]) + + // 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", @@ -1958,6 +2030,20 @@ Content-Type: text/plain // Surface-level fields without tags should be set using field name. assert.Equal(t, "without-tag-value", resp.Header().Get("WithoutTag")) 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")) + + // 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")) + + // 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")) }, }, { @@ -1965,12 +2051,21 @@ 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. + } + + // 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. } type Resp struct { *HiddenHeaders `hidden:"true"` + // 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. @@ -1986,7 +2081,13 @@ Content-Type: text/plain return &Resp{ HiddenHeaders: &HiddenHeaders{ HiddenWithTag: "hidden-with-tag-value", - HiddenWithoutTag: "should-be-header", + 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", @@ -2005,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") + // 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") @@ -2018,8 +2122,11 @@ 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 still be sent at runtime using field name. - assert.Equal(t, "should-be-header", resp.Header().Get("HiddenWithoutTag")) + // Hidden headers without tag should NOT be set. + assert.Empty(t, resp.Header().Values("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")) // Visible surface-level fields should be sent at runtime. assert.Equal(t, "visible-with-tag-value", resp.Header().Get("X-Visible-With-Tag"))