Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions internal/test/servers/routers/trailing_slash/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=../../../../../configuration-schema.json
package: serversrouterstrailingslash
generate:
std-http-server: true
models: true
output: trailing_slash.gen.go
8 changes: 8 additions & 0 deletions internal/test/servers/routers/trailing_slash/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Package serversrouterstrailingslash exercises stdhttp route registration
// for OpenAPI paths with trailing slashes: they must register as
// {$}-anchored exact-match patterns, both to honor OpenAPI's exact-path
// semantics and to avoid ServeMux registration panics when subtree
// patterns from independent paths overlap ambiguously (issue #2065).
package serversrouterstrailingslash

//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml
79 changes: 79 additions & 0 deletions internal/test/servers/routers/trailing_slash/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package serversrouterstrailingslash

import (
Comment thread
mromaszewicz marked this conversation as resolved.
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type server struct {
lastOp string
lastID string
}

func (s *server) Test1(w http.ResponseWriter, r *http.Request, id string) {
s.lastOp, s.lastID = "test1", id
w.WriteHeader(http.StatusOK)
}

func (s *server) Test2(w http.ResponseWriter, r *http.Request) {
s.lastOp = "test2"
w.WriteHeader(http.StatusOK)
}

// TestTrailingSlashRoutes covers issue #2065: the two spec paths overlap
// ambiguously as ServeMux subtree patterns and used to panic inside
// HandlerWithOptions; as {$}-anchored patterns they register and route by
// OpenAPI's exact-path semantics.
func TestTrailingSlashRoutes(t *testing.T) {
s := &server{}

// Registration itself was the panic in the issue.
var h http.Handler
require.NotPanics(t, func() {
h = Handler(s)
})

do := func(path string) (*server, *httptest.ResponseRecorder) {
s.lastOp, s.lastID = "", ""
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return s, rec
}

// Exact paths route to their handlers.
got, rec := do("/api/test/42/test2/")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "test1", got.lastOp)
assert.Equal(t, "42", got.lastID)

got, rec = do("/api/test/test3/")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "test2", got.lastOp)

// The ambiguous path from the issue's panic message matches the
// parameterized route exactly (id="test3"), not both.
got, rec = do("/api/test/test3/test2/")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "test1", got.lastOp)
assert.Equal(t, "test3", got.lastID)

// {$}-anchored patterns are exact matches, not subtrees: paths below a
// trailing-slash route are 404, not swallowed by it.
_, rec = do("/api/test/test3/deeper")
assert.Equal(t, http.StatusNotFound, rec.Code)

// ServeMux still issues its trailing-slash redirect for the anchored
// pattern: a request without the trailing slash is redirected to the
// canonical path rather than 404ing. The redirect status differs
// across Go releases (301 on older toolchains, 307 on newer), so pin
// the redirect and its target rather than the exact code.
_, rec = do("/api/test/test3")
assert.True(t, rec.Code == http.StatusMovedPermanently || rec.Code == http.StatusTemporaryRedirect,
"expected a trailing-slash redirect, got %d", rec.Code)
assert.Equal(t, "/api/test/test3/", rec.Header().Get("Location"))
}
35 changes: 35 additions & 0 deletions internal/test/servers/routers/trailing_slash/spec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ==========================================================================
# Category: servers/routers/trailing_slash
# Tests: OpenAPI paths with trailing slashes register as exact-match
# ServeMux patterns ({$}-anchored) instead of subtree patterns.
#
# Folds in (provenance):
# - issues/issue-2065 (subtree pattern conflict panic at registration)
# ==========================================================================
openapi: "3.0.3"
info:
title: servers/routers/trailing_slash
version: 1.0.0
paths:
# This pair of paths is the issue-2065 repro: as ServeMux subtree
# patterns they overlap ambiguously ("/api/test/test3/test2/" matches
# both) and registration panics; as {$}-anchored exact patterns they
# coexist.
/api/test/{id}/test2/:
get:
operationId: test1
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: ok
/api/test/test3/:
get:
operationId: test2
responses:
'200':
description: ok
198 changes: 198 additions & 0 deletions internal/test/servers/routers/trailing_slash/trailing_slash.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 16 additions & 7 deletions pkg/codegen/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,16 +641,25 @@ func SwaggerUriToGorillaUri(uri string) string {
// {?param}
// {?param*}
func SwaggerUriToStdHttpUri(uri string) string {
// https://pkg.go.dev/net/http#hdr-Patterns-ServeMux
// The special wildcard {$} matches only the end of the URL. For example, the pattern "/{$}" matches only the path "/", whereas the pattern "/" matches every path.
if uri == "/" {
return "/{$}"
}

return pathParamRE.ReplaceAllStringFunc(uri, func(match string) string {
uri = pathParamRE.ReplaceAllStringFunc(uri, func(match string) string {
sub := pathParamRE.FindStringSubmatch(match)
return "{" + SanitizeGoIdentifier(sub[1]) + "}"
})

// https://pkg.go.dev/net/http#hdr-Patterns-ServeMux
// A ServeMux pattern ending in '/' matches the whole subtree below it,
// while an OpenAPI path ending in '/' means exactly that path. The
// special wildcard {$} anchors the pattern to the end of the URL:
// "/foo/{$}" matches only "/foo/", whereas "/foo/" matches every path
// under it. Anchoring also prevents registration panics when subtree
// patterns from independent spec paths overlap ambiguously (#2065).
// Appended after parameter sanitization so the '$' is not treated as a
// parameter name.
if strings.HasSuffix(uri, "/") {
uri += "{$}"
}
Comment thread
mromaszewicz marked this conversation as resolved.

return uri
}

// OrderedParamsFromUri returns the argument names, in order, in a given URI string, so for
Expand Down
8 changes: 8 additions & 0 deletions pkg/codegen/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,14 @@ func TestSwaggerUriToStdHttpUriUri(t *testing.T) {

// Go keywords are valid ServeMux wildcard names and should not be prefixed
assert.Equal(t, "/path/{type}", SwaggerUriToStdHttpUri("/path/{type}"))

// OpenAPI paths with a trailing slash mean exactly that path, but a
// ServeMux pattern ending in '/' matches the whole subtree — and
// overlapping subtree patterns panic at registration. Anchor with {$}.
// Please see https://github.com/oapi-codegen/oapi-codegen/issues/2065
assert.Equal(t, "/path/{$}", SwaggerUriToStdHttpUri("/path/"))
assert.Equal(t, "/api/test/{id}/test2/{$}", SwaggerUriToStdHttpUri("/api/test/{id}/test2/"))
assert.Equal(t, "/api/test/test3/{$}", SwaggerUriToStdHttpUri("/api/test/test3/"))
}

func TestOrderedParamsFromUri(t *testing.T) {
Expand Down