From e23d8e9780babf1183d5b673614034f68a4716cb Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:47:51 +0000 Subject: [PATCH] feat(openapi): sort generated tags deterministically Co-Authored-By: Virgil --- openapi.go | 25 ++++++++++++++++++++++ openapi_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/openapi.go b/openapi.go index 7b379fb..d91d97e 100644 --- a/openapi.go +++ b/openapi.go @@ -6,6 +6,7 @@ import ( "encoding/json" "iter" "net/http" + "sort" "strconv" "strings" "unicode" @@ -609,9 +610,33 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { } } + sortTags(tags) + return tags } +// sortTags keeps system first and orders the remaining tags alphabetically so +// generated specs stay stable across registration order changes. +func sortTags(tags []map[string]any) { + if len(tags) < 2 { + return + } + + sort.SliceStable(tags, func(i, j int) bool { + left, _ := tags[i]["name"].(string) + right, _ := tags[j]["name"].(string) + + switch { + case left == "system": + return true + case right == "system": + return false + default: + return left < right + } + }) +} + func graphqlPathItem(path string, operationIDs map[string]int) map[string]any { return map[string]any{ "post": map[string]any{ diff --git a/openapi_test.go b/openapi_test.go index 10798ea..ec816f0 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -1554,6 +1554,61 @@ func TestSpecBuilder_Good_DefaultTagsFromGroupName(t *testing.T) { } } +func TestSpecBuilder_Good_TagsAreSortedDeterministically(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "gamma", + basePath: "/api/gamma", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/status", + Summary: "Check status", + Tags: []string{"zeta", "alpha", "beta"}, + 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) + } + + tags, ok := spec["tags"].([]any) + if !ok { + t.Fatalf("expected tags array, got %T", spec["tags"]) + } + + names := make([]string, 0, len(tags)) + for _, raw := range tags { + tag := raw.(map[string]any) + name, _ := tag["name"].(string) + names = append(names, name) + } + + expected := []string{"system", "alpha", "beta", "gamma", "zeta"} + if len(names) != len(expected) { + t.Fatalf("expected %d tags, got %d: %v", len(expected), len(names), names) + } + for i := range expected { + if names[i] != expected[i] { + t.Fatalf("expected tag order %v, got %v", expected, names) + } + } +} + func TestSpecBuilder_Good_DeprecatedOperation(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",