From 71c179018df633c11944e06b2708ba56e169a3bc Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 09:08:16 +0000 Subject: [PATCH] refactor(api): snapshot route metadata during spec build Co-Authored-By: Virgil --- openapi.go | 20 ++++++----- openapi_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/openapi.go b/openapi.go index 5c28a29..c283cc9 100644 --- a/openapi.go +++ b/openapi.go @@ -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} } diff --git a/openapi_test.go b/openapi_test.go index 678e8d5..da1bb76 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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",