From ddf82d7611d297a0f30770803578f9417913a8af Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Sun, 28 Jun 2026 15:20:56 +0800 Subject: [PATCH] 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. --- tree.go | 161 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 132 insertions(+), 29 deletions(-) diff --git a/tree.go b/tree.go index 580abbaf..11d6edcd 100644 --- a/tree.go +++ b/tree.go @@ -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, '/') + } } }