Files
fiber/path.go

843 lines
25 KiB
Go

// ⚡️ Fiber is an Express inspired web framework written in Go with ☕️
// 📄 GitHub Repository: https://github.com/gofiber/fiber
// 📌 API Documentation: https://docs.gofiber.io
// ⚠️ This path parser was inspired by https://github.com/ucarion/urlpath
// 💖 Maintained and modified for Fiber by @renewerner87
package fiber
import (
"bytes"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/gofiber/utils/v2"
utilsbytes "github.com/gofiber/utils/v2/bytes"
utilsstrings "github.com/gofiber/utils/v2/strings"
"github.com/google/uuid"
)
// routeParser holds the path segments and param names
type routeParser struct {
segs []*routeSegment // the parsed segments of the route
params []string // that parameter names the parsed route
wildCardCount int // number of wildcard parameters, used internally to give the wildcard parameter its number
plusCount int // number of plus parameters, used internally to give the plus parameter its number
}
var routerParserPool = &sync.Pool{
New: func() any {
return &routeParser{}
},
}
// routeSegment holds the segment metadata
type routeSegment struct {
// const information
Const string // constant part of the route
ParamName string // name of the parameter for access to it, for wildcards and plus parameters access iterators starting with 1 are added
ComparePart string // search part to find the end of the parameter
Constraints []*Constraint // Constraint type if segment is a parameter, if not it will be set to noConstraint by default
PartCount int // how often is the search part contained in the non-param segments? -> necessary for greedy search
Length int // length of the parameter for segment, when its 0 then the length is undetermined
// future TODO: add support for optional groups "/abc(/def)?"
// parameter information
IsParam bool // Truth value that indicates whether it is a parameter or a constant part
IsGreedy bool // indicates whether the parameter is greedy or not, is used with wildcard and plus
IsOptional bool // indicates whether the parameter is optional or not
// common information
IsLast bool // shows if the segment is the last one for the route
HasOptionalSlash bool // segment has the possibility of an optional slash
}
// different special routing signs
const (
wildcardParam byte = '*' // indicates an optional greedy parameter
plusParam byte = '+' // indicates a required greedy parameter
optionalParam byte = '?' // concludes a parameter by name and makes it optional
paramStarterChar byte = ':' // start character for a parameter with name
slashDelimiter byte = '/' // separator for the route, unlike the other delimiters this character at the end can be optional
escapeChar byte = '\\' // escape character
paramConstraintStart byte = '<' // start of type constraint for a parameter
paramConstraintEnd byte = '>' // end of type constraint for a parameter
paramConstraintSeparator byte = ';' // separator of type constraints for a parameter
paramConstraintDataStart byte = '(' // start of data of type constraint for a parameter
paramConstraintDataEnd byte = ')' // end of data of type constraint for a parameter
paramConstraintDataSeparator byte = ',' // separator of data of type constraint for a parameter
)
// TypeConstraint parameter constraint types
type TypeConstraint uint16
// Constraint describes the validation rules that apply to a dynamic route
// segment when matching incoming requests.
type Constraint struct {
RegexCompiler *regexp.Regexp
Name string
Data []string
customConstraints []CustomConstraint
ID TypeConstraint
}
// CustomConstraint is an interface for custom constraints
type CustomConstraint interface {
// Name returns the name of the constraint.
// This name is used in the constraint matching.
Name() string
// Execute executes the constraint.
// It returns true if the constraint is matched and right.
// param is the parameter value to check.
// args are the constraint arguments.
Execute(param string, args ...string) bool
}
const (
noConstraint TypeConstraint = 1 << iota
intConstraint
boolConstraint
floatConstraint
alphaConstraint
datetimeConstraint
guidConstraint
minLenConstraint
maxLenConstraint
lenConstraint
betweenLenConstraint
minConstraint
maxConstraint
rangeConstraint
regexConstraint
)
const (
needOneData = minLenConstraint | maxLenConstraint | lenConstraint | minConstraint | maxConstraint | datetimeConstraint | regexConstraint
needTwoData = betweenLenConstraint | rangeConstraint
)
// list of possible parameter and segment delimiter
var (
// slash has a special role, unlike the other parameters it must not be interpreted as a parameter
routeDelimiter = []byte{slashDelimiter, '-', '.'}
// list of chars for the parameter recognizing
parameterStartChars = [256]bool{
wildcardParam: true,
plusParam: true,
paramStarterChar: true,
}
// list of chars of delimiters and the starting parameter name char
parameterDelimiterChars = append([]byte{paramStarterChar, escapeChar}, routeDelimiter...)
// list of chars to find the end of a parameter
parameterEndChars = [256]bool{
optionalParam: true,
paramStarterChar: true,
escapeChar: true,
slashDelimiter: true,
'-': true,
'.': true,
}
)
// RoutePatternMatch reports whether path matches the provided Fiber route pattern.
//
// Patterns use the same syntax as routes registered on an App, including
// parameters (for example `:id`), wildcards (`*`, `+`), and optional segments.
// The optional Config argument can be used to control case sensitivity and
// strict routing behavior. This helper allows checking potential matches
// without registering a route.
func RoutePatternMatch(path, pattern string, cfg ...Config) bool {
// See logic in (*Route).match and (*App).register
var ctxParams [maxParams]string
config := Config{}
if len(cfg) > 0 {
config = cfg[0]
}
if path == "" {
path = "/"
}
// Cannot have an empty pattern
if pattern == "" {
pattern = "/"
}
// Pattern always start with a '/'
if pattern[0] != '/' {
pattern = "/" + pattern
}
patternPretty := []byte(pattern)
// Case-sensitive routing, all to lowercase
if !config.CaseSensitive {
patternPretty = utilsbytes.UnsafeToLower(patternPretty)
path = utilsstrings.ToLower(path)
}
// Strict routing, remove trailing slashes
if !config.StrictRouting && len(patternPretty) > 1 {
patternPretty = utils.TrimRight(patternPretty, '/')
}
parser, _ := routerParserPool.Get().(*routeParser) //nolint:errcheck // only contains routeParser
parser.reset()
patternStr := string(patternPretty)
parser.parseRoute(patternStr)
defer routerParserPool.Put(parser)
// '*' wildcard matches any path
if (patternStr == "/" && path == "/") || patternStr == "/*" {
return true
}
// Does this route have parameters
if len(parser.params) > 0 {
if match := parser.getMatch(path, path, &ctxParams, false); match {
return true
}
}
// Check for a simple match
patternPretty = RemoveEscapeCharBytes(patternPretty)
return string(patternPretty) == path
}
func (parser *routeParser) reset() {
parser.segs = parser.segs[:0]
parser.params = parser.params[:0]
parser.wildCardCount = 0
parser.plusCount = 0
}
// parseRoute analyzes the route and divides it into segments for constant areas and parameters,
// this information is needed later when assigning the requests to the declared routes
func (parser *routeParser) parseRoute(pattern string, customConstraints ...CustomConstraint) {
var n int
var seg *routeSegment
for pattern != "" {
nextParamPosition := findNextParamPosition(pattern)
// handle the parameter part
if nextParamPosition == 0 {
n, seg = parser.analyseParameterPart(pattern, customConstraints...)
parser.params, parser.segs = append(parser.params, seg.ParamName), append(parser.segs, seg)
} else {
n, seg = parser.analyseConstantPart(pattern, nextParamPosition)
parser.segs = append(parser.segs, seg)
}
pattern = pattern[n:]
}
// mark last segment
if len(parser.segs) > 0 {
parser.segs[len(parser.segs)-1].IsLast = true
}
parser.segs = addParameterMetaInfo(parser.segs)
}
// parseRoute analyzes the route and divides it into segments for constant areas and parameters,
// this information is needed later when assigning the requests to the declared routes
func parseRoute(pattern string, customConstraints ...CustomConstraint) routeParser {
parser := routeParser{}
parser.parseRoute(pattern, customConstraints...)
// Check if the route has too many parameters
if len(parser.params) > maxParams {
panic(fmt.Sprintf("Route '%s' has %d parameters, which exceeds the maximum of %d",
pattern, len(parser.params), maxParams))
}
return parser
}
// addParameterMetaInfo add important meta information to the parameter segments
// to simplify the search for the end of the parameter
func addParameterMetaInfo(segs []*routeSegment) []*routeSegment {
var comparePart string
segLen := len(segs)
// loop from end to begin
for i := segLen - 1; i >= 0; i-- {
// set the compare part for the parameter
if segs[i].IsParam {
// important for finding the end of the parameter
segs[i].ComparePart = RemoveEscapeChar(comparePart)
} else {
comparePart = segs[i].Const
if len(comparePart) > 1 {
comparePart = utils.TrimRight(comparePart, slashDelimiter)
}
}
}
// loop from beginning to end
for i := range segLen {
// check how often the compare part is in the following const parts
if segs[i].IsParam {
// check if parameter segments are directly after each other;
// when neither this parameter nor the next parameter are greedy, we only want one character
if segLen > i+1 && !segs[i].IsGreedy && segs[i+1].IsParam && !segs[i+1].IsGreedy {
segs[i].Length = 1
}
if segs[i].ComparePart == "" {
continue
}
for j := i + 1; j <= len(segs)-1; j++ {
if !segs[j].IsParam {
// count is important for the greedy match
segs[i].PartCount += strings.Count(segs[j].Const, segs[i].ComparePart)
}
}
// check if the end of the segment is an optional slash and then if the segment is optional or the last one
} else if segs[i].Const[len(segs[i].Const)-1] == slashDelimiter && (segs[i].IsLast || (segLen > i+1 && segs[i+1].IsOptional)) {
segs[i].HasOptionalSlash = true
}
}
return segs
}
// findNextParamPosition search for the next possible parameter start position
func findNextParamPosition(pattern string) int {
// Find the first parameter position
next := -1
for i := range pattern {
if parameterStartChars[pattern[i]] && (i == 0 || pattern[i-1] != escapeChar) {
next = i
break
}
}
if next > 0 && pattern[next] != wildcardParam {
// checking the found parameterStartChar is a cluster
for i := next + 1; i < len(pattern); i++ {
if !parameterStartChars[pattern[i]] {
return i - 1
}
}
return len(pattern) - 1
}
return next
}
// analyseConstantPart find the end of the constant part and create the route segment
func (*routeParser) analyseConstantPart(pattern string, nextParamPosition int) (int, *routeSegment) {
// handle the constant part
processedPart := pattern
if nextParamPosition != -1 {
// remove the constant part until the parameter
processedPart = pattern[:nextParamPosition]
}
constPart := RemoveEscapeChar(processedPart)
return len(processedPart), &routeSegment{
Const: constPart,
Length: len(constPart),
}
}
// analyseParameterPart find the parameter end and create the route segment
func (parser *routeParser) analyseParameterPart(pattern string, customConstraints ...CustomConstraint) (int, *routeSegment) {
isWildCard := pattern[0] == wildcardParam
isPlusParam := pattern[0] == plusParam
paramEndPosition := 0
paramConstraintStartPosition := -1
paramConstraintEndPosition := -1
// handle wildcard end
if !isWildCard && !isPlusParam {
paramEndPosition = -1
search := pattern[1:]
for i := range search {
if paramConstraintStartPosition == -1 && search[i] == paramConstraintStart && (i == 0 || search[i-1] != escapeChar) {
paramConstraintStartPosition = i + 1
continue
}
if paramConstraintEndPosition == -1 && search[i] == paramConstraintEnd && (i == 0 || search[i-1] != escapeChar) {
paramConstraintEndPosition = i + 1
continue
}
if parameterEndChars[search[i]] {
if (paramConstraintStartPosition == -1 && paramConstraintEndPosition == -1) ||
(paramConstraintStartPosition != -1 && paramConstraintEndPosition != -1) {
paramEndPosition = i
break
}
}
}
switch {
case paramEndPosition == -1:
paramEndPosition = len(pattern) - 1
case bytes.IndexByte(parameterDelimiterChars, pattern[paramEndPosition+1]) == -1:
paramEndPosition++
default:
// do nothing
}
}
// cut params part
processedPart := pattern[0 : paramEndPosition+1]
n := paramEndPosition + 1
paramName := RemoveEscapeChar(GetTrimmedParam(processedPart))
// Check has constraint
var constraints []*Constraint
if hasConstraint := paramConstraintStartPosition != -1 && paramConstraintEndPosition != -1; hasConstraint {
constraintString := pattern[paramConstraintStartPosition+1 : paramConstraintEndPosition]
userConstraints := splitNonEscaped(constraintString, paramConstraintSeparator)
constraints = make([]*Constraint, 0, len(userConstraints))
for _, c := range userConstraints {
start := findNextNonEscapedCharPosition(c, paramConstraintDataStart)
end := strings.LastIndexByte(c, paramConstraintDataEnd)
// Assign constraint
if start != -1 && end != -1 {
constraint := &Constraint{
ID: getParamConstraintType(c[:start]),
Name: c[:start],
customConstraints: customConstraints,
}
// remove escapes from data
if constraint.ID != regexConstraint {
constraint.Data = splitNonEscaped(c[start+1:end], paramConstraintDataSeparator)
if len(constraint.Data) == 1 {
constraint.Data[0] = RemoveEscapeChar(constraint.Data[0])
} else if len(constraint.Data) == 2 { // This is fine, we simply expect two parts
constraint.Data[0] = RemoveEscapeChar(constraint.Data[0])
constraint.Data[1] = RemoveEscapeChar(constraint.Data[1])
}
}
// Precompile regex if has regex constraint
if constraint.ID == regexConstraint {
constraint.Data = []string{c[start+1 : end]}
constraint.RegexCompiler = regexp.MustCompile(constraint.Data[0])
}
constraints = append(constraints, constraint)
} else {
constraints = append(constraints, &Constraint{
ID: getParamConstraintType(c),
Data: []string{},
Name: c,
customConstraints: customConstraints,
})
}
}
paramName = RemoveEscapeChar(GetTrimmedParam(pattern[0:paramConstraintStartPosition]))
}
// add access iterator to wildcard and plus
if isWildCard {
parser.wildCardCount++
paramName += strconv.Itoa(parser.wildCardCount)
} else if isPlusParam {
parser.plusCount++
paramName += strconv.Itoa(parser.plusCount)
}
segment := &routeSegment{
ParamName: paramName,
IsParam: true,
IsOptional: isWildCard || pattern[paramEndPosition] == optionalParam,
IsGreedy: isWildCard || isPlusParam,
}
if len(constraints) > 0 {
segment.Constraints = constraints
}
return n, segment
}
// findNextNonEscapedCharPosition searches the next char position and skips the escaped characters
func findNextNonEscapedCharPosition(search string, char byte) int {
for i := 0; i < len(search); i++ {
if search[i] == char && (i == 0 || search[i-1] != escapeChar) {
return i
}
}
return -1
}
// splitNonEscaped slices s into all substrings separated by sep and returns a slice of the substrings between those separators
// This function also takes a care of escape char when splitting.
func splitNonEscaped(s string, sep byte) []string {
var result []string
i := findNextNonEscapedCharPosition(s, sep)
for i > -1 {
result = append(result, s[:i])
s = s[i+1:]
i = findNextNonEscapedCharPosition(s, sep)
}
return append(result, s)
}
func hasPartialMatchBoundary(path string, matchedLength int) bool {
if matchedLength < 0 || matchedLength > len(path) {
return false
}
if matchedLength == len(path) {
return true
}
if matchedLength == 0 {
return false
}
if path[matchedLength-1] == slashDelimiter {
return true
}
if matchedLength < len(path) && path[matchedLength] == slashDelimiter {
return true
}
return false
}
// getMatch parses the passed url and tries to match it against the route segments and determine the parameter positions
func (parser *routeParser) getMatch(detectionPath, path string, params *[maxParams]string, partialCheck bool) bool { //nolint:revive // Accepting a bool param is fine here
originalDetectionPath := detectionPath
var i, paramsIterator, partLen int
for _, segment := range parser.segs {
partLen = len(detectionPath)
// check const segment
if !segment.IsParam {
i = segment.Length
// is optional part or the const part must match with the given string
// check if the end of the segment is an optional slash
if segment.HasOptionalSlash && partLen == i-1 && detectionPath == segment.Const[:i-1] {
i--
} else if i > partLen || detectionPath[:i] != segment.Const {
return false
}
} else {
// determine parameter length
i = findParamLen(detectionPath, segment)
if !segment.IsOptional && i == 0 {
return false
}
// take over the params positions
params[paramsIterator] = path[:i]
if !segment.IsOptional || i != 0 {
// check constraint
for _, c := range segment.Constraints {
if matched := c.CheckConstraint(params[paramsIterator]); !matched {
return false
}
}
}
paramsIterator++
}
// reduce founded part from the string
if partLen > 0 {
detectionPath, path = detectionPath[i:], path[i:]
}
}
if detectionPath != "" {
if !partialCheck {
return false
}
consumedLength := len(originalDetectionPath) - len(detectionPath)
if !hasPartialMatchBoundary(originalDetectionPath, consumedLength) {
return false
}
}
return true
}
// findParamLen for the expressjs wildcard behavior (right to left greedy)
// look at the other segments and take what is left for the wildcard from right to left
func findParamLen(s string, segment *routeSegment) int {
if segment.IsLast {
return findParamLenForLastSegment(s, segment)
}
if segment.Length != 0 && len(s) >= segment.Length {
return segment.Length
} else if segment.IsGreedy {
// Search the parameters until the next constant part
// special logic for greedy params
searchCount := strings.Count(s, segment.ComparePart)
if searchCount > 1 {
return findGreedyParamLen(s, searchCount, segment)
}
}
if len(segment.ComparePart) == 1 {
if constPosition := strings.IndexByte(s, segment.ComparePart[0]); constPosition != -1 {
return constPosition
}
} else if constPosition := strings.Index(s, segment.ComparePart); constPosition != -1 {
// if the compare part was found, but contains a slash although this part is not greedy, then it must not match
// example: /api/:param/fixedEnd -> path: /api/123/456/fixedEnd = no match , /api/123/fixedEnd = match
if !segment.IsGreedy && strings.IndexByte(s[:constPosition], slashDelimiter) != -1 {
return 0
}
return constPosition
}
return len(s)
}
// findParamLenForLastSegment get the length of the parameter if it is the last segment
func findParamLenForLastSegment(s string, seg *routeSegment) int {
if !seg.IsGreedy {
if i := strings.IndexByte(s, slashDelimiter); i != -1 {
return i
}
}
return len(s)
}
// findGreedyParamLen get the length of the parameter for greedy segments from right to left
func findGreedyParamLen(s string, searchCount int, segment *routeSegment) int {
// check all from right to left segments
for i := segment.PartCount; i > 0 && searchCount > 0; i-- {
searchCount--
constPosition := strings.LastIndex(s, segment.ComparePart)
if constPosition == -1 {
break
}
s = s[:constPosition]
}
return len(s)
}
// GetTrimmedParam trims the ':' & '?' from a string
func GetTrimmedParam(param string) string {
start := 0
end := len(param)
if end == 0 || param[start] != paramStarterChar { // is not a param
return param
}
start++
if param[end-1] == optionalParam { // is ?
end--
}
return param[start:end]
}
// RemoveEscapeChar removes escape characters
func RemoveEscapeChar(word string) string {
// Fast path: check if there are any escape characters first
escapeIdx := strings.IndexByte(word, '\\')
if escapeIdx == -1 {
return word // No escape chars, return original string without allocation
}
// Slow path: copy and remove escape characters
b := []byte(word)
dst := escapeIdx
for src := escapeIdx + 1; src < len(b); src++ {
if b[src] != '\\' {
b[dst] = b[src]
dst++
}
}
return string(b[:dst])
}
// RemoveEscapeCharBytes removes escape characters
func RemoveEscapeCharBytes(word []byte) []byte {
dst := 0
for src := range word {
if word[src] != '\\' {
word[dst] = word[src]
dst++
}
}
return word[:dst]
}
func getParamConstraintType(constraintPart string) TypeConstraint {
switch constraintPart {
case ConstraintInt:
return intConstraint
case ConstraintBool:
return boolConstraint
case ConstraintFloat:
return floatConstraint
case ConstraintAlpha:
return alphaConstraint
case ConstraintGUID:
return guidConstraint
case ConstraintMinLen, ConstraintMinLenLower:
return minLenConstraint
case ConstraintMaxLen, ConstraintMaxLenLower:
return maxLenConstraint
case ConstraintLen:
return lenConstraint
case ConstraintBetweenLen, ConstraintBetweenLenLower:
return betweenLenConstraint
case ConstraintMin:
return minConstraint
case ConstraintMax:
return maxConstraint
case ConstraintRange:
return rangeConstraint
case ConstraintDatetime:
return datetimeConstraint
case ConstraintRegex:
return regexConstraint
default:
return noConstraint
}
}
// CheckConstraint validates if a param matches the given constraint
// Returns true if the param passes the constraint check, false otherwise
func (c *Constraint) CheckConstraint(param string) bool {
// First check if there's a custom constraint with the same name
// This allows custom constraints to override built-in constraints
for _, cc := range c.customConstraints {
if cc.Name() == c.Name {
return cc.Execute(param, c.Data...)
}
}
var (
err error
num int
)
// Validate constraint has required data
if c.ID&needOneData != 0 && len(c.Data) == 0 {
return false
}
if c.ID&needTwoData != 0 && len(c.Data) < 2 {
return false
}
switch c.ID {
case noConstraint:
return true
case intConstraint:
_, err = strconv.Atoi(param)
case boolConstraint:
_, err = strconv.ParseBool(param)
case floatConstraint:
_, err = strconv.ParseFloat(param, 32)
case alphaConstraint:
for _, r := range param {
if !unicode.IsLetter(r) {
return false
}
}
case guidConstraint:
_, err = uuid.Parse(param)
case minLenConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
if len(param) < data {
return false
}
case maxLenConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
if len(param) > data {
return false
}
case lenConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
if len(param) != data {
return false
}
case betweenLenConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
data2, parseErr := strconv.Atoi(c.Data[1])
if parseErr != nil {
return false
}
length := len(param)
if length < data || length > data2 {
return false
}
case minConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
num, err = strconv.Atoi(param)
if err != nil || num < data {
return false
}
case maxConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
num, err = strconv.Atoi(param)
if err != nil || num > data {
return false
}
case rangeConstraint:
data, parseErr := strconv.Atoi(c.Data[0])
if parseErr != nil {
return false
}
data2, parseErr := strconv.Atoi(c.Data[1])
if parseErr != nil {
return false
}
num, err = strconv.Atoi(param)
if err != nil || num < data || num > data2 {
return false
}
case datetimeConstraint:
_, err = time.Parse(c.Data[0], param)
if err != nil {
return false
}
case regexConstraint:
if c.RegexCompiler == nil {
return false
}
if match := c.RegexCompiler.MatchString(param); !match {
return false
}
default:
return false
}
return err == nil
}