fix(api): deep-clone swagger security schemes

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 07:19:32 +00:00
parent 69dd16cba6
commit b0549dc14e
4 changed files with 111 additions and 3 deletions

View file

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

View file

@ -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)
}
}
}

View file

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

View file

@ -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"])
}
}