fix(openapi): deep clone route metadata
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
2d09cc5d28
commit
be43aa3d72
3 changed files with 183 additions and 1 deletions
|
|
@ -1582,7 +1582,7 @@ func collectRouteDescriptions(g RouteGroup) []RouteDescription {
|
|||
if rd.Hidden {
|
||||
continue
|
||||
}
|
||||
descs = append(descs, rd)
|
||||
descs = append(descs, cloneRouteDescription(rd))
|
||||
}
|
||||
|
||||
return descs
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue