From 93f460cb6d9c754d9759385d1b58662a142179d2 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Tue, 6 Jan 2026 23:16:58 -0600 Subject: [PATCH 1/9] Fix wildcard path patterns in OpenAPI spec output for known routers --- openapi.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++- openapi_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/openapi.go b/openapi.go index 35601c7b..9f9684c5 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,54 @@ 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 serveMuxWildcard.MatchString(path) { + return serveMuxWildcard.ReplaceAllString(path, "{$1}") + } + + // Gorilla Mux: {name:.*} -> {name} + if gorillaMuxWildcard.MatchString(path) { + return gorillaMuxWildcard.ReplaceAllString(path, "{$1}") + } + + // Gin, HttpRouter, BunRouter: /*name -> /{name} + if starNameWildcard.MatchString(path) { + return starNameWildcard.ReplaceAllString(path, "/{$1}") + } + + // 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 +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 { + fixed[fixWildcardPath(path)] = item + } + return fixed +} diff --git a/openapi_test.go b/openapi_test.go index 43223155..b11bf0b1 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -265,3 +265,58 @@ func TestDowngrade(t *testing.T) { // Check that the downgrade worked as expected. assert.JSONEq(t, expected, string(v30)) } + +func TestFixWildcardPaths(t *testing.T) { + input := map[string]*huma.PathItem{ + // ServeMux + "/api/{path...}": {}, + "/files/{filepath...}": {}, + // Gorilla Mux + "/mux/{path:.*}": {}, + "/mux/v1/{rest:.*}": {}, + // Gin, HttpRouter, BunRouter + "/gin/*filepath": {}, + "/router/v1/*rest": {}, + // Chi, Echo + "/chi/*": {}, + "/echo/static/*": {}, + // Fiber + "/fiber/+": {}, + "/fiber/assets/+": {}, + // No wildcard (unchanged) + "/users/{id}": {}, + "/api/v1/items": {}, + } + + expected := map[string]bool{ + // ServeMux + "/api/{path}": true, + "/files/{filepath}": true, + // Gorilla Mux + "/mux/{path}": true, + "/mux/v1/{rest}": true, + // Gin, HttpRouter, BunRouter + "/gin/{filepath}": true, + "/router/v1/{rest}": true, + // Chi, Echo + "/chi/{path}": true, + "/echo/static/{path}": true, + // Fiber + "/fiber/{path}": true, + "/fiber/assets/{path}": true, + // No wildcard (unchanged) + "/users/{id}": true, + "/api/v1/items": true, + } + + result := huma.FixWildcardPaths(input) + + require.Len(t, result, len(expected), "result should have same number of paths") + + for path := range result { + assert.True(t, expected[path], "unexpected path in result: %q", path) + } + + // Test nil input + assert.Nil(t, huma.FixWildcardPaths(nil)) +} From f226a2d536466de73b5a5c4516f872ea3f377ad9 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Wed, 7 Jan 2026 15:11:16 -0600 Subject: [PATCH 2/9] Update openapi.go Simplified regex matching and replacement. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- openapi.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openapi.go b/openapi.go index 9f9684c5..be91e434 100644 --- a/openapi.go +++ b/openapi.go @@ -1693,18 +1693,18 @@ var ( // fixWildcardPath converts router-specific wildcard patterns to OpenAPI-compatible path parameters func fixWildcardPath(path string) string { // ServeMux: {name...} -> {name} - if serveMuxWildcard.MatchString(path) { - return serveMuxWildcard.ReplaceAllString(path, "{$1}") + if replaced := serveMuxWildcard.ReplaceAllString(path, "{$1}"); replaced != path { + return replaced } // Gorilla Mux: {name:.*} -> {name} - if gorillaMuxWildcard.MatchString(path) { - return gorillaMuxWildcard.ReplaceAllString(path, "{$1}") + if replaced := gorillaMuxWildcard.ReplaceAllString(path, "{$1}"); replaced != path { + return replaced } // Gin, HttpRouter, BunRouter: /*name -> /{name} - if starNameWildcard.MatchString(path) { - return starNameWildcard.ReplaceAllString(path, "/{$1}") + if replaced := starNameWildcard.ReplaceAllString(path, "/{$1}"); replaced != path { + return replaced } // Chi, Echo, Fiber: trailing /* or /+ -> /{path} From ce19d378780a74cd0d07452cd9bfa82359cca25b Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 00:40:59 -0600 Subject: [PATCH 3/9] Create distinct PathItem pointers so we can verify they are preserved --- openapi_test.go | 63 ++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/openapi_test.go b/openapi_test.go index b11bf0b1..3bd7d61e 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -267,54 +267,63 @@ func TestDowngrade(t *testing.T) { } 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...}": {}, - "/files/{filepath...}": {}, + "/api/{path...}": pathItems[0], + "/files/{filepath...}": pathItems[1], // Gorilla Mux - "/mux/{path:.*}": {}, - "/mux/v1/{rest:.*}": {}, + "/mux/{path:.*}": pathItems[2], + "/mux/v1/{rest:.*}": pathItems[3], // Gin, HttpRouter, BunRouter - "/gin/*filepath": {}, - "/router/v1/*rest": {}, + "/gin/*filepath": pathItems[4], + "/router/v1/*rest": pathItems[5], // Chi, Echo - "/chi/*": {}, - "/echo/static/*": {}, + "/chi/*": pathItems[6], + "/echo/static/*": pathItems[7], // Fiber - "/fiber/+": {}, - "/fiber/assets/+": {}, + "/fiber/+": pathItems[8], + "/fiber/assets/+": pathItems[9], // No wildcard (unchanged) - "/users/{id}": {}, - "/api/v1/items": {}, + "/users/{id}": pathItems[10], + "/api/v1/items": pathItems[11], } - expected := map[string]bool{ + // Map from expected output path to the expected PathItem pointer + expected := map[string]*huma.PathItem{ // ServeMux - "/api/{path}": true, - "/files/{filepath}": true, + "/api/{path}": pathItems[0], + "/files/{filepath}": pathItems[1], // Gorilla Mux - "/mux/{path}": true, - "/mux/v1/{rest}": true, + "/mux/{path}": pathItems[2], + "/mux/v1/{rest}": pathItems[3], // Gin, HttpRouter, BunRouter - "/gin/{filepath}": true, - "/router/v1/{rest}": true, + "/gin/{filepath}": pathItems[4], + "/router/v1/{rest}": pathItems[5], // Chi, Echo - "/chi/{path}": true, - "/echo/static/{path}": true, + "/chi/{path}": pathItems[6], + "/echo/static/{path}": pathItems[7], // Fiber - "/fiber/{path}": true, - "/fiber/assets/{path}": true, + "/fiber/{path}": pathItems[8], + "/fiber/assets/{path}": pathItems[9], // No wildcard (unchanged) - "/users/{id}": true, - "/api/v1/items": true, + "/users/{id}": pathItems[10], + "/api/v1/items": pathItems[11], } result := huma.FixWildcardPaths(input) require.Len(t, result, len(expected), "result should have same number of paths") - for path := range result { - assert.True(t, expected[path], "unexpected path in result: %q", path) + 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 From b1c18ac7c413cf67192b570d7f6a80c351a6e341 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 00:42:45 -0600 Subject: [PATCH 4/9] Improves FixWildcardPaths documentation --- openapi.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openapi.go b/openapi.go index be91e434..7119d7d7 100644 --- a/openapi.go +++ b/openapi.go @@ -1719,7 +1719,20 @@ func fixWildcardPath(path string) string { return path } -// FixWildcardPaths returns a copy of the paths map with wildcard patterns normalized for OpenAPI +// 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 From 0e6c1bd3549d8939086f7e3a9783855504bb6434 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 00:47:46 -0600 Subject: [PATCH 5/9] Fixed len of array to match test cases --- openapi_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi_test.go b/openapi_test.go index 3bd7d61e..3014eca3 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -268,7 +268,7 @@ func TestDowngrade(t *testing.T) { func TestFixWildcardPaths(t *testing.T) { // Create distinct PathItem pointers so we can verify they are preserved - pathItems := make([]*huma.PathItem, 14) + pathItems := make([]*huma.PathItem, 12) for i := range pathItems { pathItems[i] = &huma.PathItem{} } From 69c6e14f0f3091bcb7c8cf2d74a7471b5963b0c7 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 00:52:08 -0600 Subject: [PATCH 6/9] Fixed normalization for rare edge case Should never happen as this would require multiple routers on purpose creating the collision with different syntaxes. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- openapi.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openapi.go b/openapi.go index 7119d7d7..b690f690 100644 --- a/openapi.go +++ b/openapi.go @@ -1739,7 +1739,17 @@ func FixWildcardPaths(paths map[string]*PathItem) map[string]*PathItem { } fixed := make(map[string]*PathItem, len(paths)) for path, item := range paths { - fixed[fixWildcardPath(path)] = item + 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. + if _, exists := fixed[normalized]; exists && normalized != path { + fixed[path] = item + continue + } + + fixed[normalized] = item } return fixed } From 0ca8d4750f5257d20ae902e36da4dfc9a56ed016 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 00:54:41 -0600 Subject: [PATCH 7/9] Added collision edge case to feature and test case --- openapi_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openapi_test.go b/openapi_test.go index 3014eca3..67d7adf8 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -268,7 +268,7 @@ func TestDowngrade(t *testing.T) { func TestFixWildcardPaths(t *testing.T) { // Create distinct PathItem pointers so we can verify they are preserved - pathItems := make([]*huma.PathItem, 12) + pathItems := make([]*huma.PathItem, 14) for i := range pathItems { pathItems[i] = &huma.PathItem{} } @@ -292,6 +292,9 @@ func TestFixWildcardPaths(t *testing.T) { // 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 @@ -314,6 +317,9 @@ func TestFixWildcardPaths(t *testing.T) { // 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) From 3393a2897578e7ab81f9dae4e5e729875d93a0c1 Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 01:04:45 -0600 Subject: [PATCH 8/9] Added test for empty map --- openapi_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/openapi_test.go b/openapi_test.go index 67d7adf8..75cc2f94 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -334,4 +334,5 @@ func TestFixWildcardPaths(t *testing.T) { // Test nil input assert.Nil(t, huma.FixWildcardPaths(nil)) + assert.Empty(t, huma.FixWildcardPaths(map[string]*huma.PathItem{})) } From 7d8957e4a27566a02aa4cc5f049658ddf59f5c8b Mon Sep 17 00:00:00 2001 From: Guillermo Estrada Date: Thu, 8 Jan 2026 01:11:06 -0600 Subject: [PATCH 9/9] Added comment --- openapi.go | 1 + 1 file changed, 1 insertion(+) diff --git a/openapi.go b/openapi.go index b690f690..e937d8f3 100644 --- a/openapi.go +++ b/openapi.go @@ -1744,6 +1744,7 @@ func FixWildcardPaths(paths map[string]*PathItem) map[string]*PathItem { // 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