From be43aa3d72e0871e8aa58b1cad2b254ccbb6ae7c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 14:35:59 +0000 Subject: [PATCH] fix(openapi): deep clone route metadata Co-Authored-By: Virgil --- openapi.go | 2 +- openapi_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++ spec_builder_helper.go | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/openapi.go b/openapi.go index cb701de..c6e98b1 100644 --- a/openapi.go +++ b/openapi.go @@ -1582,7 +1582,7 @@ func collectRouteDescriptions(g RouteGroup) []RouteDescription { if rd.Hidden { continue } - descs = append(descs, rd) + descs = append(descs, cloneRouteDescription(rd)) } return descs diff --git a/openapi_test.go b/openapi_test.go index d445407..790aaa0 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -91,6 +91,30 @@ func (s *countingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] { } } +type mutatingIterGroup struct { + name string + basePath string + descs []api.RouteDescription +} + +func (s *mutatingIterGroup) Name() string { return s.name } +func (s *mutatingIterGroup) BasePath() string { return s.basePath } +func (s *mutatingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (s *mutatingIterGroup) Describe() []api.RouteDescription { return nil } +func (s *mutatingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] { + return func(yield func(api.RouteDescription) bool) { + for i, rd := range s.descs { + if !yield(rd) { + return + } + s.descs[i].Response["mutated"] = true + s.descs[i].RequestBody["mutated"] = true + s.descs[i].Parameters[0].Schema["mutated"] = true + s.descs[i].ResponseHeaders["X-Mutated"] = "yes" + } + } +} + type snapshottingGroup struct { nameCalls int basePathCalls int @@ -1208,6 +1232,80 @@ func TestSpecBuilder_Good_GroupMetadataIsSnapshottedOnce(t *testing.T) { } } +func TestSpecBuilder_Good_DeepClonesRouteMetadata(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &mutatingIterGroup{ + name: "alpha", + basePath: "/api", + descs: []api.RouteDescription{ + { + Method: "POST", + Path: "/items", + Summary: "Create item", + Tags: []string{"items"}, + Parameters: []api.ParameterDescription{ + { + Name: "id", + In: "path", + Schema: map[string]any{ + "type": "string", + }, + }, + }, + RequestBody: map[string]any{ + "type": "object", + }, + Response: map[string]any{ + "type": "object", + }, + ResponseHeaders: map[string]string{ + "X-Test": "Original header", + }, + }, + }, + } + + 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) + } + + op := spec["paths"].(map[string]any)["/api/items"].(map[string]any)["post"].(map[string]any) + requestSchema := op["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + if _, ok := requestSchema["mutated"]; ok { + t.Fatal("did not expect request body mutation to leak into the spec") + } + + responses := op["responses"].(map[string]any) + resp201 := responses["200"].(map[string]any) + appJSON := resp201["content"].(map[string]any)["application/json"].(map[string]any) + responseSchema := appJSON["schema"].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any) + if _, ok := responseSchema["mutated"]; ok { + t.Fatal("did not expect response mutation to leak into the spec") + } + + headers := resp201["headers"].(map[string]any) + if _, ok := headers["X-Mutated"]; ok { + t.Fatal("did not expect response header mutation to leak into the spec") + } + + params := op["parameters"].([]any) + pathParam := params[0].(map[string]any) + schema := pathParam["schema"].(map[string]any) + if _, ok := schema["mutated"]; ok { + t.Fatal("did not expect parameter schema mutation to leak into the spec") + } +} + func TestSpecBuilder_Good_SecuredResponses(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/spec_builder_helper.go b/spec_builder_helper.go index 1de6a34..1c8f1e4 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -143,6 +143,90 @@ func cloneSecuritySchemes(schemes map[string]any) map[string]any { return out } +func cloneRouteDescription(rd RouteDescription) RouteDescription { + out := rd + + out.Tags = slices.Clone(rd.Tags) + out.Security = cloneSecurityRequirements(rd.Security) + out.Parameters = cloneParameterDescriptions(rd.Parameters) + out.RequestBody = cloneOpenAPIObject(rd.RequestBody) + out.RequestExample = cloneOpenAPIValue(rd.RequestExample) + out.Response = cloneOpenAPIObject(rd.Response) + out.ResponseExample = cloneOpenAPIValue(rd.ResponseExample) + out.ResponseHeaders = cloneStringMap(rd.ResponseHeaders) + + return out +} + +func cloneParameterDescriptions(params []ParameterDescription) []ParameterDescription { + if params == nil { + return nil + } + if len(params) == 0 { + return []ParameterDescription{} + } + + out := make([]ParameterDescription, len(params)) + for i, param := range params { + out[i] = param + out[i].Schema = cloneOpenAPIObject(param.Schema) + out[i].Example = cloneOpenAPIValue(param.Example) + } + + return out +} + +func cloneSecurityRequirements(security []map[string][]string) []map[string][]string { + if security == nil { + return nil + } + if len(security) == 0 { + return []map[string][]string{} + } + + out := make([]map[string][]string, len(security)) + for i, requirement := range security { + if len(requirement) == 0 { + continue + } + + cloned := make(map[string][]string, len(requirement)) + for name, scopes := range requirement { + cloned[name] = slices.Clone(scopes) + } + out[i] = cloned + } + + return out +} + +func cloneOpenAPIObject(v map[string]any) map[string]any { + if v == nil { + return nil + } + if len(v) == 0 { + return map[string]any{} + } + + cloned, _ := cloneOpenAPIValue(v).(map[string]any) + return cloned +} + +func cloneStringMap(v map[string]string) map[string]string { + if v == nil { + return nil + } + if len(v) == 0 { + return map[string]string{} + } + + out := make(map[string]string, len(v)) + for key, value := range v { + out[key] = value + } + return out +} + // cloneOpenAPIValue recursively copies JSON-like OpenAPI values so callers can // safely retain and reuse their original maps after configuring an engine. func cloneOpenAPIValue(v any) any {