fix(openapi): normalise spec builder metadata

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 14:48:26 +00:00
parent 2b71c78c33
commit 0022931eff
2 changed files with 119 additions and 14 deletions

View file

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

View file

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