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 <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 19:21:46 +00:00
parent a055781d5d
commit ea94081231
2 changed files with 78 additions and 1 deletions

View file

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

View file

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