fix(openapi): deep clone route metadata

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 14:35:59 +00:00
parent 2d09cc5d28
commit be43aa3d72
3 changed files with 183 additions and 1 deletions

View file

@ -1582,7 +1582,7 @@ func collectRouteDescriptions(g RouteGroup) []RouteDescription {
if rd.Hidden {
continue
}
descs = append(descs, rd)
descs = append(descs, cloneRouteDescription(rd))
}
return descs

View file

@ -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",

View file

@ -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 {