diff --git a/openapi.go b/openapi.go index 35601c7b..e937d8f3 100644 --- a/openapi.go +++ b/openapi.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "reflect" + "regexp" + "strings" "time" "github.com/danielgtaylor/huma/v2/yaml" @@ -1541,7 +1543,7 @@ func (o *OpenAPI) MarshalJSON() ([]byte, error) { {"info", o.Info, omitNever}, {"jsonSchemaDialect", o.JSONSchemaDialect, omitEmpty}, {"servers", o.Servers, omitEmpty}, - {"paths", o.Paths, omitEmpty}, + {"paths", FixWildcardPaths(o.Paths), omitEmpty}, {"webhooks", o.Webhooks, omitEmpty}, {"components", o.Components, omitEmpty}, {"security", o.Security, omitNil}, @@ -1677,3 +1679,78 @@ func (o *OpenAPI) DowngradeYAML() ([]byte, error) { } return buf.Bytes(), err } + +// Patterns for router-specific wildcards +var ( + // Matches {name...} (ServeMux) + serveMuxWildcard = regexp.MustCompile(`\{([^}]+)\.\.\.}`) + // Matches {name:.*} (Gorilla Mux) + gorillaMuxWildcard = regexp.MustCompile(`\{([^:}]+):\.\*}`) + // Matches *name at end of path (Gin, HttpRouter, BunRouter) + starNameWildcard = regexp.MustCompile(`/\*([a-zA-Z_][a-zA-Z0-9_]*)$`) +) + +// fixWildcardPath converts router-specific wildcard patterns to OpenAPI-compatible path parameters +func fixWildcardPath(path string) string { + // ServeMux: {name...} -> {name} + if replaced := serveMuxWildcard.ReplaceAllString(path, "{$1}"); replaced != path { + return replaced + } + + // Gorilla Mux: {name:.*} -> {name} + if replaced := gorillaMuxWildcard.ReplaceAllString(path, "{$1}"); replaced != path { + return replaced + } + + // Gin, HttpRouter, BunRouter: /*name -> /{name} + if replaced := starNameWildcard.ReplaceAllString(path, "/{$1}"); replaced != path { + return replaced + } + + // Chi, Echo, Fiber: trailing /* or /+ -> /{path} + if strings.HasSuffix(path, "/*") { + return strings.TrimSuffix(path, "/*") + "/{path}" + } + if strings.HasSuffix(path, "/+") { + return strings.TrimSuffix(path, "/+") + "/{path}" + } + + // No match, return original + return path +} + +// FixWildcardPaths returns a copy of the paths map with wildcard patterns normalized for OpenAPI. +// +// Different routers use different syntax for wildcard/catch-all path parameters +// (e.g., {path...}, /*name, /*, /+), but the OpenAPI specification only supports +// the standard {paramName} format. This function transforms router-specific +// wildcard patterns into OpenAPI-compatible path parameters. +// +// This transformation is applied during JSON marshaling of the OpenAPI spec, +// so the internal Paths map retains the original router-specific patterns for +// correct request routing, while the generated OpenAPI document uses standard +// path parameter syntax for compatibility with OpenAPI tools and clients. +// +// The PathItem values are preserved (same pointer references), only the map keys +// are transformed. +func FixWildcardPaths(paths map[string]*PathItem) map[string]*PathItem { + if paths == nil { + return nil + } + fixed := make(map[string]*PathItem, len(paths)) + for path, item := range paths { + normalized := fixWildcardPath(path) + + // If normalization causes a collision (multiple original paths mapping + // to the same normalized key), fall back to the original path to avoid + // silently dropping routes from the OpenAPI spec. + // Non-deterministic due to map iteration order, but collisions should be rare in practice. + if _, exists := fixed[normalized]; exists && normalized != path { + fixed[path] = item + continue + } + + fixed[normalized] = item + } + return fixed +} diff --git a/openapi_test.go b/openapi_test.go index 43223155..75cc2f94 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -265,3 +265,74 @@ func TestDowngrade(t *testing.T) { // Check that the downgrade worked as expected. assert.JSONEq(t, expected, string(v30)) } + +func TestFixWildcardPaths(t *testing.T) { + // Create distinct PathItem pointers so we can verify they are preserved + pathItems := make([]*huma.PathItem, 14) + for i := range pathItems { + pathItems[i] = &huma.PathItem{} + } + + input := map[string]*huma.PathItem{ + // ServeMux + "/api/{path...}": pathItems[0], + "/files/{filepath...}": pathItems[1], + // Gorilla Mux + "/mux/{path:.*}": pathItems[2], + "/mux/v1/{rest:.*}": pathItems[3], + // Gin, HttpRouter, BunRouter + "/gin/*filepath": pathItems[4], + "/router/v1/*rest": pathItems[5], + // Chi, Echo + "/chi/*": pathItems[6], + "/echo/static/*": pathItems[7], + // Fiber + "/fiber/+": pathItems[8], + "/fiber/assets/+": pathItems[9], + // No wildcard (unchanged) + "/users/{id}": pathItems[10], + "/api/v1/items": pathItems[11], + // Collision with existing path (should never happen in practice) + "/collision/{path}": pathItems[12], + "/collision/{path...}": pathItems[13], + } + + // Map from expected output path to the expected PathItem pointer + expected := map[string]*huma.PathItem{ + // ServeMux + "/api/{path}": pathItems[0], + "/files/{filepath}": pathItems[1], + // Gorilla Mux + "/mux/{path}": pathItems[2], + "/mux/v1/{rest}": pathItems[3], + // Gin, HttpRouter, BunRouter + "/gin/{filepath}": pathItems[4], + "/router/v1/{rest}": pathItems[5], + // Chi, Echo + "/chi/{path}": pathItems[6], + "/echo/static/{path}": pathItems[7], + // Fiber + "/fiber/{path}": pathItems[8], + "/fiber/assets/{path}": pathItems[9], + // No wildcard (unchanged) + "/users/{id}": pathItems[10], + "/api/v1/items": pathItems[11], + // Collision with existing path (should never happen in practice) + "/collision/{path}": pathItems[12], // original remains + "/collision/{path...}": pathItems[13], // unchanged (conflict) + } + + result := huma.FixWildcardPaths(input) + + require.Len(t, result, len(expected), "result should have same number of paths") + + for path, expectedItem := range expected { + actualItem, exists := result[path] + assert.True(t, exists, "expected path not in result: %q", path) + assert.Same(t, expectedItem, actualItem, "PathItem for path %q should be preserved", path) + } + + // Test nil input + assert.Nil(t, huma.FixWildcardPaths(nil)) + assert.Empty(t, huma.FixWildcardPaths(map[string]*huma.PathItem{})) +}