Skip to content

Commit f140cb3

Browse files
authored
[url] Add a new package containing URL helper functions (#752)
<!-- Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> ### Description This PR adds a new package containing helper functions for some common URL operations such as checking path segments, joining paths, splitting paths etc. ### Test Coverage <!-- Please put an `x` in the correct box e.g. `[x]` to indicate the testing coverage of this change. --> - [X] This change is covered by existing or additional automated tests. - [ ] Manual testing has been performed (and evidence provided) as automated testing was not feasible. - [ ] Additional tests are not required for this change (e.g. documentation update).
1 parent 98fb144 commit f140cb3

File tree

5 files changed

+704
-0
lines changed

5 files changed

+704
-0
lines changed

changes/20251124113919.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[url]` Add a new package containing url helper functions

utils/url/url.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package url
2+
3+
import (
4+
netUrl "net/url"
5+
"path"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/ARM-software/golang-utils/utils/collection"
10+
"github.com/ARM-software/golang-utils/utils/commonerrors"
11+
"github.com/ARM-software/golang-utils/utils/reflection"
12+
)
13+
14+
const (
15+
defaultPathSeparator = "/"
16+
minimumPathParameterLength = 3
17+
)
18+
19+
// Section 3.3 of RFC3986 details valid characters for path segments (see https://datatracker.ietf.org/doc/html/rfc3986#section-3.3)
20+
var validPathRegex = regexp.MustCompile(`^(?:[A-Za-z0-9._~\-!$&'()*+,;=:@{}]|%[0-9A-Fa-f]{2})+$`)
21+
22+
// PathSegmentMatcherFunc defines the signature for path segment matcher functions.
23+
type PathSegmentMatcherFunc = func(segmentA, segmentB string) (match bool, err error)
24+
25+
// ValidatePathParameter checks whether a path parameter is valid. An error is returned if it is invalid.
26+
// 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)
27+
func ValidatePathParameter(parameter string) error {
28+
if !MatchesPathParameterSyntax(parameter) {
29+
return commonerrors.Newf(commonerrors.ErrInvalid, "parameter %q must not be empty, cannot contain only whitespaces, have a length greater than or equal to three, start with an opening brace, and end with a closing brace", parameter)
30+
}
31+
32+
unescapedSegment, err := netUrl.PathUnescape(parameter)
33+
if err != nil {
34+
return commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "an error occurred during path unescaping for parameter %q", parameter)
35+
}
36+
37+
if !validPathRegex.MatchString(unescapedSegment) {
38+
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)
39+
}
40+
41+
return nil
42+
}
43+
44+
// MatchesPathParameterSyntax checks whether the parameter string matches the syntax for a path parameter as described by the OpenAPI spec (see https://spec.openapis.org/oas/v3.0.0.html#path-templating).
45+
func MatchesPathParameterSyntax(parameter string) bool {
46+
if reflection.IsEmpty(parameter) {
47+
return false
48+
}
49+
50+
if len(parameter) < minimumPathParameterLength {
51+
return false
52+
}
53+
54+
if !strings.HasPrefix(parameter, "{") || !strings.HasSuffix(parameter, "}") {
55+
return false
56+
}
57+
58+
return strings.Count(parameter, "{") == 1 && strings.Count(parameter, "}") == 1
59+
}
60+
61+
// HasMatchingPathSegments checks whether two path strings match based on their segments by doing a simple equality check on each path segment pair.
62+
func HasMatchingPathSegments(pathA, pathB string) (match bool, err error) {
63+
return MatchingPathSegments(pathA, pathB, BasicEqualityPathSegmentMatcher)
64+
}
65+
66+
// HasMatchingPathSegmentsWithParams is similar to HasMatchingPathSegments but also considers segments as matching if at least one of them contains a path parameter.
67+
//
68+
// HasMatchingPathSegmentsWithParams("/some/{param}/path", "/some/{param}/path") // true
69+
// HasMatchingPathSegmentsWithParams("/some/abc/path", "/some/{param}/path") // true
70+
// HasMatchingPathSegmentsWithParams("/some/abc/path", "/some/def/path") // false
71+
func HasMatchingPathSegmentsWithParams(pathA, pathB string) (match bool, err error) {
72+
return MatchingPathSegments(pathA, pathB, BasicEqualityPathSegmentWithParamMatcher)
73+
}
74+
75+
// BasicEqualityPathSegmentMatcher is a PathSegmentMatcherFunc that performs direct string comparison of two path segments.
76+
func BasicEqualityPathSegmentMatcher(segmentA, segmentB string) (match bool, err error) {
77+
match = segmentA == segmentB
78+
return
79+
}
80+
81+
// BasicEqualityPathSegmentWithParamMatcher is a PathSegmentMatcherFunc that is similar to BasicEqualityPathSegmentMatcher but accounts for path parameter segments.
82+
func BasicEqualityPathSegmentWithParamMatcher(segmentA, segmentB string) (match bool, err error) {
83+
if MatchesPathParameterSyntax(segmentA) {
84+
if errValidatePathASeg := ValidatePathParameter(segmentA); errValidatePathASeg != nil {
85+
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, errValidatePathASeg, "an error occurred while validating path parameter %q", segmentA)
86+
return
87+
}
88+
89+
match = !reflection.IsEmpty(segmentB)
90+
return
91+
}
92+
93+
if MatchesPathParameterSyntax(segmentB) {
94+
if errValidatePathBSeg := ValidatePathParameter(segmentB); errValidatePathBSeg != nil {
95+
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, errValidatePathBSeg, "an error occurred while validating path parameter %q", segmentB)
96+
return
97+
}
98+
99+
match = !reflection.IsEmpty(segmentA)
100+
return
101+
}
102+
103+
return BasicEqualityPathSegmentMatcher(segmentA, segmentB)
104+
}
105+
106+
// MatchingPathSegments checks whether two path strings match based on their segments using the provided matcher function.
107+
func MatchingPathSegments(pathA, pathB string, matcherFn PathSegmentMatcherFunc) (match bool, err error) {
108+
if reflection.IsEmpty(pathA) {
109+
err = commonerrors.UndefinedVariable("path A")
110+
return
111+
}
112+
113+
if reflection.IsEmpty(pathB) {
114+
err = commonerrors.UndefinedVariable("path B")
115+
return
116+
}
117+
118+
if matcherFn == nil {
119+
err = commonerrors.UndefinedVariable("segment matcher function")
120+
return
121+
}
122+
123+
unescapedPathA, errPathASeg := netUrl.PathUnescape(pathA)
124+
if errPathASeg != nil {
125+
err = commonerrors.WrapErrorf(commonerrors.ErrUnexpected, errPathASeg, "an error occurred while unescaping path %q", pathA)
126+
return
127+
}
128+
129+
unescapedPathB, errPathBSeg := netUrl.PathUnescape(pathB)
130+
if errPathBSeg != nil {
131+
err = commonerrors.WrapErrorf(commonerrors.ErrUnexpected, errPathBSeg, "an error occurred while unescaping path %q", pathB)
132+
return
133+
}
134+
135+
pathASegments := SplitPath(unescapedPathA)
136+
pathBSegments := SplitPath(unescapedPathB)
137+
if len(pathASegments) != len(pathBSegments) {
138+
return
139+
}
140+
141+
for i := range pathBSegments {
142+
match, err = matcherFn(pathASegments[i], pathBSegments[i])
143+
if err != nil {
144+
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])
145+
return
146+
}
147+
148+
if !match {
149+
return
150+
}
151+
}
152+
153+
match = true
154+
return
155+
}
156+
157+
// SplitPath returns a slice containing the individual segments that make up the path string p.
158+
// It looks for the default forward slash path separator when splitting.
159+
func SplitPath(p string) []string {
160+
if reflection.IsEmpty(p) {
161+
return []string{}
162+
}
163+
164+
p = path.Clean(p)
165+
p = strings.Trim(p, defaultPathSeparator)
166+
return collection.ParseListWithCleanup(p, defaultPathSeparator)
167+
}

0 commit comments

Comments
 (0)