Skip to content

Literal colon routes don't work properly in non-Run() scenarios #4413

@Zhang-Siyang

Description

@Zhang-Siyang

Description

Gin currently supports literal colon routes (e.g., /api/v1\:method) through backslash escaping, as implemented in PR #2857. However, there's a significant usage limitation:

Current Behavior

  • engine.Run() ✅ Works correctly (automatically calls updateRouteTrees())
  • engine.Handler() ❌ Doesn't work (doesn't call updateRouteTrees())
  • Direct usage as http.Handler ❌ Doesn't work

Root Cause

The literal colon feature depends on updateRouteTrees() method to convert stored escaped paths (\:) to actual paths (:). However, this method is only called in engine.Run(), not in other usage scenarios.

Problem Scenario

I encountered this issue when using the following code:

server := &http.Server{Handler: engine}
server.ListenAndServe()

Potential Solutions

  1. Use sync.Once in ServeHTTP:

    var routeTreesUpdated sync.Once
    
    func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
        routeTreesUpdated.Do(func() {
            engine.updateRouteTrees()
        })
        // existing logic...
    }
  2. Add updateRouteTrees() logic in engine.Handler():
    Recommend users to use engine.Handler() instead of using engine directly, and call updateRouteTrees() in Handler() method

  3. Update existing colon implementation:
    Redesign the literal colon handling mechanism to avoid the need for delayed route tree updates

Gin Version

v1.11.0

Can you reproduce the bug?

Yes

Source Code

// Apology
// Due to continuous build failures on https://go.dev/play/ (dependency download timeout), I cannot provide runnable code snippets, but the test program has been uploaded to: https://go.dev/play/p/UJUroSOmkq1

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

func setupRouter() *gin.Engine {
	gin.SetMode(gin.ReleaseMode)
	r := gin.New()

	r.GET("/test", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"type": "static",
			"path": "/test",
		})
	})

	r.GET("/test/:param", func(c *gin.Context) {
		param := c.Param("param")
		c.JSON(http.StatusOK, gin.H{
			"type":  "param",
			"param": param,
		})
	})

	r.GET(`/test\:action`, func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"type": "literal_colon",
			"path": "/test:action",
		})
	})

	r.UpdateRouteTrees() // important

	return r
}

func TestRoutes(t *testing.T) {
	router := setupRouter()

	tests := []struct {
		name         string
		url          string
		expectedCode int
		expectedBody string
	}{
		{
			name:         "static route",
			url:          "/test",
			expectedCode: http.StatusOK,
			expectedBody: `{"path":"/test","type":"static"}`,
		},
		{
			name:         "param route",
			url:          "/test/foo",
			expectedCode: http.StatusOK,
			expectedBody: `{"param":"foo","type":"param"}`,
		},
		{
			name:         "literal colon route",
			url:          "/test:action",
			expectedCode: http.StatusOK,
			expectedBody: `{"path":"/test:action","type":"literal_colon"}`,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			w := httptest.NewRecorder()
			req, _ := http.NewRequest("GET", tt.url, nil)
			router.ServeHTTP(w, req)

			assert.Equal(t, tt.expectedCode, w.Code)
			assert.JSONEq(t, tt.expectedBody, w.Body.String())
		})
	}
}
-- go.mod --
module gin_colon

go 1.25.3

require (
	github.com/gin-gonic/gin v1.11.0
	github.com/stretchr/testify v1.11.1
)

require (
	github.com/bytedance/sonic v1.14.0 // indirect
	github.com/bytedance/sonic/loader v0.3.0 // indirect
	github.com/cloudwego/base64x v0.1.6 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
	github.com/gin-contrib/sse v1.1.0 // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.27.0 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/goccy/go-yaml v1.18.0 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/quic-go/qpack v0.5.1 // indirect
	github.com/quic-go/quic-go v0.54.0 // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.3.0 // indirect
	go.uber.org/mock v0.5.0 // indirect
	golang.org/x/arch v0.20.0 // indirect
	golang.org/x/crypto v0.40.0 // indirect
	golang.org/x/mod v0.25.0 // indirect
	golang.org/x/net v0.42.0 // indirect
	golang.org/x/sync v0.16.0 // indirect
	golang.org/x/sys v0.35.0 // indirect
	golang.org/x/text v0.27.0 // indirect
	golang.org/x/tools v0.34.0 // indirect
	google.golang.org/protobuf v1.36.9 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace github.com/gin-gonic/gin => github.com/Zhang-Siyang/gin v0.0.0-20251030080216-1440601f40e6

Go Version

1.25

Operating System

macOS/Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    type/bugFound something you weren't expecting? Report it here!

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions