diff --git a/huma.go b/huma.go index 0d0f1740..77689ee4 100644 --- a/huma.go +++ b/huma.go @@ -270,7 +270,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p } return pfi - }, false, "Body") + }, true, "Body") } // findResolvers searches a given type for resolvers matching a specified resolverType. @@ -306,6 +306,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 +} + // findHeaders extracts header-related metadata from a given struct type using reflection. // It returns a findResult containing headerInfo for fields tagged with "header" or // defaulting to field names. Embedded fields or fields named "Status" and "Body" are @@ -319,6 +340,20 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { header := sf.Tag.Get("header") if header == "" { + // 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 + } + header = sf.Name } @@ -331,7 +366,7 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { } return &headerInfo{sf, header, timeFormat} - }, false, "Status", "Body") + }, true, "Status", "Body") } type findResultPath[T comparable] struct { @@ -382,6 +417,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) + } +} + // jsonName extracts the JSON name from a struct field or converts the field name // to lowercase if no JSON tag is present. func jsonName(field reflect.StructField) string { @@ -740,6 +834,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 { @@ -838,10 +933,10 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) } } - 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() @@ -860,7 +955,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 } } @@ -883,7 +978,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 { @@ -894,13 +989,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 } } @@ -911,6 +1006,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. @@ -938,15 +1035,15 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) formValueParser = func(val reflect.Value) {} } else { 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 } // Skip FormFile and []FormFile fields as they are handled separately. if p.Type == formFileType || p.Type == formFilesType { - return + return false } pb.Reset() @@ -959,14 +1056,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 { @@ -976,6 +1073,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 }) } } @@ -1517,7 +1616,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 @@ -1593,6 +1692,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 @@ -1619,21 +1719,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_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 + }) + }) + }) +} diff --git a/huma_test.go b/huma_test.go index afb290b2..4afa837b 100644 --- a/huma_test.go +++ b/huma_test.go @@ -2123,6 +2123,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"` @@ -2134,6 +2156,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{ @@ -2150,6 +2178,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 }) @@ -2168,6 +2218,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", @@ -2187,6 +2259,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")) }, }, { @@ -2194,12 +2280,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 be set as header using field name. + 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 - nested (not promoted), so NOT set as a header. } 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. @@ -2217,6 +2312,12 @@ Content-Type: text/plain HiddenWithTag: "hidden-with-tag-value", HiddenWithoutTag: "should-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), @@ -2234,6 +2335,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") @@ -2247,9 +2351,14 @@ 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. + // 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")) + // 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")) @@ -4131,6 +4240,118 @@ 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")) +} + +// 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") + }) +} + func TestNonJSONValidation(t *testing.T) { // Test that validation works when only non-JSON content type is specified. // This tests the fix for supporting validation schemas from non-JSON content types.