From ea94081231435ab9a0d4147ec0bb32806009a2dd Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 19:21:46 +0000 Subject: [PATCH] feat(api): normalise OpenAPI path joins Normalise the concatenation of BasePath() and RouteDescription paths so trailing or missing slashes do not produce malformed OpenAPI entries. Add a regression test for mixed slash formatting. Co-Authored-By: Virgil --- openapi.go | 42 +++++++++++++++++++++++++++++++++++++++++- openapi_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/openapi.go b/openapi.go index 35aa67c..07ba581 100644 --- a/openapi.go +++ b/openapi.go @@ -116,7 +116,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { continue } for _, rd := range dg.Describe() { - fullPath := g.BasePath() + rd.Path + fullPath := joinOpenAPIPath(g.BasePath(), rd.Path) method := strings.ToLower(rd.Method) operation := map[string]any{ @@ -174,6 +174,46 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { return paths } +// joinOpenAPIPath normalises a base path and relative route path into a single +// OpenAPI path without duplicate or missing separators. +func joinOpenAPIPath(basePath, routePath string) string { + basePath = strings.TrimSpace(basePath) + routePath = strings.TrimSpace(routePath) + + if basePath == "" { + basePath = "/" + } + if routePath == "" || routePath == "/" { + return normaliseOpenAPIPath(basePath) + } + + basePath = normaliseOpenAPIPath(basePath) + routePath = strings.TrimLeft(routePath, "/") + + if basePath == "/" { + return "/" + routePath + } + + return basePath + "/" + routePath +} + +// normaliseOpenAPIPath trims whitespace and collapses trailing separators +// while preserving the root path. +func normaliseOpenAPIPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + path = strings.TrimRight(path, "/") + if path == "" { + return "/" + } + return path +} + // operationResponses builds the standard response set for a documented API // operation. The framework always exposes the common envelope responses, plus // middleware-driven 429 and 504 errors. diff --git a/openapi_test.go b/openapi_test.go index b75f3ea..b4b7ca9 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -624,6 +624,43 @@ func TestSpecBuilder_Good_PathParameters(t *testing.T) { } } +func TestSpecBuilder_Good_PathNormalisation(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "users", + basePath: "/api/", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "users/{id}", + Summary: "Get user", + Response: map[string]any{ + "type": "object", + }, + }, + }, + } + + 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) + } + + paths := spec["paths"].(map[string]any) + if _, ok := paths["/api/users/{id}"]; !ok { + t.Fatalf("expected normalised path /api/users/{id}, got %v", paths) + } +} + func TestSpecBuilder_Good_ExplicitParameters(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",