Skip to content

Commit 7c27b16

Browse files
committed
fix(openapi): key unnamed types by shape, not by the word "Object"
An API with one endpoint returning free-form JSON and another returning an empty body produces no spec at all. OpenAPISpec() hands back nil, and the generator's own explanation is that the component name "Object" is pinned to two different types, "Object and Object", so rename one of them. You cannot rename either. Neither name was ever pinned. getQualifiedTypeName returns the literal string "Object" for any type without a name, and that string is not just a label. It keys the component registry. So map[string]interface{} and struct{} arrive at the same registry entry, noteComponent sees a second type claiming a name the first already holds, and reportPinnedConflict fails the whole document over a collision the user did not cause and cannot fix. Unnamed types now key on reflect's own rendering, which is stable for a given type and different for different ones. The display name stays "Object"; GetTypeName is untouched. Only identity changes. The tests cover both registration orders, because order is the user's and the outcome should not be.
1 parent 7aa820a commit 7c27b16

2 files changed

Lines changed: 85 additions & 1 deletion

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package router
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/xraph/forge/internal/shared"
9+
)
10+
11+
// Anonymous types have no name of their own, so the generator shows them all as
12+
// "Object". That is a display choice, and it used to be an identity choice too:
13+
// every unnamed type keyed the component registry under the same string, so the
14+
// second one to arrive looked like a different type claiming a name the first
15+
// already held. The generator reported that as a pinned-name conflict -- advice
16+
// nobody could act on, since neither name was ever pinned -- and returned no
17+
// document at all.
18+
//
19+
// One endpoint answering with free-form JSON and another answering with an
20+
// empty body is all it takes, and that pair turns up in almost any real API.
21+
func registerFreeFormResponse(t *testing.T, r Router) {
22+
t.Helper()
23+
require.NoError(t, r.GET("/manifest",
24+
func(ctx shared.Context, req *collisionEmptyRequest) (*map[string]any, error) {
25+
return &map[string]any{}, nil
26+
}))
27+
}
28+
29+
func registerEmptyResponse(t *testing.T, r Router) {
30+
t.Helper()
31+
require.NoError(t, r.POST("/revoke",
32+
func(ctx shared.Context, req *collisionEmptyRequest) (*struct{}, error) {
33+
return &struct{}{}, nil
34+
}))
35+
}
36+
37+
func TestOpenAPI_DistinctAnonymousResponsesDoNotCollide(t *testing.T) {
38+
for _, tc := range []struct {
39+
name string
40+
mapFirst bool
41+
}{
42+
{name: "MapFirst", mapFirst: true},
43+
{name: "StructFirst", mapFirst: false},
44+
} {
45+
t.Run(tc.name, func(t *testing.T) {
46+
r := NewRouter(WithOpenAPI(OpenAPIConfig{Title: "AnonTypes", Version: "1.0.0"}))
47+
48+
if tc.mapFirst {
49+
registerFreeFormResponse(t, r)
50+
registerEmptyResponse(t, r)
51+
} else {
52+
registerEmptyResponse(t, r)
53+
registerFreeFormResponse(t, r)
54+
}
55+
56+
spec := r.OpenAPISpec()
57+
require.NotNil(t, spec,
58+
"two distinct unnamed types were reported as one name conflict, so no spec was generated")
59+
requireEveryRefResolves(t, spec)
60+
})
61+
}
62+
}
63+
64+
// Generate returns the error alongside the spec, and a caller that asks for the
65+
// spec twice must get the same answer both times. Guarding it here keeps the
66+
// registry from carrying conflict state over between passes.
67+
func TestOpenAPI_AnonymousResponsesGenerateIsRepeatable(t *testing.T) {
68+
r := NewRouter(WithOpenAPI(OpenAPIConfig{Title: "AnonRepeat", Version: "1.0.0"}))
69+
registerFreeFormResponse(t, r)
70+
registerEmptyResponse(t, r)
71+
72+
first := r.OpenAPISpec()
73+
require.NotNil(t, first)
74+
75+
second := r.OpenAPISpec()
76+
require.NotNil(t, second, "a second Generate pass lost the spec the first one produced")
77+
require.Equal(t, len(first.Components.Schemas), len(second.Components.Schemas))
78+
}

‎internal/router/openapi_schema.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,13 @@ func getQualifiedTypeName(t reflect.Type) string {
11841184
typeName := t.Name()
11851185

11861186
if typeName == "" {
1187-
return "Object"
1187+
// An unnamed type -- map[string]any, struct{}, []T -- has no name to
1188+
// qualify, and every one of them displays as "Object". That is fine as
1189+
// a label and wrong as an identity: this string keys the component
1190+
// registry, so returning a constant made two unrelated types look like
1191+
// one type claiming a name twice. reflect's own rendering tells them
1192+
// apart and stays stable for the same type.
1193+
return t.String()
11881194
}
11891195

11901196
if pkgPath != "" {

0 commit comments

Comments
 (0)