refactor(api): snapshot route metadata during spec build

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 09:08:16 +00:00
parent c383d85923
commit 71c179018d
2 changed files with 103 additions and 9 deletions

View file

@ -49,8 +49,9 @@ type SpecBuilder struct {
}
type preparedRouteGroup struct {
group RouteGroup
descs []RouteDescription
name string
basePath string
descs []RouteDescription
}
const openAPIDialect = "https://spec.openapis.org/oas/3.1/dialect/base"
@ -271,7 +272,7 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
for _, g := range groups {
for _, rd := range g.descs {
fullPath := joinOpenAPIPath(g.group.BasePath(), rd.Path)
fullPath := joinOpenAPIPath(g.basePath, rd.Path)
method := strings.ToLower(rd.Method)
deprecated := rd.Deprecated || strings.TrimSpace(rd.SunsetDate) != "" || strings.TrimSpace(rd.Replacement) != ""
deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement)
@ -294,7 +295,7 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
},
}
}
if tags := resolvedOperationTags(g.group, rd); len(tags) > 0 {
if tags := resolvedOperationTags(g.name, rd); len(tags) > 0 {
operation["tags"] = tags
}
@ -789,7 +790,7 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any {
}
for _, g := range groups {
name := strings.TrimSpace(g.group.Name())
name := strings.TrimSpace(g.name)
if name != "" && !seen[name] {
tags = append(tags, map[string]any{
"name": name,
@ -1473,8 +1474,9 @@ func prepareRouteGroups(groups []RouteGroup) []preparedRouteGroup {
continue
}
out = append(out, preparedRouteGroup{
group: g,
descs: collectRouteDescriptions(g),
name: g.Name(),
basePath: g.BasePath(),
descs: collectRouteDescriptions(g),
})
}
@ -1659,12 +1661,12 @@ func mergeOperationParameters(existing any, explicit []map[string]any) []map[str
// resolvedOperationTags returns the explicit route tags when provided, or a
// stable fallback derived from the group's name when the route omits tags.
func resolvedOperationTags(g RouteGroup, rd RouteDescription) []string {
func resolvedOperationTags(groupName string, rd RouteDescription) []string {
if tags := cleanTags(rd.Tags); len(tags) > 0 {
return tags
}
if name := strings.TrimSpace(g.Name()); name != "" {
if name := strings.TrimSpace(groupName); name != "" {
return []string{name}
}

View file

@ -90,6 +90,31 @@ func (s *countingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] {
}
}
type snapshottingGroup struct {
nameCalls int
basePathCalls int
descs []api.RouteDescription
}
func (s *snapshottingGroup) Name() string {
s.nameCalls++
if s.nameCalls == 1 {
return "alpha"
}
return "beta"
}
func (s *snapshottingGroup) BasePath() string {
s.basePathCalls++
if s.basePathCalls == 1 {
return "/alpha"
}
return "/beta"
}
func (s *snapshottingGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *snapshottingGroup) Describe() []api.RouteDescription { return s.descs }
// ── SpecBuilder tests ─────────────────────────────────────────────────────
func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
@ -1007,6 +1032,73 @@ func TestSpecBuilder_Good_DescribeIterNilFallsBackToDescribe(t *testing.T) {
}
}
func TestSpecBuilder_Good_GroupMetadataIsSnapshottedOnce(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &snapshottingGroup{
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Snapshot status",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
if _, ok := paths["/alpha/status"]; !ok {
t.Fatalf("expected snapshotted path /alpha/status, got %v", paths)
}
if _, ok := paths["/beta/status"]; ok {
t.Fatal("did not expect mutated base path to leak into the spec")
}
tags := spec["tags"].([]any)
foundAlpha := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "alpha" {
foundAlpha = true
break
}
if tm["name"] == "beta" {
t.Fatal("did not expect mutated group name to leak into the spec")
}
}
if !foundAlpha {
t.Fatal("expected snapshotted group name in spec tags")
}
op := paths["/alpha/status"].(map[string]any)["get"].(map[string]any)
opTags, ok := op["tags"].([]any)
if !ok || len(opTags) != 1 || opTags[0] != "alpha" {
t.Fatalf("expected snapshotted operation tag alpha, got %v", op["tags"])
}
if group.nameCalls != 1 {
t.Fatalf("expected Name to be called once, got %d", group.nameCalls)
}
if group.basePathCalls != 1 {
t.Fatalf("expected BasePath to be called once, got %d", group.basePathCalls)
}
}
func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",