Skip to content

Commit

Permalink
✨ feature: route constraints (#1998)
Browse files Browse the repository at this point in the history
* Segment parameters constraints and determining it's type

* add parsing for constraints.

* fix tests

* add tests, benchs & some fixes.

* fix regex & datetime tests.

* clean up constraint parser, multiple constraint support.

* update

* regex customization.

* constants, remove variadic methods.

* add some benchs, refactor constraint check funtion.

* more readable conditions

* fix tests

* precompile regex

* precompile regex when parsing the route

* update comments
Co-authored-by: wernerr <rene@gofiber.io>

Co-authored-by: Mohab Abd El-Dayem <mohab.m.mohamed@gmail.com>
Co-authored-by: RW <rene@gofiber.io>
  • Loading branch information
3 people committed Aug 16, 2022
1 parent 95abdac commit 2517944
Show file tree
Hide file tree
Showing 3 changed files with 563 additions and 12 deletions.
18 changes: 18 additions & 0 deletions helpers.go
Expand Up @@ -695,3 +695,21 @@ const (
CookieSameSiteStrictMode = "strict"
CookieSameSiteNoneMode = "none"
)

// Route Constraints
const (
ConstraintInt = "int"
ConstraintBool = "bool"
ConstraintFloat = "float"
ConstraintAlpha = "alpha"
ConstraintGuid = "guid"
ConstraintMinLen = "minLen"
ConstraintMaxLen = "maxLen"
ConstraintExactLen = "exactLen"
ConstraintBetweenLen = "betweenLen"
ConstraintMin = "min"
ConstraintMax = "max"
ConstraintRange = "range"
ConstraintDatetime = "datetime"
ConstraintRegex = "regex"
)
297 changes: 285 additions & 12 deletions path.go
Expand Up @@ -7,9 +7,13 @@
package fiber

import (
"regexp"
"strconv"
"strings"
"time"
"unicode"

"github.com/gofiber/fiber/v2/internal/uuid"
"github.com/gofiber/fiber/v2/utils"
)

Expand All @@ -33,20 +37,54 @@ type routeSegment struct {
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
Length int // length of the parameter for segment, when its 0 then the length is undetermined
IsLast bool // shows if the segment is the last one for the route
HasOptionalSlash bool // segment has the possibility of an optional slash
Constraints []*Constraint // Constraint type if segment is a parameter, if not it will be set to noConstraint by default
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)?"
}

// different special routing signs
const (
wildcardParam byte = '*' // indicates a 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
wildcardParam byte = '*' // indicates a 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 datas of type constraint for a parameter
)

// parameter constraint types
type TypeConstraint int16

type Constraint struct {
ID TypeConstraint
RegexCompiler *regexp.Regexp
Data []string
}

const (
noConstraint TypeConstraint = iota + 1
intConstraint
boolConstraint
floatConstraint
alphaConstraint
datetimeConstraint
guidConstraint
minLenConstraint
maxLenConstraint
exactLenConstraint
betweenLenConstraint
minConstraint
maxConstraint
rangeConstraint
regexConstraint
)

// list of possible parameter and segment delimiter
Expand All @@ -61,6 +99,18 @@ var (
parameterDelimiterChars = append([]byte{paramStarterChar, escapeChar}, routeDelimiter...)
// list of chars to find the end of a parameter
parameterEndChars = append([]byte{optionalParam}, parameterDelimiterChars...)
// list of parameter constraint start
parameterConstraintStartChars = []byte{paramConstraintStart}
// list of parameter constraint end
parameterConstraintEndChars = []byte{paramConstraintEnd}
// list of parameter separator
parameterConstraintSeparatorChars = []byte{paramConstraintSeparator}
// list of parameter constraint data start
parameterConstraintDataStartChars = []byte{paramConstraintDataStart}
// list of parameter constraint data end
parameterConstraintDataEndChars = []byte{paramConstraintDataEnd}
// list of parameter constraint data separator
parameterConstraintDataSeparatorChars = []byte{paramConstraintDataSeparator}
)

// parseRoute analyzes the route and divides it into segments for constant areas and parameters,
Expand Down Expand Up @@ -177,8 +227,16 @@ func (routeParser *routeParser) analyseConstantPart(pattern string, nextParamPos
func (routeParser *routeParser) analyseParameterPart(pattern string) (string, *routeSegment) {
isWildCard := pattern[0] == wildcardParam
isPlusParam := pattern[0] == plusParam
parameterEndPosition := findNextNonEscapedCharsetPosition(pattern[1:], parameterEndChars)

var parameterEndPosition int
if strings.ContainsRune(pattern, rune(paramConstraintStart)) && strings.ContainsRune(pattern, rune(paramConstraintEnd)) {
parameterEndPosition = findNextCharsetPositionConstraint(pattern[1:], parameterEndChars)
} else {
parameterEndPosition = findNextNonEscapedCharsetPosition(pattern[1:], parameterEndChars)
}

parameterConstraintStart := -1
parameterConstraintEnd := -1
// handle wildcard end
if isWildCard || isPlusParam {
parameterEndPosition = 0
Expand All @@ -187,10 +245,53 @@ func (routeParser *routeParser) analyseParameterPart(pattern string) (string, *r
} else if !isInCharset(pattern[parameterEndPosition+1], parameterDelimiterChars) {
parameterEndPosition++
}

// find constraint part if exists in the parameter part and remove it
if parameterEndPosition > 0 {
parameterConstraintStart = findNextNonEscapedCharsetPosition(pattern[0:parameterEndPosition], parameterConstraintStartChars)
parameterConstraintEnd = findNextNonEscapedCharsetPosition(pattern[0:parameterEndPosition+1], parameterConstraintEndChars)
}

// cut params part
processedPart := pattern[0 : parameterEndPosition+1]

paramName := RemoveEscapeChar(GetTrimmedParam(processedPart))

// Check has constraint
var constraints []*Constraint

if hasConstraint := (parameterConstraintStart != -1 && parameterConstraintEnd != -1); hasConstraint {
constraintString := pattern[parameterConstraintStart+1 : parameterConstraintEnd]
userconstraints := strings.Split(constraintString, string(parameterConstraintSeparatorChars))
constraints = make([]*Constraint, 0, len(userconstraints))

for _, c := range userconstraints {
start := findNextNonEscapedCharsetPosition(c, parameterConstraintDataStartChars)
end := findNextNonEscapedCharsetPosition(c, parameterConstraintDataEndChars)

// Assign constraint
if start != -1 && end != -1 {
constraint := &Constraint{
ID: getParamConstraintType(c[:start]),
Data: strings.Split(RemoveEscapeChar(c[start+1:end]), string(parameterConstraintDataSeparatorChars)),
}

// Precompile regex if has regex constraint
if constraint.ID == regexConstraint {
constraint.RegexCompiler = regexp.MustCompile(constraint.Data[0])
}

constraints = append(constraints, constraint)
} else {
constraints = append(constraints, &Constraint{
ID: getParamConstraintType(c),
Data: []string{},
})
}
}

paramName = RemoveEscapeChar(GetTrimmedParam(pattern[0:parameterConstraintStart]))
}

// add access iterator to wildcard and plus
if isWildCard {
routeParser.wildCardCount++
Expand All @@ -200,12 +301,18 @@ func (routeParser *routeParser) analyseParameterPart(pattern string) (string, *r
paramName += strconv.Itoa(routeParser.plusCount)
}

return processedPart, &routeSegment{
segment := &routeSegment{
ParamName: paramName,
IsParam: true,
IsOptional: isWildCard || pattern[parameterEndPosition] == optionalParam,
IsGreedy: isWildCard || isPlusParam,
}

if len(constraints) > 0 {
segment.Constraints = constraints
}

return processedPart, segment
}

// isInCharset check is the given character in the charset list
Expand All @@ -230,6 +337,34 @@ func findNextCharsetPosition(search string, charset []byte) int {
return nextPosition
}

// findNextCharsetPositionConstraint search the next char position from the charset
// unlike findNextCharsetPosition, it takes care of constraint start-end chars to parse route pattern
func findNextCharsetPositionConstraint(search string, charset []byte) int {
nextPosition := -1
constraintStart := -1
constraintEnd := -1

for _, char := range charset {
pos := strings.IndexByte(search, char)

if char == paramConstraintStart {
constraintStart = pos
}

if char == paramConstraintEnd {
constraintEnd = pos
}
//fmt.Println(string(char))
if pos != -1 && (pos < nextPosition || nextPosition == -1) {
if pos > constraintStart && pos < constraintEnd {
nextPosition = pos
}
}
}

return nextPosition
}

// findNextNonEscapedCharsetPosition search the next char position from the charset and skip the escaped characters
func findNextNonEscapedCharsetPosition(search string, charset []byte) int {
pos := findNextCharsetPosition(search, charset)
Expand Down Expand Up @@ -272,6 +407,14 @@ func (routeParser *routeParser) getMatch(detectionPath, path string, params *[ma
}
// take over the params positions
params[paramsIterator] = path[:i]

// check constraint
for _, c := range segment.Constraints {
if matched := c.CheckConstraint(params[paramsIterator]); !matched {
return false
}
}

paramsIterator++
}

Expand Down Expand Up @@ -370,3 +513,133 @@ func RemoveEscapeChar(word string) string {
}
return word
}

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:
return minLenConstraint
case ConstraintMaxLen:
return maxLenConstraint
case ConstraintExactLen:
return exactLenConstraint
case ConstraintBetweenLen:
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
}

}

func (c *Constraint) CheckConstraint(param string) bool {
var err error
var num int

// check data exists
needOneData := []TypeConstraint{minLenConstraint, maxLenConstraint, exactLenConstraint, minConstraint, maxConstraint, datetimeConstraint, regexConstraint}
needTwoData := []TypeConstraint{betweenLenConstraint, rangeConstraint}

for _, data := range needOneData {
if c.ID == data && len(c.Data) == 0 {
return false
}
}

for _, data := range needTwoData {
if c.ID == data && len(c.Data) < 2 {
return false
}
}

// check constraints
switch c.ID {
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, _ := strconv.Atoi(c.Data[0])

if len(param) < data {
return false
}
case maxLenConstraint:
data, _ := strconv.Atoi(c.Data[0])

if len(param) > data {
return false
}
case exactLenConstraint:
data, _ := strconv.Atoi(c.Data[0])

if len(param) != data {
return false
}
case betweenLenConstraint:
data, _ := strconv.Atoi(c.Data[0])
data2, _ := strconv.Atoi(c.Data[1])
length := len(param)
if length < data || length > data2 {
return false
}
case minConstraint:
data, _ := strconv.Atoi(c.Data[0])
num, err = strconv.Atoi(param)

if num < data {
return false
}
case maxConstraint:
data, _ := strconv.Atoi(c.Data[0])
num, err = strconv.Atoi(param)

if num > data {
return false
}
case rangeConstraint:
data, _ := strconv.Atoi(c.Data[0])
data2, _ := strconv.Atoi(c.Data[1])
num, err = strconv.Atoi(param)

if num < data || num > data2 {
return false
}
case datetimeConstraint:
_, err = time.Parse(c.Data[0], param)
case regexConstraint:
if match := c.RegexCompiler.MatchString(param); !match {
return false
}
}

return err == nil
}

0 comments on commit 2517944

Please sign in to comment.