diff --git a/openapi.go b/openapi.go index 44da110..10f8c8b 100644 --- a/openapi.go +++ b/openapi.go @@ -721,7 +721,7 @@ func securitySchemeComponents(overrides map[string]any) map[string]any { if name == "" || scheme == nil { continue } - schemes[name] = scheme + schemes[name] = cloneOpenAPIValue(scheme) } return schemes diff --git a/options.go b/options.go index 3b99ee2..80952ed 100644 --- a/options.go +++ b/options.go @@ -266,7 +266,7 @@ func WithSwaggerSecuritySchemes(schemes map[string]any) Option { if name == "" || scheme == nil { continue } - e.swaggerSecuritySchemes[name] = scheme + e.swaggerSecuritySchemes[name] = cloneOpenAPIValue(scheme) } } } diff --git a/spec_builder_helper.go b/spec_builder_helper.go index 463b768..23cd167 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -3,6 +3,7 @@ package api import ( + "reflect" "slices" ) @@ -52,7 +53,61 @@ func cloneSecuritySchemes(schemes map[string]any) map[string]any { out := make(map[string]any, len(schemes)) for name, scheme := range schemes { - out[name] = scheme + out[name] = cloneOpenAPIValue(scheme) } 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 { + switch value := v.(type) { + case map[string]any: + out := make(map[string]any, len(value)) + for k, nested := range value { + out[k] = cloneOpenAPIValue(nested) + } + return out + case []any: + out := make([]any, len(value)) + for i, nested := range value { + out[i] = cloneOpenAPIValue(nested) + } + return out + default: + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return nil + } + + switch rv.Kind() { + case reflect.Map: + out := reflect.MakeMapWithSize(rv.Type(), rv.Len()) + for _, key := range rv.MapKeys() { + cloned := cloneOpenAPIValue(rv.MapIndex(key).Interface()) + if cloned == nil { + out.SetMapIndex(key, reflect.Zero(rv.Type().Elem())) + continue + } + out.SetMapIndex(key, reflect.ValueOf(cloned)) + } + return out.Interface() + case reflect.Slice: + if rv.IsNil() { + return v + } + out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len()) + for i := 0; i < rv.Len(); i++ { + cloned := cloneOpenAPIValue(rv.Index(i).Interface()) + if cloned == nil { + out.Index(i).Set(reflect.Zero(rv.Type().Elem())) + continue + } + out.Index(i).Set(reflect.ValueOf(cloned)) + } + return out.Interface() + default: + return value + } + } +} diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go index e549889..f5f5f9a 100644 --- a/spec_builder_helper_test.go +++ b/spec_builder_helper_test.go @@ -312,3 +312,56 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredSSEPathWithoutBroker(t * t.Fatalf("expected x-sse-path=/events, got %v", got) } } + +func TestEngine_Good_OpenAPISpecBuilderClonesSecuritySchemes(t *testing.T) { + gin.SetMode(gin.TestMode) + + securityScheme := map[string]any{ + "type": "oauth2", + "flows": map[string]any{ + "clientCredentials": map[string]any{ + "tokenUrl": "https://auth.example.com/token", + }, + }, + } + schemes := map[string]any{ + "oauth2": securityScheme, + } + + e, err := api.New( + api.WithSwagger("Engine API", "Engine metadata", "2.0.0"), + api.WithSwaggerSecuritySchemes(schemes), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Mutate the original input after configuration. The builder snapshot should + // remain stable and keep the original token URL. + securityScheme["type"] = "mutated" + securityScheme["flows"].(map[string]any)["clientCredentials"].(map[string]any)["tokenUrl"] = "https://mutated.example.com/token" + + data, err := e.OpenAPISpecBuilder().Build(nil) + 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) + } + + securitySchemes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any) + oauth2, ok := securitySchemes["oauth2"].(map[string]any) + if !ok { + t.Fatal("expected oauth2 security scheme in generated spec") + } + if oauth2["type"] != "oauth2" { + t.Fatalf("expected cloned oauth2.type=oauth2, got %v", oauth2["type"]) + } + flows := oauth2["flows"].(map[string]any) + clientCredentials := flows["clientCredentials"].(map[string]any) + if clientCredentials["tokenUrl"] != "https://auth.example.com/token" { + t.Fatalf("expected original tokenUrl to be preserved, got %v", clientCredentials["tokenUrl"]) + } +}