diff --git a/adapters/humamux/humamux_test.go b/adapters/humamux/humamux_test.go index 4d95beda..4a9c51d1 100644 --- a/adapters/humamux/humamux_test.go +++ b/adapters/humamux/humamux_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/registry.go b/registry.go index 8fa84b61..be205e32 100644 --- a/registry.go +++ b/registry.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "strconv" "strings" "unicode/utf8" ) @@ -124,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 + } + } } } 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)