Skip to content
78 changes: 77 additions & 1 deletion openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"maps"
"net/http"
"reflect"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -1561,7 +1562,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},
Expand Down Expand Up @@ -1697,3 +1698,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_]*)$`)
)
Comment thread
phrozen marked this conversation as resolved.

// 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}"
}
Comment thread
wolveix marked this conversation as resolved.

// No match, return original
return path
Comment thread
phrozen marked this conversation as resolved.
}

// 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.
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FixWildcardPaths function is exported (public API) primarily to enable testing from the external test package. However, this makes it part of the public API surface, which means it could be called by external users and needs to be maintained for backward compatibility. Consider either: (1) moving the test to an internal test file (package huma) where it can test the unexported version, or (2) if this function is intentionally part of the public API, ensure the documentation clearly indicates its intended use case and that users typically don't need to call it directly.

Suggested change
// are transformed.
// are transformed.
//
// This helper is primarily used internally by huma during OpenAPI generation
// and by tests that validate path normalization behavior. Typical users of the
// huma package do not need to call FixWildcardPaths directly; instead, it is
// applied automatically when serializing the OpenAPI definition.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs direction, testing on a different package is weird. What would make more sense is to review implementation with the current test, and once validated remove it and un-export the function.

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
}
71 changes: 71 additions & 0 deletions openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,77 @@ func TestDowngrade(t *testing.T) {
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)
}
Comment thread
phrozen marked this conversation as resolved.

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)
}
Comment thread
phrozen marked this conversation as resolved.

// Test nil input
assert.Nil(t, huma.FixWildcardPaths(nil))
Comment thread
phrozen marked this conversation as resolved.
assert.Empty(t, huma.FixWildcardPaths(map[string]*huma.PathItem{}))
}

func TestAddOperationForceUniqueOperationIDs(t *testing.T) {
oapi := &huma.OpenAPI{}
oapi.AddOperation(&huma.Operation{
Expand Down