From ae3b2ba5a484cb81b78f5b040fe1f378b1f430ba Mon Sep 17 00:00:00 2001 From: mcklmo Date: Sun, 14 Sep 2025 14:24:47 +0200 Subject: [PATCH 1/2] fix: avoid faulty duplicate detection bug caused by missing reflection type name for inline struct definitions, combined with an empty OperationID --- adapters/humamux/humagmux_test.go | 60 +++++++++++++++++++++++++++++++ huma.go | 34 ++++++++++++++---- registry.go | 6 ++++ 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/adapters/humamux/humagmux_test.go b/adapters/humamux/humagmux_test.go index 4d95beda..4a9c51d1 100644 --- a/adapters/humamux/humagmux_test.go +++ b/adapters/humamux/humagmux_test.go @@ -133,3 +133,63 @@ func BenchmarkHumaGorillaMux(b *testing.B) { } } } + +func TestOperationWithoutIDAndInlineRequestTypeDefinitionNotPanics(t *testing.T) { + r := mux.NewRouter() + api := New(r, huma.DefaultConfig("Test", "1.0.0")) + + var ( + op1 = huma.Operation{Method: http.MethodGet, Path: "/1"} // no OperationID + op2 = huma.Operation{Method: http.MethodGet, Path: "/2"} // no OperationID + ) + + huma.Register(api, op1, func(ctx context.Context, i *struct { // inline request input type definition + Body struct { + Test1 string // field name varying + } + }, + ) (*struct{}, error, + ) { + return nil, nil + }) + + huma.Register(api, op2, func(ctx context.Context, i *struct { // inline request input type definition + Body struct { + Test2 string // field name varying + } + }, + ) (*struct{}, error, + ) { + return nil, nil + }) +} + +func TestOperationWithoutIDAndInlineResponseTypeDefinitionNotPanics(t *testing.T) { + r := mux.NewRouter() + api := New(r, huma.DefaultConfig("", "")) + + var ( + op1 = huma.Operation{Method: http.MethodGet, Path: "/1"} // no OperationID + op2 = huma.Operation{Method: http.MethodGet, Path: "/2"} // no OperationID + ) + + huma.Register(api, op1, func(ctx context.Context, i *struct{}, + ) (*struct { // inline response output type definition + Body struct { + Test1 string // field name varying + } + }, error, + ) { + return nil, nil + }) + + huma.Register(api, op2, func(ctx context.Context, i *struct{}, + ) (*struct { // inline response output type definition + Body struct { + Test2 string // field name varying + } + }, error, + ) { + return nil, nil + }) +} diff --git a/huma.go b/huma.go index 6f98afaa..90af0d58 100644 --- a/huma.go +++ b/huma.go @@ -31,10 +31,12 @@ import ( var errDeadlineUnsupported = fmt.Errorf("%w", http.ErrNotSupported) -var bodyCallbackType = reflect.TypeOf(func(Context) {}) -var cookieType = reflect.TypeOf((*http.Cookie)(nil)).Elem() -var fmtStringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() -var stringType = reflect.TypeOf("") +var ( + bodyCallbackType = reflect.TypeOf(func(Context) {}) + cookieType = reflect.TypeOf((*http.Cookie)(nil)).Elem() + fmtStringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() + stringType = reflect.TypeOf("") +) // SetReadDeadline is a utility to set the read deadline on a response writer, // if possible. If not, it will not incur any allocations (unlike the stdlib @@ -714,7 +716,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) } } - var receiver = f + receiver := f if f.Addr().Type().Implements(reflect.TypeFor[ParamWrapper]()) { receiver = f.Addr().Interface().(ParamWrapper).Receiver() } @@ -1221,7 +1223,7 @@ func setRequestBodyFromBody(op *Operation, registry Registry, fBody reflect.Stru op.RequestBody.Content[contentType] = &MediaType{} } if op.RequestBody.Content[contentType].Schema == nil { - hint := getHint(inputType, fBody.Name, op.OperationID+"Request") + hint := getHint(inputType, fBody.Name, getDefaultHint(op.OperationID, registry, inputType, "Request")) if nameHint := fBody.Tag.Get("nameHint"); nameHint != "" { hint = nameHint } @@ -1230,6 +1232,24 @@ func setRequestBodyFromBody(op *Operation, registry Registry, fBody reflect.Stru } } +// getDefaultHint checks if the hint already exists in the registry and returns a new hint if it does. +// +// It suffixes the hint with an increasing number if it already exists, starting from 1. +// +// However, the operationID takes precedence if it is not empty. +func getDefaultHint(operationID string, registry Registry, inputType reflect.Type, defaultPrefix string) string { + if operationID != "" { + return operationID + defaultPrefix + } + + defaultHint := defaultPrefix + for i := 1; registry.NameExistsInSchema(inputType, defaultHint); i++ { + defaultHint = defaultPrefix + strconv.Itoa(i) + } + + return defaultHint +} + type rawBodyType int const ( @@ -1359,7 +1379,7 @@ func processOutputType(outputType reflect.Type, op *Operation, registry Registry op.Responses[statusStr].Headers = map[string]*Param{} } if !outBodyFunc { - hint := getHint(outputType, f.Name, op.OperationID+"Response") + hint := getHint(outputType, f.Name, getDefaultHint(op.OperationID, registry, outputType, "Response")) if nameHint := f.Tag.Get("nameHint"); nameHint != "" { hint = nameHint } diff --git a/registry.go b/registry.go index 6ee69153..562c5fe4 100644 --- a/registry.go +++ b/registry.go @@ -15,6 +15,7 @@ import ( // schemas to exist while being flexible enough to support other use cases // like only inline objects (no refs) or always using refs for structs. type Registry interface { + NameExistsInSchema(t reflect.Type, hint string) bool Schema(t reflect.Type, allowRef bool, hint string) *Schema SchemaFromRef(ref string) *Schema TypeFromRef(ref string) reflect.Type @@ -160,6 +161,11 @@ func (r *mapRegistry) RegisterTypeAlias(t reflect.Type, alias reflect.Type) { r.aliases[t] = alias } +func (r *mapRegistry) NameExistsInSchema(t reflect.Type, hint string) bool { + _, ok := r.schemas[r.namer(t, hint)] + return ok +} + // NewMapRegistry creates a new registry that stores schemas in a map and // returns references to them using the given prefix. func NewMapRegistry(prefix string, namer func(t reflect.Type, hint string) string) Registry { From a3b6e21c7e23fffb841551dd28d3995cf97ad3fe Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:37:06 +0100 Subject: [PATCH 2/2] fix: resolve inline schema name collisions in the registry Move duplicate-name handling into mapRegistry.Schema, where type identity is already tracked via the seen set. Identical inline types reuse the existing ref (dedup preserved), distinct anonymous types get a numeric suffix, and named-type collisions still panic. This drops the NameExistsInSchema addition to the public Registry interface. --- huma.go | 22 ++-------------------- registry.go | 32 ++++++++++++++++++++------------ registry_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/huma.go b/huma.go index 82bb0286..78b286e3 100644 --- a/huma.go +++ b/huma.go @@ -1400,7 +1400,7 @@ func setRequestBodyFromBody(op *Operation, registry Registry, fBody reflect.Stru op.RequestBody.Content[contentType] = &MediaType{} } if op.RequestBody.Content[contentType].Schema == nil { - hint := getHint(inputType, fBody.Name, getDefaultHint(op.OperationID, registry, inputType, "Request")) + hint := getHint(inputType, fBody.Name, op.OperationID+"Request") if nameHint := fBody.Tag.Get("nameHint"); nameHint != "" { hint = nameHint } @@ -1409,24 +1409,6 @@ func setRequestBodyFromBody(op *Operation, registry Registry, fBody reflect.Stru } } -// getDefaultHint checks if the hint already exists in the registry and returns a new hint if it does. -// -// It suffixes the hint with an increasing number if it already exists, starting from 1. -// -// However, the operationID takes precedence if it is not empty. -func getDefaultHint(operationID string, registry Registry, inputType reflect.Type, defaultPrefix string) string { - if operationID != "" { - return operationID + defaultPrefix - } - - defaultHint := defaultPrefix - for i := 1; registry.NameExistsInSchema(inputType, defaultHint); i++ { - defaultHint = defaultPrefix + strconv.Itoa(i) - } - - return defaultHint -} - type rawBodyType int const ( @@ -1556,7 +1538,7 @@ func processOutputType(outputType reflect.Type, op *Operation, registry Registry op.Responses[statusStr].Headers = map[string]*Param{} } if !outBodyFunc { - hint := getHint(outputType, f.Name, getDefaultHint(op.OperationID, registry, outputType, "Response")) + hint := getHint(outputType, f.Name, op.OperationID+"Response") if nameHint := f.Tag.Get("nameHint"); nameHint != "" { hint = nameHint } diff --git a/registry.go b/registry.go index 5d96793e..be205e32 100644 --- a/registry.go +++ b/registry.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "strconv" "strings" "unicode/utf8" ) @@ -14,7 +15,6 @@ import ( // schemas to exist while being flexible enough to support other use cases // like only inline objects (no refs) or always using refs for structs. type Registry interface { - NameExistsInSchema(t reflect.Type, hint string) bool Schema(t reflect.Type, allowRef bool, hint string) *Schema SchemaFromRef(ref string) *Schema TypeFromRef(ref string) reflect.Type @@ -125,17 +125,30 @@ func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema if getsRef { if s, ok := r.schemas[name]; ok { - if _, ok = r.seen[t]; !ok { - // The name matches but the type is different, so we have a dupe. + if _, seen := r.seen[t]; seen { + // The name and type both match, so reuse the existing schema. + if allowRef { + return &Schema{Ref: r.prefix + name} + } - panic(fmt.Errorf("duplicate name: %s, new type: %s, existing type: %s", name, t, r.types[name])) + return s } - if allowRef { - return &Schema{Ref: r.prefix + name} + // The name matches but the type is different. Named types are a + // genuine duplicate the caller must resolve (e.g. with a custom + // namer), but unnamed inline types share a hint-derived name by + // design, so disambiguate them with an incrementing numeric suffix. + if t.Name() != "" { + panic(fmt.Errorf("duplicate name: %s, new type: %s, existing type: %s", name, t, r.types[name])) } - return s + base := name + for i := 1; ; i++ { + name = base + strconv.Itoa(i) + if _, exists := r.schemas[name]; !exists { + break + } + } } } @@ -185,11 +198,6 @@ func (r *mapRegistry) RegisterTypeAlias(t reflect.Type, alias reflect.Type) { r.aliases[t] = alias } -func (r *mapRegistry) NameExistsInSchema(t reflect.Type, hint string) bool { - _, ok := r.schemas[r.namer(t, hint)] - return ok -} - func (r *mapRegistry) Config() registryConfig { return r.config } diff --git a/registry_test.go b/registry_test.go index cf432da8..832dd0f5 100644 --- a/registry_test.go +++ b/registry_test.go @@ -98,6 +98,53 @@ func TestAllowAdditionalPropertiesByDefault(t *testing.T) { }) } +func TestInlineTypeDisambiguation(t *testing.T) { + // Distinct inline (unnamed) structs share the same hint-derived name and + // must be disambiguated rather than panic. See issue reproduced in #893. + r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer) + + first := r.Schema(reflect.TypeOf(struct { + Field1 string `json:"field1"` + }{}), true, "Request") + second := r.Schema(reflect.TypeOf(struct { + Field2 string `json:"field2"` + }{}), true, "Request") + + assert.Equal(t, "#/components/schemas/Request", first.Ref) + assert.Equal(t, "#/components/schemas/Request1", second.Ref) + assert.Len(t, r.Map(), 2) +} + +func TestInlineTypeDeduplication(t *testing.T) { + // Identical inline structs are the same reflect.Type and must still + // deduplicate to a single schema. + r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer) + + first := r.Schema(reflect.TypeOf(struct { + Field string `json:"field"` + }{}), true, "Request") + second := r.Schema(reflect.TypeOf(struct { + Field string `json:"field"` + }{}), true, "Request") + + assert.Equal(t, "#/components/schemas/Request", first.Ref) + assert.Equal(t, first.Ref, second.Ref) + assert.Len(t, r.Map(), 1) +} + +func TestDuplicateNamedTypePanics(t *testing.T) { + // Genuinely different named types that collide on a name remain a hard + // error, directing the caller to a custom namer. + r := NewMapRegistry("#/components/schemas/", func(reflect.Type, string) string { + return "Collision" + }) + + r.Schema(reflect.TypeFor[S](), true, "") + assert.Panics(t, func() { + r.Schema(reflect.TypeFor[Output[int]](), true, "") + }) +} + func TestRegistryConfigValue(t *testing.T) { r := NewMapRegistry("/schemas", DefaultSchemaNamer)