diff --git a/.greptile/rules.md b/.greptile/rules.md index 5dd854ad6..f45f17cd0 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 diff --git a/internal/test/paths/colon_paths_test.go b/internal/test/paths/colon_paths_test.go new file mode 100644 index 000000000..9585bb901 --- /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 000000000..0c036900c --- /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 000000000..f8a0800ba --- /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 000000000..3b49f3bc7 --- /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 000000000..5708b7108 --- /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 000000000..55576ee5d --- /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 000000000..02bb08703 --- /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 000000000..e9417b39b --- /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 000000000..ee4fdf8e9 --- /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 000000000..514642cb4 --- /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 7da785d16..2e13fecea 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 555de1c04..43bf3efba 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 b1128a9db..c51596f09 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 3158521a6..562abbd00 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 000000000..8536aad11 --- /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 5ac8be929..8460db83c 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 543ecba82..55810bbb9 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,6 +577,7 @@ func SwaggerUriToIrisUri(uri string) string { // {?param} // {?param*} func SwaggerUriToEchoUri(uri string) string { + uri = escapeLiteralPathColons(uri) return pathParamRE.ReplaceAllString(uri, ":$1") } @@ -576,6 +594,7 @@ func SwaggerUriToEchoUri(uri string) string { // {?param} // {?param*} func SwaggerUriToFiberUri(uri string) string { + uri = escapeLiteralPathColons(uri) return pathParamRE.ReplaceAllString(uri, ":$1") } @@ -608,6 +627,7 @@ func SwaggerUriToChiUri(uri string) string { // {?param} // {?param*} func SwaggerUriToGinUri(uri string) string { + uri = escapeLiteralPathColons(uri) return pathParamRE.ReplaceAllString(uri, ":$1") } diff --git a/pkg/codegen/utils_test.go b/pkg/codegen/utils_test.go index 7c1f02ba4..b1da98fe0 100644 --- a/pkg/codegen/utils_test.go +++ b/pkg/codegen/utils_test.go @@ -368,6 +368,10 @@ 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 (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) { @@ -385,6 +389,10 @@ 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 (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 @@ -419,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) {