From 22c8c074099bc795d62d14bacf228e2c677c0d81 Mon Sep 17 00:00:00 2001 From: cosban Date: Mon, 28 Oct 2024 12:31:13 -0600 Subject: [PATCH 1/5] add literal colon support for gin and echo --- pkg/codegen/utils.go | 2 ++ pkg/codegen/utils_test.go | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/pkg/codegen/utils.go b/pkg/codegen/utils.go index e82d5e3e3c..6c75b568de 100644 --- a/pkg/codegen/utils.go +++ b/pkg/codegen/utils.go @@ -543,6 +543,7 @@ func SwaggerUriToIrisUri(uri string) string { // {?param} // {?param*} func SwaggerUriToEchoUri(uri string) string { + uri = strings.ReplaceAll(uri, ":", "\\:") return pathParamRE.ReplaceAllString(uri, ":$1") } @@ -591,6 +592,7 @@ func SwaggerUriToChiUri(uri string) string { // {?param} // {?param*} func SwaggerUriToGinUri(uri string) string { + uri = strings.ReplaceAll(uri, ":", "\\:") return pathParamRE.ReplaceAllString(uri, ":$1") } diff --git a/pkg/codegen/utils_test.go b/pkg/codegen/utils_test.go index 28f9f3f3dd..86260955de 100644 --- a/pkg/codegen/utils_test.go +++ b/pkg/codegen/utils_test.go @@ -368,6 +368,9 @@ func TestSwaggerUriToEchoUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToEchoUri("/path/{;arg*}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToEchoUri("/path/{?arg}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToEchoUri("/path/{?arg*}/foo")) + + // Make sure literal colons are escaped + assert.Equal(t, "/path/:arg\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) } func TestSwaggerUriToGinUri(t *testing.T) { @@ -385,6 +388,9 @@ func TestSwaggerUriToGinUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToGinUri("/path/{;arg*}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToGinUri("/path/{?arg}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToGinUri("/path/{?arg*}/foo")) + + // Make sure literal colons are escaped + assert.Equal(t, "/path/:arg\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) } func TestSwaggerUriToGorillaUri(t *testing.T) { // TODO From b045ccbe6928d74f0d83c5190ede6b53cd53c9e9 Mon Sep 17 00:00:00 2001 From: cosban Date: Mon, 28 Oct 2024 12:43:57 -0600 Subject: [PATCH 2/5] backslashes should themselves be escaped too --- internal/test/issues/issue-312/issue.gen.go | 2 +- pkg/codegen/utils.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/test/issues/issue-312/issue.gen.go b/internal/test/issues/issue-312/issue.gen.go index f0285c071f..787a65e8bc 100644 --- a/internal/test/issues/issue-312/issue.gen.go +++ b/internal/test/issues/issue-312/issue.gen.go @@ -488,7 +488,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } router.GET(baseURL+"/pets/:petId", wrapper.GetPet) - router.POST(baseURL+"/pets:validate", wrapper.ValidatePets) + router.POST(baseURL+"/pets\\:validate", wrapper.ValidatePets) } diff --git a/pkg/codegen/utils.go b/pkg/codegen/utils.go index 6c75b568de..85c4a62333 100644 --- a/pkg/codegen/utils.go +++ b/pkg/codegen/utils.go @@ -543,7 +543,7 @@ func SwaggerUriToIrisUri(uri string) string { // {?param} // {?param*} func SwaggerUriToEchoUri(uri string) string { - uri = strings.ReplaceAll(uri, ":", "\\:") + uri = strings.ReplaceAll(uri, ":", "\\\\:") return pathParamRE.ReplaceAllString(uri, ":$1") } @@ -592,7 +592,7 @@ func SwaggerUriToChiUri(uri string) string { // {?param} // {?param*} func SwaggerUriToGinUri(uri string) string { - uri = strings.ReplaceAll(uri, ":", "\\:") + uri = strings.ReplaceAll(uri, ":", "\\\\:") return pathParamRE.ReplaceAllString(uri, ":$1") } From a58b695c2a26e63f173027f7e618db868fef51eb Mon Sep 17 00:00:00 2001 From: cosban Date: Mon, 28 Oct 2024 12:45:53 -0600 Subject: [PATCH 3/5] and of course, the tests need to be updated to account for the backslashed backslashes --- pkg/codegen/utils_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/codegen/utils_test.go b/pkg/codegen/utils_test.go index 86260955de..e4bd8e5587 100644 --- a/pkg/codegen/utils_test.go +++ b/pkg/codegen/utils_test.go @@ -370,7 +370,7 @@ func TestSwaggerUriToEchoUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToEchoUri("/path/{?arg*}/foo")) // Make sure literal colons are escaped - assert.Equal(t, "/path/:arg\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) + assert.Equal(t, "/path/:arg\\\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) } func TestSwaggerUriToGinUri(t *testing.T) { @@ -390,7 +390,7 @@ func TestSwaggerUriToGinUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToGinUri("/path/{?arg*}/foo")) // Make sure literal colons are escaped - assert.Equal(t, "/path/:arg\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) + assert.Equal(t, "/path/:arg\\\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) } func TestSwaggerUriToGorillaUri(t *testing.T) { // TODO From 3053bdb519f378e7ae10ff36d480bfa785e0cd7f Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 16 Jul 2026 11:30:35 -0700 Subject: [PATCH 4/5] Complete literal-colon path support for gin, echo, and fiber Builds on the original fix by escaping literal colons in the Fiber URI converter as well, and corrects the escaping to match the register templates' current behaviour. The register templates render the converted path through toGoString (strconv.Quote), so the converter must emit a single backslash (`\:`); the quoting turns it into `\\:` in the generated source, which is the value the router sees at runtime. The prior double-backslash escape predated the templates' switch to toGoString and would emit `\\:` to the router, which Echo rejects (Not Found) and Gin panics on. A shared escapeLiteralPathColons helper now feeds SwaggerUriToEchoUri, SwaggerUriToGinUri, and SwaggerUriToFiberUri. The Fiber v3 handler template interpolated the path raw inside quotes; a backslash would produce invalid Go, so it now uses toGoString like every other server template. Iris and the `{}`-style routers (Chi, Gorilla, net/http) are unaffected: Iris already routes literal colons correctly and escaping breaks it, and the others do not treat `:` specially. Adds a paths/ regression fixture that registers two colon paths sharing a prefix (/pets:validate, /pets:generate) on Echo, Gin, Fiber v2, and Fiber v3 and asserts each dispatches to its own handler. Moves the existing path_edge_cases fixture under paths/ and regenerates it with the escaped route. --- internal/test/paths/colon_paths_test.go | 101 ++++++++++++++++++ internal/test/paths/doc.go | 10 ++ internal/test/paths/echo/colon.gen.go | 93 ++++++++++++++++ internal/test/paths/echo/config.yaml | 6 ++ internal/test/paths/fiber/colon.gen.go | 92 ++++++++++++++++ internal/test/paths/fiber/config.yaml | 6 ++ internal/test/paths/fiberv3/colon.gen.go | 92 ++++++++++++++++ internal/test/paths/fiberv3/config.yaml | 6 ++ internal/test/paths/gin/colon.gen.go | 84 +++++++++++++++ internal/test/paths/gin/config.yaml | 6 ++ .../path_edge_cases/config.yaml | 2 +- .../path_edge_cases/doc.go | 4 +- .../path_edge_cases/path_edge_cases.gen.go | 6 +- .../path_edge_cases/path_edge_cases_test.go | 2 +- .../path_edge_cases/spec.yaml | 0 internal/test/paths/spec.yaml | 29 +++++ .../templates/fiber-v3/fiber-handler.tmpl | 2 +- pkg/codegen/utils.go | 22 +++- pkg/codegen/utils_test.go | 14 ++- 19 files changed, 563 insertions(+), 14 deletions(-) create mode 100644 internal/test/paths/colon_paths_test.go create mode 100644 internal/test/paths/doc.go create mode 100644 internal/test/paths/echo/colon.gen.go create mode 100644 internal/test/paths/echo/config.yaml create mode 100644 internal/test/paths/fiber/colon.gen.go create mode 100644 internal/test/paths/fiber/config.yaml create mode 100644 internal/test/paths/fiberv3/colon.gen.go create mode 100644 internal/test/paths/fiberv3/config.yaml create mode 100644 internal/test/paths/gin/colon.gen.go create mode 100644 internal/test/paths/gin/config.yaml rename internal/test/{parameters => paths}/path_edge_cases/config.yaml (84%) rename internal/test/{parameters => paths}/path_edge_cases/doc.go (71%) rename internal/test/{parameters => paths}/path_edge_cases/path_edge_cases.gen.go (99%) rename internal/test/{parameters => paths}/path_edge_cases/path_edge_cases_test.go (99%) rename internal/test/{parameters => paths}/path_edge_cases/spec.yaml (100%) create mode 100644 internal/test/paths/spec.yaml diff --git a/internal/test/paths/colon_paths_test.go b/internal/test/paths/colon_paths_test.go new file mode 100644 index 0000000000..9585bb9012 --- /dev/null +++ b/internal/test/paths/colon_paths_test.go @@ -0,0 +1,101 @@ +package paths + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + fiberv2 "github.com/gofiber/fiber/v2" + fiberv3 "github.com/gofiber/fiber/v3" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + colonecho "github.com/oapi-codegen/oapi-codegen/v2/internal/test/paths/echo" + colonfiber "github.com/oapi-codegen/oapi-codegen/v2/internal/test/paths/fiber" + colonfiberv3 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/paths/fiberv3" + colongin "github.com/oapi-codegen/oapi-codegen/v2/internal/test/paths/gin" +) + +// Each handler writes its own operation name so the test can prove that +// /pets:validate and /pets:generate dispatch to distinct handlers rather than +// colliding on a single ":"-parameter route (issue #1726). + +type echoServer struct{} + +func (echoServer) ValidatePets(ctx echo.Context) error { return ctx.String(http.StatusOK, "validate") } +func (echoServer) GeneratePets(ctx echo.Context) error { return ctx.String(http.StatusOK, "generate") } + +type ginServer struct{} + +func (ginServer) ValidatePets(c *gin.Context) { c.String(http.StatusOK, "validate") } +func (ginServer) GeneratePets(c *gin.Context) { c.String(http.StatusOK, "generate") } + +type fiberServer struct{} + +func (fiberServer) ValidatePets(c *fiberv2.Ctx) error { return c.SendString("validate") } +func (fiberServer) GeneratePets(c *fiberv2.Ctx) error { return c.SendString("generate") } + +type fiberv3Server struct{} + +func (fiberv3Server) ValidatePets(c fiberv3.Ctx) error { return c.SendString("validate") } +func (fiberv3Server) GeneratePets(c fiberv3.Ctx) error { return c.SendString("generate") } + +// wantBody maps each colon path to the body its dedicated handler returns. +var wantBody = map[string]string{ + "/pets:validate": "validate", + "/pets:generate": "generate", +} + +// assertServeHTTP drives an http.Handler-compatible router (echo, gin). +func assertServeHTTP(t *testing.T, name string, h http.Handler) { + t.Helper() + for path, want := range wantBody { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, path, nil) + h.ServeHTTP(rec, req) + assert.Equalf(t, http.StatusOK, rec.Code, "%s POST %s status", name, path) + assert.Equalf(t, want, rec.Body.String(), "%s POST %s dispatched to wrong handler", name, path) + } +} + +func TestEchoColonPathsRouteIndependently(t *testing.T) { + e := echo.New() + colonecho.RegisterHandlers(e, echoServer{}) + assertServeHTTP(t, "echo", e) +} + +func TestGinColonPathsRouteIndependently(t *testing.T) { + gin.SetMode(gin.TestMode) + g := gin.New() + colongin.RegisterHandlers(g, ginServer{}) + assertServeHTTP(t, "gin", g) +} + +func TestFiberColonPathsRouteIndependently(t *testing.T) { + app := fiberv2.New() + colonfiber.RegisterHandlers(app, fiberServer{}) + for path, want := range wantBody { + req := httptest.NewRequest(http.MethodPost, path, nil) + resp, err := app.Test(req) + require.NoErrorf(t, err, "fiber POST %s", path) + assert.Equalf(t, http.StatusOK, resp.StatusCode, "fiber POST %s status", path) + body, _ := io.ReadAll(resp.Body) + assert.Equalf(t, want, string(body), "fiber POST %s dispatched to wrong handler", path) + } +} + +func TestFiberV3ColonPathsRouteIndependently(t *testing.T) { + app := fiberv3.New() + colonfiberv3.RegisterHandlers(app, fiberv3Server{}) + for path, want := range wantBody { + req := httptest.NewRequest(http.MethodPost, path, nil) + resp, err := app.Test(req) + require.NoErrorf(t, err, "fiberv3 POST %s", path) + assert.Equalf(t, http.StatusOK, resp.StatusCode, "fiberv3 POST %s status", path) + body, _ := io.ReadAll(resp.Body) + assert.Equalf(t, want, string(body), "fiberv3 POST %s dispatched to wrong handler", path) + } +} diff --git a/internal/test/paths/doc.go b/internal/test/paths/doc.go new file mode 100644 index 0000000000..0c036900cb --- /dev/null +++ b/internal/test/paths/doc.go @@ -0,0 +1,10 @@ +// Package paths verifies path-handling edge cases in generated servers. It +// currently checks that literal-colon path segments route independently on the +// ':'-parameter routers (Echo, Gin, Fiber v2/v3) rather than colliding as a +// single path parameter. See issue #1726. +package paths + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=echo/config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=gin/config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=fiber/config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=fiberv3/config.yaml spec.yaml diff --git a/internal/test/paths/echo/colon.gen.go b/internal/test/paths/echo/colon.gen.go new file mode 100644 index 0000000000..f8a0800baf --- /dev/null +++ b/internal/test/paths/echo/colon.gen.go @@ -0,0 +1,93 @@ +// Package colonpathsecho provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package colonpathsecho + +import ( + "github.com/labstack/echo/v4" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /pets:generate) + GeneratePets(ctx echo.Context) error + + // (POST /pets:validate) + ValidatePets(ctx echo.Context) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GeneratePets converts echo context to params. +func (w *ServerInterfaceWrapper) GeneratePets(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GeneratePets(ctx) + return err +} + +// ValidatePets converts echo context to params. +func (w *ServerInterfaceWrapper) ValidatePets(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ValidatePets(ctx) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) +} + +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.POST(options.BaseURL+"/pets\\:validate", wrapper.ValidatePets, options.OperationMiddlewares["validatePets"]...) + router.POST(options.BaseURL+"/pets\\:generate", wrapper.GeneratePets, options.OperationMiddlewares["generatePets"]...) + +} diff --git a/internal/test/paths/echo/config.yaml b/internal/test/paths/echo/config.yaml new file mode 100644 index 0000000000..3b49f3bc75 --- /dev/null +++ b/internal/test/paths/echo/config.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../../../configuration-schema.json +package: colonpathsecho +generate: + echo-server: true + models: true +output: echo/colon.gen.go diff --git a/internal/test/paths/fiber/colon.gen.go b/internal/test/paths/fiber/colon.gen.go new file mode 100644 index 0000000000..5708b7108a --- /dev/null +++ b/internal/test/paths/fiber/colon.gen.go @@ -0,0 +1,92 @@ +// Package colonpathsfiber provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package colonpathsfiber + +import ( + "github.com/gofiber/fiber/v2" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /pets:generate) + GeneratePets(c *fiber.Ctx) error + + // (POST /pets:validate) + ValidatePets(c *fiber.Ctx) error +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []HandlerMiddlewareFunc +} + +type MiddlewareFunc fiber.Handler +type HandlerMiddlewareFunc func(c *fiber.Ctx, next fiber.Handler) error + +// GeneratePets operation middleware +func (siw *ServerInterfaceWrapper) GeneratePets(c *fiber.Ctx) error { + + handler := func(c *fiber.Ctx) error { + return siw.Handler.GeneratePets(c) + } + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + m := siw.HandlerMiddlewares[i] + next := handler + handler = func(c *fiber.Ctx) error { + return m(c, next) + } + } + + return handler(c) +} + +// ValidatePets operation middleware +func (siw *ServerInterfaceWrapper) ValidatePets(c *fiber.Ctx) error { + + handler := func(c *fiber.Ctx) error { + return siw.Handler.ValidatePets(c) + } + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + m := siw.HandlerMiddlewares[i] + next := handler + handler = func(c *fiber.Ctx) error { + return m(c, next) + } + } + + return handler(c) +} + +// FiberServerOptions provides options for the Fiber server. +type FiberServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + HandlerMiddlewares []HandlerMiddlewareFunc +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router fiber.Router, si ServerInterface) { + RegisterHandlersWithOptions(router, si, FiberServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router fiber.Router, si ServerInterface, options FiberServerOptions) { + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.HandlerMiddlewares, + } + + for _, m := range options.Middlewares { + router.Use(fiber.Handler(m)) + } + + router.Post(options.BaseURL+"/pets\\:validate", wrapper.ValidatePets) + + router.Post(options.BaseURL+"/pets\\:generate", wrapper.GeneratePets) + +} diff --git a/internal/test/paths/fiber/config.yaml b/internal/test/paths/fiber/config.yaml new file mode 100644 index 0000000000..55576ee5d3 --- /dev/null +++ b/internal/test/paths/fiber/config.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../../../configuration-schema.json +package: colonpathsfiber +generate: + fiber-server: true + models: true +output: fiber/colon.gen.go diff --git a/internal/test/paths/fiberv3/colon.gen.go b/internal/test/paths/fiberv3/colon.gen.go new file mode 100644 index 0000000000..02bb08703a --- /dev/null +++ b/internal/test/paths/fiberv3/colon.gen.go @@ -0,0 +1,92 @@ +// Package colonpathsfiberv3 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package colonpathsfiberv3 + +import ( + "github.com/gofiber/fiber/v3" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /pets:generate) + GeneratePets(c fiber.Ctx) error + + // (POST /pets:validate) + ValidatePets(c fiber.Ctx) error +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []HandlerMiddlewareFunc +} + +type MiddlewareFunc fiber.Handler +type HandlerMiddlewareFunc func(c fiber.Ctx, next fiber.Handler) error + +// GeneratePets operation middleware +func (siw *ServerInterfaceWrapper) GeneratePets(c fiber.Ctx) error { + + handler := func(c fiber.Ctx) error { + return siw.Handler.GeneratePets(c) + } + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + m := siw.HandlerMiddlewares[i] + next := handler + handler = func(c fiber.Ctx) error { + return m(c, next) + } + } + + return handler(c) +} + +// ValidatePets operation middleware +func (siw *ServerInterfaceWrapper) ValidatePets(c fiber.Ctx) error { + + handler := func(c fiber.Ctx) error { + return siw.Handler.ValidatePets(c) + } + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + m := siw.HandlerMiddlewares[i] + next := handler + handler = func(c fiber.Ctx) error { + return m(c, next) + } + } + + return handler(c) +} + +// FiberServerOptions provides options for the Fiber server. +type FiberServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + HandlerMiddlewares []HandlerMiddlewareFunc +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router fiber.Router, si ServerInterface) { + RegisterHandlersWithOptions(router, si, FiberServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router fiber.Router, si ServerInterface, options FiberServerOptions) { + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.HandlerMiddlewares, + } + + for _, m := range options.Middlewares { + router.Use(fiber.Handler(m)) + } + + router.Post(options.BaseURL+"/pets\\:validate", wrapper.ValidatePets) + + router.Post(options.BaseURL+"/pets\\:generate", wrapper.GeneratePets) + +} diff --git a/internal/test/paths/fiberv3/config.yaml b/internal/test/paths/fiberv3/config.yaml new file mode 100644 index 0000000000..e9417b39b0 --- /dev/null +++ b/internal/test/paths/fiberv3/config.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../../../configuration-schema.json +package: colonpathsfiberv3 +generate: + fiber-v3-server: true + models: true +output: fiberv3/colon.gen.go diff --git a/internal/test/paths/gin/colon.gen.go b/internal/test/paths/gin/colon.gen.go new file mode 100644 index 0000000000..ee4fdf8e91 --- /dev/null +++ b/internal/test/paths/gin/colon.gen.go @@ -0,0 +1,84 @@ +// Package colonpathsgin provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package colonpathsgin + +import ( + "github.com/gin-gonic/gin" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /pets:generate) + GeneratePets(c *gin.Context) + + // (POST /pets:validate) + ValidatePets(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GeneratePets operation middleware +func (siw *ServerInterfaceWrapper) GeneratePets(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GeneratePets(c) +} + +// ValidatePets operation middleware +func (siw *ServerInterfaceWrapper) ValidatePets(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.ValidatePets(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/pets\\:validate", wrapper.ValidatePets) + router.POST(options.BaseURL+"/pets\\:generate", wrapper.GeneratePets) +} diff --git a/internal/test/paths/gin/config.yaml b/internal/test/paths/gin/config.yaml new file mode 100644 index 0000000000..514642cb4d --- /dev/null +++ b/internal/test/paths/gin/config.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../../../configuration-schema.json +package: colonpathsgin +generate: + gin-server: true + models: true +output: gin/colon.gen.go diff --git a/internal/test/parameters/path_edge_cases/config.yaml b/internal/test/paths/path_edge_cases/config.yaml similarity index 84% rename from internal/test/parameters/path_edge_cases/config.yaml rename to internal/test/paths/path_edge_cases/config.yaml index 7da785d168..2e13feceac 100644 --- a/internal/test/parameters/path_edge_cases/config.yaml +++ b/internal/test/paths/path_edge_cases/config.yaml @@ -1,5 +1,5 @@ # yaml-language-server: $schema=../../../../configuration-schema.json -package: parameterspathedgecases +package: pathedgecases generate: echo-server: true client: true diff --git a/internal/test/parameters/path_edge_cases/doc.go b/internal/test/paths/path_edge_cases/doc.go similarity index 71% rename from internal/test/parameters/path_edge_cases/doc.go rename to internal/test/paths/path_edge_cases/doc.go index 555de1c044..43bf3efba1 100644 --- a/internal/test/parameters/path_edge_cases/doc.go +++ b/internal/test/paths/path_edge_cases/doc.go @@ -1,6 +1,6 @@ -// Package parameterspathedgecases covers special cases in path handling: +// Package pathedgecases covers special cases in path handling: // colons in paths and URL escaping of reserved characters (issue #312), and // operation-level parameter definitions overriding path-level ones (issue #1180). -package parameterspathedgecases +package pathedgecases //go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/parameters/path_edge_cases/path_edge_cases.gen.go b/internal/test/paths/path_edge_cases/path_edge_cases.gen.go similarity index 99% rename from internal/test/parameters/path_edge_cases/path_edge_cases.gen.go rename to internal/test/paths/path_edge_cases/path_edge_cases.gen.go index b1128a9db3..c51596f097 100644 --- a/internal/test/parameters/path_edge_cases/path_edge_cases.gen.go +++ b/internal/test/paths/path_edge_cases/path_edge_cases.gen.go @@ -1,7 +1,7 @@ -// Package parameterspathedgecases provides primitives to interact with the openapi HTTP API. +// Package pathedgecases provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package parameterspathedgecases +package pathedgecases import ( "bytes" @@ -737,7 +737,7 @@ func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options Handler: si, } - router.POST(options.BaseURL+"/pets:validate", wrapper.ValidatePets, options.OperationMiddlewares["validatePets"]...) + router.POST(options.BaseURL+"/pets\\:validate", wrapper.ValidatePets, options.OperationMiddlewares["validatePets"]...) router.GET(options.BaseURL+"/pets/:petId", wrapper.GetPet, options.OperationMiddlewares["getPet"]...) router.GET(options.BaseURL+"/simplePrimitive/:param", wrapper.GetSimplePrimitive, options.OperationMiddlewares["getSimplePrimitive"]...) diff --git a/internal/test/parameters/path_edge_cases/path_edge_cases_test.go b/internal/test/paths/path_edge_cases/path_edge_cases_test.go similarity index 99% rename from internal/test/parameters/path_edge_cases/path_edge_cases_test.go rename to internal/test/paths/path_edge_cases/path_edge_cases_test.go index 3158521a6e..562abbd000 100644 --- a/internal/test/parameters/path_edge_cases/path_edge_cases_test.go +++ b/internal/test/paths/path_edge_cases/path_edge_cases_test.go @@ -1,4 +1,4 @@ -package parameterspathedgecases +package pathedgecases import ( "context" diff --git a/internal/test/parameters/path_edge_cases/spec.yaml b/internal/test/paths/path_edge_cases/spec.yaml similarity index 100% rename from internal/test/parameters/path_edge_cases/spec.yaml rename to internal/test/paths/path_edge_cases/spec.yaml diff --git a/internal/test/paths/spec.yaml b/internal/test/paths/spec.yaml new file mode 100644 index 0000000000..8536aad117 --- /dev/null +++ b/internal/test/paths/spec.yaml @@ -0,0 +1,29 @@ +# issue-1726: two paths sharing a prefix but ending in different literal-colon +# segments must route independently. Routers that use ':' to introduce a path +# parameter (Echo, Gin, Fiber) would otherwise treat ":validate"/":generate" as +# the same parameter and collide, always dispatching to the first route. +openapi: "3.0.0" +info: + version: 1.0.0 + title: Literal colon paths +paths: + /pets:validate: + post: + operationId: validatePets + responses: + '200': + description: OK + content: + text/plain: + schema: + type: string + /pets:generate: + post: + operationId: generatePets + responses: + '200': + description: OK + content: + text/plain: + schema: + type: string diff --git a/pkg/codegen/templates/fiber-v3/fiber-handler.tmpl b/pkg/codegen/templates/fiber-v3/fiber-handler.tmpl index 5ac8be929f..8460db83c1 100644 --- a/pkg/codegen/templates/fiber-v3/fiber-handler.tmpl +++ b/pkg/codegen/templates/fiber-v3/fiber-handler.tmpl @@ -22,6 +22,6 @@ for _, m := range options.Middlewares { } {{end}} {{range .}} -router.{{.Method | lower | title }}(options.BaseURL+"{{.Path | swaggerUriToFiberUri}}", wrapper.{{.HandlerName}}) +router.{{.Method | lower | title }}(options.BaseURL+{{.Path | swaggerUriToFiberUri | toGoString}}, wrapper.{{.HandlerName}}) {{end}} } diff --git a/pkg/codegen/utils.go b/pkg/codegen/utils.go index eb01380f57..55810bbb94 100644 --- a/pkg/codegen/utils.go +++ b/pkg/codegen/utils.go @@ -547,6 +547,23 @@ func SwaggerUriToIrisUri(uri string) string { return pathParamRE.ReplaceAllString(uri, ":$1") } +// escapeLiteralPathColons escapes literal ':' characters in an OpenAPI path so +// that routers which use ':' to introduce a path parameter (Echo, Gin, Fiber) +// treat them as literals rather than parameter delimiters. Without this, a path +// like "/pets:validate" registers a parameter named "validate", so multiple +// such paths collide on the same prefix (issue #1726). +// +// The escaped form is a single backslash before the colon (`\:`). The register +// templates render the result through toGoString (strconv.Quote), which turns +// the backslash into `\\` in the emitted Go source, so the value the router +// sees at runtime is exactly `\:` — the escape those routers understand. +// +// It must run before parameter substitution, which introduces its own ':' +// delimiters that must stay unescaped. +func escapeLiteralPathColons(uri string) string { + return strings.ReplaceAll(uri, ":", `\:`) +} + // SwaggerUriToEchoUri converts a OpenAPI style path URI with parameters to an // Echo compatible path URI. We need to replace all of OpenAPI parameters with // ":param". Valid input parameters are: @@ -560,7 +577,7 @@ func SwaggerUriToIrisUri(uri string) string { // {?param} // {?param*} func SwaggerUriToEchoUri(uri string) string { - uri = strings.ReplaceAll(uri, ":", "\\\\:") + uri = escapeLiteralPathColons(uri) return pathParamRE.ReplaceAllString(uri, ":$1") } @@ -577,6 +594,7 @@ func SwaggerUriToEchoUri(uri string) string { // {?param} // {?param*} func SwaggerUriToFiberUri(uri string) string { + uri = escapeLiteralPathColons(uri) return pathParamRE.ReplaceAllString(uri, ":$1") } @@ -609,7 +627,7 @@ func SwaggerUriToChiUri(uri string) string { // {?param} // {?param*} func SwaggerUriToGinUri(uri string) string { - uri = strings.ReplaceAll(uri, ":", "\\\\:") + uri = escapeLiteralPathColons(uri) return pathParamRE.ReplaceAllString(uri, ":$1") } diff --git a/pkg/codegen/utils_test.go b/pkg/codegen/utils_test.go index 17d3ce13ca..b1da98fe0a 100644 --- a/pkg/codegen/utils_test.go +++ b/pkg/codegen/utils_test.go @@ -369,8 +369,9 @@ func TestSwaggerUriToEchoUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToEchoUri("/path/{?arg}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToEchoUri("/path/{?arg*}/foo")) - // Make sure literal colons are escaped - assert.Equal(t, "/path/:arg\\\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) + // Make sure literal colons are escaped (issue #1726) + assert.Equal(t, `/path\:foo`, SwaggerUriToEchoUri("/path:foo")) + assert.Equal(t, `/path/:arg\:foo`, SwaggerUriToEchoUri("/path/{arg}:foo")) } func TestSwaggerUriToGinUri(t *testing.T) { @@ -389,8 +390,9 @@ func TestSwaggerUriToGinUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToGinUri("/path/{?arg}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToGinUri("/path/{?arg*}/foo")) - // Make sure literal colons are escaped - assert.Equal(t, "/path/:arg\\\\:foo", SwaggerUriToGinUri("/path/{arg}:foo")) + // Make sure literal colons are escaped (issue #1726) + assert.Equal(t, `/path\:foo`, SwaggerUriToGinUri("/path:foo")) + assert.Equal(t, `/path/:arg\:foo`, SwaggerUriToGinUri("/path/{arg}:foo")) } func TestSwaggerUriToGorillaUri(t *testing.T) { // TODO @@ -425,6 +427,10 @@ func TestSwaggerUriToFiberUri(t *testing.T) { assert.Equal(t, "/path/:arg/foo", SwaggerUriToFiberUri("/path/{;arg*}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToFiberUri("/path/{?arg}/foo")) assert.Equal(t, "/path/:arg/foo", SwaggerUriToFiberUri("/path/{?arg*}/foo")) + + // Make sure literal colons are escaped (issue #1726) + assert.Equal(t, `/path\:foo`, SwaggerUriToFiberUri("/path:foo")) + assert.Equal(t, `/path/:arg\:foo`, SwaggerUriToFiberUri("/path/{arg}:foo")) } func TestSwaggerUriToChiUri(t *testing.T) { From 9d8b5b911cfd396e8f8f0fe97e1e72afb02ad173 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 16 Jul 2026 11:42:05 -0700 Subject: [PATCH 5/5] Update greptile directives --- .greptile/rules.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.greptile/rules.md b/.greptile/rules.md index 5dd854ad6e..f45f17cd08 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -77,6 +77,7 @@ Be pragmatic: do not demand identical changes in seven places. Do your best to a - `openapi31/` — OpenAPI 3.1-specific behavior - `options/` — output-options flags (name-normalizer, filters, skip-prune, yaml-tags, …) - `parameters/` — parameter binding, styles, encoding, nil handling (incl. the cross-framework `roundtrip/` harness) +- `paths/` — path-level routing edge cases: literal colons in paths, URL escaping of reserved characters, and path-parameter precedence. Because colon-in-path routing is backend-specific, leaves here may fan out into per-framework subpackages (`echo/`, `gin/`, `fiber/`, `fiberv3/`, …) each with its own `config.yaml` and generated server, driven by one shared `spec.yaml` and a single top-level routing test — mirroring `parameters/roundtrip/`. This multi-subpackage shape is expected for routing fixtures and is not the "standard leaf layout" churn to flag. - `references/` — external `$ref`s, import-mapping, multi-package generation, overlays - `schemas/` — schema-to-type mapping (primitives, objects, enums, nullable, recursive, …) - `servers/` — server codegen (routers, middleware, strict servers) @@ -86,7 +87,7 @@ When a PR adds a test, enforce the following: - **Do not allow issue-numbered test directories to come back.** Flag any new directory named after a GitHub issue (`internal/test/issues/…`, `issue-1234/`, `issueNNNN/`, and similar) anywhere under `internal/test/`. The old `issues/` tree was deliberately dissolved into the categories above. - **Prefer extending an existing test case.** If the scenario fits an existing category leaf — same OpenAPI construct, same generation config — the new schemas/operations belong in that leaf's `spec.yaml` and its `*_test.go`, marked with a provenance comment (`# From issue-NNNN: `) so the issue context is not lost. Suggest the specific leaf to extend when you can identify one. -- **A new leaf is fine when nothing matches.** A new subdirectory under the right category is the correct move when the scenario needs a *different generation config* (other `generate:` targets or `output-options:`) or inherently needs its own files (multi-file external-ref layouts). It should follow the standard layout — `doc.go` (with the `//go:generate` line), `config.yaml`, `spec.yaml`, `_test.go` — with a snake_case scenario-named directory (never an issue number) and the issue reference in a comment. +- **A new leaf is fine when nothing matches.** A new subdirectory under the right category is the correct move when the scenario needs a *different generation config* (other `generate:` targets or `output-options:`) or inherently needs its own files (multi-file external-ref layouts, or per-framework routing fixtures like `paths/` and `parameters/roundtrip/`). It should follow the standard layout — `doc.go` (with the `//go:generate` line), `config.yaml`, `spec.yaml`, `_test.go` — with a snake_case scenario-named directory (never an issue number) and the issue reference in a comment. Cross-framework fixtures legitimately depart from this by fanning out into per-framework subpackages; do not flag that shape. - **Regression tests for bug fixes are still expected** — they just live in the category matching the feature, not in an issue-named directory. ## 7. General