Files
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

184 lines
5.6 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"net/http"
"regexp"
"slices"
"strings"
"github.com/hanzoai/git/modules/container"
"github.com/hanzoai/git/modules/util"
)
type RouterPathGroup struct {
r *Router
pathParam string
matchers []*routerPathMatcher
}
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
rc := GetRouteContext(req.Context())
path := rc.Param(g.pathParam)
for _, m := range g.matchers {
if m.matchPath(rc, path) {
rc.RoutePatterns = append(rc.RoutePatterns, m.pattern)
executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc)
return
}
}
g.r.mux.NotFoundHandler().ServeHTTP(resp, req)
}
type RouterPathGroupPattern struct {
pattern string
re *regexp.Regexp
params []routerPathParam
middlewares []any
}
// MatchPath matches the request method, and uses regexp to match the path.
// The pattern uses "<...>" to define path parameters, for example, "/<name>" (different from chi router)
// It is only designed to resolve some special cases that chi router can't handle.
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) {
g.MatchPattern(methods, g.PatternRegexp(pattern), h...)
}
func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupPattern, h ...any) {
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
}
type routerPathParam struct {
name string
pathSepEnd bool
captureGroup int
}
type routerPathMatcher struct {
methods container.Set[string]
pattern string
re *regexp.Regexp
params []routerPathParam
middlewares []middlewareProvider
handlerFunc http.HandlerFunc
}
func (p *routerPathMatcher) matchPath(rc *RouteContext, path string) bool {
if !p.methods.Contains(rc.RouteMethod) {
return false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
pathMatches := p.re.FindStringSubmatchIndex(path) // Golang regexp match pairs [start, end, start, end, ...]
if pathMatches == nil {
return false
}
var paramMatches [][]int
for i := 2; i < len(pathMatches); {
paramMatches = append(paramMatches, []int{pathMatches[i], pathMatches[i+1]})
pmIdx := len(paramMatches) - 1
end := pathMatches[i+1]
i += 2
for ; i < len(pathMatches); i += 2 {
if pathMatches[i] >= end {
break
}
paramMatches[pmIdx] = append(paramMatches[pmIdx], pathMatches[i], pathMatches[i+1])
}
}
for i, pm := range paramMatches {
groupIdx := p.params[i].captureGroup * 2
if pm[groupIdx] == -1 || pm[groupIdx+1] == -1 {
rc.SetParam(p.params[i].name, "")
continue
}
val := path[pm[groupIdx]:pm[groupIdx+1]]
if p.params[i].pathSepEnd {
val = strings.TrimSuffix(val, "/")
}
rc.SetParam(p.params[i].name, val)
}
return true
}
func isValidMethod(name string) bool {
switch name {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead, http.MethodOptions, http.MethodConnect, http.MethodTrace:
return true
}
return false
}
func newRouterPathMatcher(methods string, patternRegexp *RouterPathGroupPattern, h ...any) *routerPathMatcher {
middlewares, handlerFunc, hasPreMiddlewares := wrapMiddlewareAndHandler(nil, patternRegexp.middlewares, h)
if hasPreMiddlewares {
panic("pre-middlewares are not supported in router path matcher")
}
p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc}
for method := range strings.SplitSeq(methods, ",") {
method = strings.TrimSpace(method)
if !isValidMethod(method) {
panic("invalid HTTP method: " + method)
}
p.methods.Add(method)
}
p.pattern, p.re, p.params = patternRegexp.pattern, patternRegexp.re, patternRegexp.params
return p
}
func patternRegexp(pattern string, h ...any) *RouterPathGroupPattern {
p := &RouterPathGroupPattern{middlewares: slices.Clone(h)}
re := []byte{'^'}
lastEnd := 0
for lastEnd < len(pattern) {
start := strings.IndexByte(pattern[lastEnd:], '<')
if start == -1 {
re = append(re, regexp.QuoteMeta(pattern[lastEnd:])...)
break
}
end := strings.IndexByte(pattern[lastEnd+start:], '>')
if end == -1 {
panic("invalid pattern: " + pattern)
}
re = append(re, regexp.QuoteMeta(pattern[lastEnd:lastEnd+start])...)
partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":")
lastEnd += start + end + 1
// TODO: it could support to specify a "capture group" for the name, for example: "/<name[2]:(\d)-(\d)>"
// it is not used so no need to implement it now
param := routerPathParam{}
if partExp == "*" {
// "<part:*>" is a shorthand for optionally matching any string (but not greedy)
partExp = ".*?"
if lastEnd < len(pattern) && pattern[lastEnd] == '/' {
// if this param part ends with path separator "/", then consider it together: "(.*?/)"
partExp += "/"
param.pathSepEnd = true
lastEnd++
}
re = append(re, '(')
re = append(re, partExp...)
re = append(re, ')', '?') // the wildcard matching is optional
} else {
// the pattern is user-provided regexp, defaults to a path part (separated by "/")
partExp = util.IfZero(partExp, "[^/]+")
re = append(re, '(')
re = append(re, partExp...)
re = append(re, ')')
}
param.name = partName
p.params = append(p.params, param)
}
re = append(re, '$')
p.pattern, p.re = pattern, regexp.MustCompile(string(re))
return p
}
func (g *RouterPathGroup) PatternRegexp(pattern string, h ...any) *RouterPathGroupPattern {
return patternRegexp(pattern, h...)
}