Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/20251124113919.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: `[url]` Add a new package containing url helper functions
154 changes: 154 additions & 0 deletions utils/url/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package url

import (
netUrl "net/url"
"path"
"regexp"
"strings"

"github.com/ARM-software/golang-utils/utils/collection"
"github.com/ARM-software/golang-utils/utils/commonerrors"
"github.com/ARM-software/golang-utils/utils/reflection"
)

const (
defaultPathSeparator = "/"
minimumPathParameterLength = 3
)

var validParamRegex = regexp.MustCompile(`^\{[A-Za-z0-9_-]+\}$`)

// The expected signature for path segment matcher functions.
type PathSegmentMatcherFunc = func(segmentA, segmentB string) (match bool, err error)

// ValidatePathParameter checks whether a path parameter is valid. An error is returned if it is invalid.
// Version 3.1.0 of the OpenAPI spec provides some guidance for path parameter values (see https://spec.openapis.org/oas/v3.1.0.html#path-templating)
func ValidatePathParameter(parameter string) error {
if reflection.IsEmpty(parameter) || len(parameter) < minimumPathParameterLength {
return commonerrors.Newf(commonerrors.ErrInvalid, "parameter segment %q must have length greater than or equal to three", parameter)
}

unescapedSegment, err := netUrl.PathUnescape(parameter)
if err != nil {
return commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "an error occurred during path unescaping for parameter %q", parameter)
}

if !validParamRegex.MatchString(unescapedSegment) {
return commonerrors.Newf(commonerrors.ErrInvalid, "parameter %q unescaped to %q can only contain alphanumeric characters, dashes, underscores, and a single pair of braces", parameter, unescapedSegment)
}

return nil
}

// IsPathParameter checks whether the parameter string is a path parameter as described by the OpenAPI spec (see https://spec.openapis.org/oas/v3.0.0.html#path-templating).
func IsPathParameter(parameter string) bool {
return !reflection.IsEmpty(parameter) && strings.HasPrefix(parameter, "{") && strings.HasSuffix(parameter, "}")
}

// HasMatchingPathSegments checks whether two path strings match based on their segments by doing a simple equality check on each path segment pair.
func HasMatchingPathSegments(pathA, pathB string) (match bool, err error) {
return MatchingPathSegments(pathA, pathB, BasicEqualityPathSegmentMatcher)
}

// HasMatchingPathSegmentsWithParams is similar to HasMatchingPathSegments but also considers segments as matching if at least one of them contains a path parameter.
//
// HasMatchingPathSegmentsWithParams("/some/{param}/path", "/some/{param}/path") // true
// HasMatchingPathSegmentsWithParams("/some/abc/path", "/some/{param}/path") // true
// HasMatchingPathSegmentsWithParams("/some/abc/path", "/some/def/path") // false
func HasMatchingPathSegmentsWithParams(pathA, pathB string) (match bool, err error) {
return MatchingPathSegments(pathA, pathB, BasicEqualityPathSegmentWithParamMatcher)
}

// BasicEqualityPathSegmentMatcher is a PathSegmentMatcherFunc that performs direct string comparison of two path segments.
func BasicEqualityPathSegmentMatcher(segmentA, segmentB string) (match bool, err error) {
match = segmentA == segmentB
return
}

// BasicEqualityPathSegmentWithParamMatcher is a PathSegmentMatcherFunc that is similar to BasicEqualityPathSegmentMatcher but accounts for path parameter segments.
func BasicEqualityPathSegmentWithParamMatcher(segmentA, segmentB string) (match bool, err error) {
if IsPathParameter(segmentA) {
if errValidatePathASeg := ValidatePathParameter(segmentA); errValidatePathASeg != nil {
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, errValidatePathASeg, "an error occurred while validating path parameter %q", segmentA)
return
}

match = !reflection.IsEmpty(segmentB)
return
}

if IsPathParameter(segmentB) {
if errValidatePathBSeg := ValidatePathParameter(segmentB); errValidatePathBSeg != nil {
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, errValidatePathBSeg, "an error occurred while validating path parameter %q", segmentB)
return
}

match = !reflection.IsEmpty(segmentA)
return
}

return BasicEqualityPathSegmentMatcher(segmentA, segmentB)
}

// MatchingPathSegments checks whether two path strings match based on their segments using the provided matcher function.
func MatchingPathSegments(pathA, pathB string, matcherFn PathSegmentMatcherFunc) (match bool, err error) {
if reflection.IsEmpty(pathA) {
err = commonerrors.UndefinedVariable("path A")
return
}

if reflection.IsEmpty(pathB) {
err = commonerrors.UndefinedVariable("path B")
return
}

if matcherFn == nil {
err = commonerrors.UndefinedVariable("segment matcher function")
return
}

unescapedPathA, errPathASeg := netUrl.PathUnescape(pathA)
if errPathASeg != nil {
err = commonerrors.WrapErrorf(commonerrors.ErrUnexpected, errPathASeg, "an error occurred while unescaping path %q", pathA)
return
}

unescapedPathB, errPathBSeg := netUrl.PathUnescape(pathB)
if errPathBSeg != nil {
err = commonerrors.WrapErrorf(commonerrors.ErrUnexpected, errPathBSeg, "an error occurred while unescaping path %q", pathB)
return
}

pathASegments := SplitPath(unescapedPathA)
pathBSegments := SplitPath(unescapedPathB)
if len(pathASegments) != len(pathBSegments) {
return
}

for i := range pathBSegments {
match, err = matcherFn(pathASegments[i], pathBSegments[i])
if err != nil {
err = commonerrors.WrapErrorf(commonerrors.ErrUnexpected, err, "an error occurred during execution of the matcher function for path segments %q and %q", pathASegments[i], pathBSegments[i])
return
}

if !match {
return
}
}

match = true
return
}

// SplitPath returns a slice containing the individual segments that make up the path string p.
// It looks for the default forward slash path separator when splitting.
func SplitPath(p string) []string {
if reflection.IsEmpty(p) {
return []string{}
}

p = path.Clean(p)
p = strings.Trim(p, defaultPathSeparator)
return collection.ParseListWithCleanup(p, defaultPathSeparator)
}
Loading
Loading