feat(openapi): support gin-style path params

Normalise Gin colon and wildcard route segments into OpenAPI template parameters so documented paths match the framework's route syntax.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 19:46:07 +00:00
parent 13cc93f4f4
commit 7c3e8e7ba6
2 changed files with 85 additions and 10 deletions

View file

@ -177,7 +177,8 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
}
// joinOpenAPIPath normalises a base path and relative route path into a single
// OpenAPI path without duplicate or missing separators.
// OpenAPI path without duplicate or missing separators. Gin-style parameters
// such as :id and *path are converted to OpenAPI template parameters.
func joinOpenAPIPath(basePath, routePath string) string {
basePath = strings.TrimSpace(basePath)
routePath = strings.TrimSpace(routePath)
@ -190,30 +191,44 @@ func joinOpenAPIPath(basePath, routePath string) string {
}
basePath = normaliseOpenAPIPath(basePath)
routePath = strings.TrimLeft(routePath, "/")
routePath = normaliseOpenAPIPath(routePath)
if basePath == "/" {
return "/" + routePath
return routePath
}
return basePath + "/" + routePath
return strings.TrimRight(basePath, "/") + "/" + strings.TrimPrefix(routePath, "/")
}
// normaliseOpenAPIPath trims whitespace and collapses trailing separators
// while preserving the root path.
// while preserving the root path and converting Gin-style path parameters.
func normaliseOpenAPIPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
segments := strings.Split(path, "/")
cleaned := make([]string, 0, len(segments))
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
switch {
case strings.HasPrefix(segment, ":") && len(segment) > 1:
segment = "{" + segment[1:] + "}"
case strings.HasPrefix(segment, "*") && len(segment) > 1:
segment = "{" + segment[1:] + "}"
}
cleaned = append(cleaned, segment)
}
path = strings.TrimRight(path, "/")
if path == "" {
if len(cleaned) == 0 {
return "/"
}
return path
return "/" + strings.Join(cleaned, "/")
}
// operationResponses builds the standard response set for a documented API

View file

@ -661,6 +661,66 @@ func TestSpecBuilder_Good_PathNormalisation(t *testing.T) {
}
}
func TestSpecBuilder_Good_GinPathParameters(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",
},
},
{
Method: "GET",
Path: "files/*path",
Summary: "Get file",
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)
userOp := paths["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
userParams := userOp["parameters"].([]any)
if len(userParams) != 1 {
t.Fatalf("expected 1 parameter for gin path, got %d", len(userParams))
}
if userParams[0].(map[string]any)["name"] != "id" {
t.Fatalf("expected gin path parameter name=id, got %v", userParams[0])
}
fileOp := paths["/api/files/{path}"].(map[string]any)["get"].(map[string]any)
fileParams := fileOp["parameters"].([]any)
if len(fileParams) != 1 {
t.Fatalf("expected 1 parameter for wildcard path, got %d", len(fileParams))
}
if fileParams[0].(map[string]any)["name"] != "path" {
t.Fatalf("expected wildcard parameter name=path, got %v", fileParams[0])
}
}
func TestSpecBuilder_Good_ExplicitParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",