Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"reflect"
"regexp"
"strings"
"time"

"github.com/danielgtaylor/huma/v2/yaml"
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -1677,3 +1679,77 @@ 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}"
}
Comment on lines +1710 to +1716
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 hardcoded parameter name "path" used for Chi, Echo, and Fiber wildcards (lines 1712 and 1715) might not be semantically meaningful in all contexts. For example, a route like "/assets/*" would become "/assets/{path}", but "path" might not be the best descriptor for asset files. Consider documenting this naming choice or explaining why a generic name was chosen over attempting to infer context from the route structure.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Needs concensus, path makes the most sense, because wildcards are meant to capture the rest of the path. Left open for opinions.


// 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.
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
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.
if _, exists := fixed[normalized]; exists && normalized != path {
fixed[path] = item
continue
}

fixed[normalized] = item
}
return fixed
}
70 changes: 70 additions & 0 deletions openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,73 @@ 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))
}