Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
113 changes: 110 additions & 3 deletions huma_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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{
Expand All @@ -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
})

Expand All @@ -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",
Expand All @@ -1958,19 +2030,42 @@ 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"))
},
},
{
Name: "response-headers-hidden",
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.
Expand All @@ -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",
Expand All @@ -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")
Expand All @@ -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"))
Expand Down
Loading