diff --git a/openapi.go b/openapi.go index 9c181c1..93434e3 100644 --- a/openapi.go +++ b/openapi.go @@ -81,20 +81,25 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { if sb == nil { sb = &SpecBuilder{} } + sb = sb.snapshot() prepared := prepareRouteGroups(groups) + info := map[string]any{ + "title": sb.Title, + "description": sb.Description, + "version": sb.Version, + } + if sb.Summary != "" { + info["summary"] = sb.Summary + } + spec := map[string]any{ "openapi": "3.1.0", "jsonSchemaDialect": openAPIDialect, - "info": map[string]any{ - "title": sb.Title, - "summary": sb.Summary, - "description": sb.Description, - "version": sb.Version, - }, - "paths": sb.buildPaths(prepared), - "tags": sb.buildTags(prepared), + "info": info, + "paths": sb.buildPaths(prepared), + "tags": sb.buildTags(prepared), } if sb.LicenseName != "" { @@ -106,12 +111,6 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { } spec["info"].(map[string]any)["license"] = license } - if sb.Summary != "" { - spec["info"].(map[string]any)["summary"] = sb.Summary - } else { - delete(spec["info"].(map[string]any), "summary") - } - if swaggerPath := sb.effectiveSwaggerPath(); swaggerPath != "" { spec["x-swagger-ui-path"] = normaliseSwaggerPath(swaggerPath) } @@ -1966,6 +1965,40 @@ func (sb *SpecBuilder) effectiveAuthentikPublicPaths() []string { return normalisePublicPaths(paths) } +// snapshot returns a trimmed copy of the builder so Build operates on stable +// input even when callers reuse or mutate their original configuration. +func (sb *SpecBuilder) snapshot() *SpecBuilder { + if sb == nil { + return &SpecBuilder{} + } + + out := *sb + out.Title = strings.TrimSpace(out.Title) + out.Summary = strings.TrimSpace(out.Summary) + out.Description = strings.TrimSpace(out.Description) + out.Version = strings.TrimSpace(out.Version) + out.SwaggerPath = strings.TrimSpace(out.SwaggerPath) + out.GraphQLPath = strings.TrimSpace(out.GraphQLPath) + out.WSPath = strings.TrimSpace(out.WSPath) + out.SSEPath = strings.TrimSpace(out.SSEPath) + out.TermsOfService = strings.TrimSpace(out.TermsOfService) + out.ContactName = strings.TrimSpace(out.ContactName) + out.ContactURL = strings.TrimSpace(out.ContactURL) + out.ContactEmail = strings.TrimSpace(out.ContactEmail) + out.LicenseName = strings.TrimSpace(out.LicenseName) + out.LicenseURL = strings.TrimSpace(out.LicenseURL) + out.ExternalDocsDescription = strings.TrimSpace(out.ExternalDocsDescription) + out.ExternalDocsURL = strings.TrimSpace(out.ExternalDocsURL) + out.CacheTTL = strings.TrimSpace(out.CacheTTL) + out.I18nDefaultLocale = strings.TrimSpace(out.I18nDefaultLocale) + out.Servers = slices.Clone(sb.Servers) + out.I18nSupportedLocales = slices.Clone(sb.I18nSupportedLocales) + out.AuthentikPublicPaths = normalisePublicPaths(sb.AuthentikPublicPaths) + out.SecuritySchemes = cloneSecuritySchemes(sb.SecuritySchemes) + + return &out +} + // isPublicOperationPath reports whether an OpenAPI path should be documented // as public because Authentik bypasses it in the running engine. func (sb *SpecBuilder) isPublicOperationPath(path string) bool { diff --git a/openapi_test.go b/openapi_test.go index 68c38b3..c2207ed 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -366,6 +366,78 @@ func TestSpecBuilder_Good_CommonResponseComponentsArePublished(t *testing.T) { } } +func TestSpecBuilder_Good_NormalisesMetadataAtBuild(t *testing.T) { + sb := &api.SpecBuilder{ + Title: " Test API ", + Summary: " ", + Description: " Trimmed description ", + Version: " 1.2.3 ", + TermsOfService: " https://example.com/terms ", + ContactName: " API Support ", + ContactURL: " https://example.com/support ", + ContactEmail: " support@example.com ", + LicenseName: " EUPL-1.2 ", + LicenseURL: " https://eupl.eu/1.2/en/ ", + ExternalDocsURL: " https://example.com/docs ", + ExternalDocsDescription: " Developer guide ", + SwaggerPath: " /docs/ ", + } + + data, err := sb.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) + } + + info := spec["info"].(map[string]any) + if info["title"] != "Test API" { + t.Fatalf("expected trimmed title, got %v", info["title"]) + } + if _, ok := info["summary"]; ok { + t.Fatal("expected blank summary to be omitted") + } + if info["description"] != "Trimmed description" { + t.Fatalf("expected trimmed description, got %v", info["description"]) + } + if info["version"] != "1.2.3" { + t.Fatalf("expected trimmed version, got %v", info["version"]) + } + if info["termsOfService"] != "https://example.com/terms" { + t.Fatalf("expected trimmed termsOfService, got %v", info["termsOfService"]) + } + contact := info["contact"].(map[string]any) + if contact["name"] != "API Support" { + t.Fatalf("expected trimmed contact name, got %v", contact["name"]) + } + if contact["url"] != "https://example.com/support" { + t.Fatalf("expected trimmed contact url, got %v", contact["url"]) + } + if contact["email"] != "support@example.com" { + t.Fatalf("expected trimmed contact email, got %v", contact["email"]) + } + license := info["license"].(map[string]any) + if license["name"] != "EUPL-1.2" { + t.Fatalf("expected trimmed licence name, got %v", license["name"]) + } + if license["url"] != "https://eupl.eu/1.2/en/" { + t.Fatalf("expected trimmed licence url, got %v", license["url"]) + } + externalDocs := spec["externalDocs"].(map[string]any) + if externalDocs["description"] != "Developer guide" { + t.Fatalf("expected trimmed external docs description, got %v", externalDocs["description"]) + } + if externalDocs["url"] != "https://example.com/docs" { + t.Fatalf("expected trimmed external docs url, got %v", externalDocs["url"]) + } + if got := spec["x-swagger-ui-path"]; got != "/docs" { + t.Fatalf("expected trimmed swagger path, got %v", got) + } +} + func TestSpecBuilder_Good_SwaggerUIPathExtension(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",