feat(router): support AIP custom verb paths

Google AIP-style APIs use literal colon suffixes for custom methods, which Gin previously parsed as wildcard parameters.

Treat literal segment colons as static text and split param nodes before static suffix children such as :mutate.

This lets custom verb routes coexist with regular parameter routes while preserving escaped-colon behavior.
This commit is contained in:
Kuroda Kayn 2026-06-28 15:20:56 +08:00
parent 34dac209ff
commit ddf82d7611

161
tree.go
View File

@ -78,15 +78,71 @@ func (n *node) addChild(child *node) {
}
func countParams(path string) uint16 {
colons := strings.Count(path, ":")
stars := strings.Count(path, "*")
return safeUint16(colons + stars)
var count uint16
skipLeadingColon := false
for {
wildcard, i, _ := findWildcard(path, skipLeadingColon)
if i < 0 {
return count
}
count++
path = path[i+len(wildcard):]
skipLeadingColon = len(path) > 0 && wildcard[0] == ':' && path[0] == ':'
}
}
func countSections(path string) uint16 {
return safeUint16(strings.Count(path, "/"))
}
func isParamStart(path string, i int) bool {
if i == 0 || path[i-1] == '/' {
return true
}
if i == len(path)-1 || path[i+1] == '/' {
return false
}
switch path[i-1] {
case '_', '-', '.':
return true
}
segmentStart := strings.LastIndexByte(path[:i], '/') + 1
prefix := path[segmentStart:i]
return len(prefix) == 0 || prefix[len(prefix)-1] != 's'
}
func (n *node) childIndex(c byte) int {
for i, idx := range []byte(n.indices) {
if idx == c {
return i
}
}
return -1
}
func (n *node) findStaticParamChild(path string, end int, match func(*node, string) bool) (int, int) {
for j := end - 1; j >= 0; j-- {
for i, c := range []byte(n.indices) {
if c == '/' || path[j] != c || !strings.HasPrefix(path[j:], n.children[i].path) {
continue
}
if match == nil || match(n.children[i], path[j:]) {
return i, j
}
}
}
return -1, end
}
func staticParamChildCanMatch(child *node, path string) bool {
skippedNodes := make([]skippedNode, 0, int(countSections(path))+1)
value := child.getValue(path, nil, &skippedNodes, false)
return value.handlers != nil || value.tsr
}
type nodeType uint8
const (
@ -180,9 +236,13 @@ walk:
c := path[0]
// '/' after param
if n.nType == param && c == '/' && len(n.children) == 1 {
if n.nType == param && c == '/' {
childIndex := n.childIndex('/')
if childIndex < 0 {
goto insert
}
parentFullPathIndex += len(n.path)
n = n.children[0]
n = n.children[childIndex]
n.priority++
continue walk
}
@ -198,7 +258,9 @@ walk:
}
// Otherwise insert it
if c != ':' && c != '*' && n.nType != catchAll {
insert:
skipLeadingColonForInsert := n.nType == param && c == ':'
if (c != ':' && c != '*' || n.nType == param && c == ':') && n.nType != catchAll {
// []byte for proper unicode char conversion, see #65
n.indices += bytesconv.BytesToString([]byte{c})
child := &node{
@ -217,7 +279,7 @@ walk:
// Adding a child to a catchAll is not possible
n.nType != catchAll &&
// Check for longer wildcard, e.g. :name and :names
(len(n.path) >= len(path) || path[len(n.path)] == '/') {
(len(n.path) >= len(path) || path[len(n.path)] == '/' || path[len(n.path)] == ':') {
continue walk
}
@ -234,7 +296,7 @@ walk:
"'")
}
n.insertChild(path, fullPath, handlers)
n.insertChildWithSkip(path, fullPath, handlers, skipLeadingColonForInsert)
return
}
@ -250,7 +312,7 @@ walk:
// Search for a wildcard segment and check the name for invalid characters.
// Returns -1 as index, if no wildcard was found.
func findWildcard(path string) (wildcard string, i int, valid bool) {
func findWildcard(path string, skipLeadingColon bool) (wildcard string, i int, valid bool) {
// Find start
escapeColon := false
for start, c := range []byte(path) {
@ -265,7 +327,15 @@ func findWildcard(path string) (wildcard string, i int, valid bool) {
escapeColon = true
continue
}
// A wildcard starts with ':' (param) or '*' (catch-all)
// A wildcard starts with ':' (param) or '*' (catch-all).
// Colons inside literal segments stay static so custom verb routes
// such as /users:batchGet can coexist with regular parameters.
if skipLeadingColon && start == 0 && c == ':' {
continue
}
if c == ':' && !isParamStart(path, start) {
continue
}
if c != ':' && c != '*' {
continue
}
@ -276,7 +346,12 @@ func findWildcard(path string) (wildcard string, i int, valid bool) {
switch c {
case '/':
return path[start : start+1+end], start, valid
case ':', '*':
case ':':
if path[start] == ':' {
return path[start : start+1+end], start, valid
}
valid = false
case '*':
valid = false
}
}
@ -286,9 +361,13 @@ func findWildcard(path string) (wildcard string, i int, valid bool) {
}
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
n.insertChildWithSkip(path, fullPath, handlers, false)
}
func (n *node) insertChildWithSkip(path string, fullPath string, handlers HandlersChain, skipLeadingColon bool) {
for {
// Find prefix until first wildcard
wildcard, i, valid := findWildcard(path)
wildcard, i, valid := findWildcard(path, skipLeadingColon)
if i < 0 { // No wildcard found
break
}
@ -325,11 +404,13 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
// will be another subpath starting with '/'
if len(wildcard) < len(path) {
path = path[len(wildcard):]
skipLeadingColon = path[0] == ':'
child := &node{
priority: 1,
fullPath: fullPath,
}
n.indices += bytesconv.BytesToString([]byte{path[0]})
n.addChild(child)
n = child
continue
@ -485,14 +566,12 @@ walk: // Outer loop for walking the tree
switch n.nType {
case param:
// fix truncate the parameter
// tree_test.go line: 204
// Find param end (either '/' or path end)
end := 0
for end < len(path) && path[end] != '/' {
end++
}
childIndex, end := n.findStaticParamChild(path, end, staticParamChildCanMatch)
// Save param value
if params != nil {
@ -521,12 +600,22 @@ walk: // Outer loop for walking the tree
}
}
if childIndex >= 0 {
path = path[end:]
n = n.children[childIndex]
continue walk
}
// we need to go deeper!
if end < len(path) {
if len(n.children) > 0 {
path = path[end:]
n = n.children[0]
continue walk
for i, c := range []byte(n.indices) {
if c == '/' {
path = path[end:]
n = n.children[i]
continue walk
}
}
}
// ... but we can't
@ -538,10 +627,10 @@ walk: // Outer loop for walking the tree
value.fullPath = n.fullPath
return value
}
if len(n.children) == 1 {
if childIndex := n.childIndex('/'); childIndex >= 0 {
// No handle found. Check if a handle for this path + a
// trailing slash exists for TSR recommendation
n = n.children[0]
n = n.children[childIndex]
value.tsr = (n.path == "/" && n.handlers != nil) || (n.path == "" && n.indices == "/")
}
return value
@ -891,18 +980,30 @@ walk: // Outer loop for walking the tree
for end < len(path) && path[end] != '/' {
end++
}
childIndex, end := n.findStaticParamChild(path, end, nil)
// Add param value to case insensitive path
ciPath = append(ciPath, path[:end]...)
if childIndex >= 0 {
n = n.children[childIndex]
npLen = len(n.path)
path = path[end:]
continue
}
// We need to go deeper!
if end < len(path) {
if len(n.children) > 0 {
// Continue with child node
n = n.children[0]
npLen = len(n.path)
path = path[end:]
continue
for i, c := range []byte(n.indices) {
if c == '/' {
// Continue with child node
n = n.children[i]
npLen = len(n.path)
path = path[end:]
continue walk
}
}
}
// ... but we can't
@ -916,12 +1017,14 @@ walk: // Outer loop for walking the tree
return ciPath
}
if fixTrailingSlash && len(n.children) == 1 {
if fixTrailingSlash {
// No handle found. Check if a handle for this path + a
// trailing slash exists
n = n.children[0]
if n.path == "/" && n.handlers != nil {
return append(ciPath, '/')
if childIndex := n.childIndex('/'); childIndex >= 0 {
n = n.children[childIndex]
if n.path == "/" && n.handlers != nil {
return append(ciPath, '/')
}
}
}