From 7c3e8e7ba63be2980d08086f22e0bd18bc62f77a Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 19:46:07 +0000 Subject: [PATCH] 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 --- openapi.go | 35 ++++++++++++++++++++--------- openapi_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/openapi.go b/openapi.go index 94e014f..4bbf146 100644 --- a/openapi.go +++ b/openapi.go @@ -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 diff --git a/openapi_test.go b/openapi_test.go index b0f737b..b8fc987 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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",