From 9817353b7117a9893158356adebf44b6dc955184 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 8 Dec 2024 22:59:34 -0800 Subject: [PATCH] rework templated URI logic to fix services with query params --- cmd/rwp/cmd/serve/api.go | 5 +- pkg/manifest/href.go | 20 +- pkg/manifest/href_test.go | 6 +- pkg/manifest/manifest.go | 66 ++++- pkg/pub/service_guided_navigation.go | 2 +- pkg/util/url/uri_template.go | 110 ------- pkg/util/url/uri_template_test.go | 55 ---- pkg/util/url/uritemplates/README.txt | 2 + pkg/util/url/uritemplates/uritemplates.go | 248 ++++++++++++++++ .../url/uritemplates/uritemplates_test.go | 280 ++++++++++++++++++ pkg/util/url/uritemplates/utils.go | 32 ++ pkg/util/url/url.go | 11 + 12 files changed, 660 insertions(+), 177 deletions(-) delete mode 100644 pkg/util/url/uri_template.go delete mode 100644 pkg/util/url/uri_template_test.go create mode 100644 pkg/util/url/uritemplates/README.txt create mode 100644 pkg/util/url/uritemplates/uritemplates.go create mode 100644 pkg/util/url/uritemplates/uritemplates_test.go create mode 100644 pkg/util/url/uritemplates/utils.go diff --git a/cmd/rwp/cmd/serve/api.go b/cmd/rwp/cmd/serve/api.go index 0702487..dfcae9d 100644 --- a/cmd/rwp/cmd/serve/api.go +++ b/cmd/rwp/cmd/serve/api.go @@ -174,13 +174,16 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { return } - // Parse asset path + // Parse asset path from mux vars href, err := url.URLFromDecodedPath(path.Clean(vars["asset"])) if err != nil { slog.Error("failed parsing asset path as URL", "error", err) w.WriteHeader(400) return } + rawHref := href.Raw() + rawHref.RawQuery = r.URL.Query().Encode() // Add the query parameters of the URL + href, _ = url.RelativeURLFromGo(rawHref) // Turn it back into a go-toolkit relative URL // Make sure the asset exists in the publication link := publication.LinkWithHref(href) diff --git a/pkg/manifest/href.go b/pkg/manifest/href.go index 696fa49..d3ca7cf 100644 --- a/pkg/manifest/href.go +++ b/pkg/manifest/href.go @@ -2,6 +2,7 @@ package manifest import ( "github.com/readium/go-toolkit/pkg/util/url" + "github.com/readium/go-toolkit/pkg/util/url/uritemplates" ) // An hypertext reference points to a resource in a [Publication]. @@ -31,7 +32,11 @@ func MustNewHREFFromString(href string, templated bool) HREF { func NewHREFFromString(href string, templated bool) (HREF, error) { if templated { // Check that the produced URL is valid - _, err := url.URLFromString(url.NewURITemplate(href).Expand(map[string]string{})) + eurl, _, err := uritemplates.Expand(href, nil) + if err != nil { + return HREF{}, err + } + _, err = url.URLFromString(eurl) if err != nil { return HREF{}, err } @@ -51,11 +56,13 @@ func NewHREFFromString(href string, templated bool) (HREF, error) { // If the HREF is a template, the [parameters] are used to expand it according to RFC 6570. func (h HREF) Resolve(base url.URL, parameters map[string]string) url.URL { if h.IsTemplated() { - u, err := url.URLFromString( - url.NewURITemplate(h.template).Expand(parameters), - ) + exp, _, err := uritemplates.Expand(h.template, parameters) + if err != nil { + panic("Invalid URL template expansion: " + err.Error()) + } + u, err := url.URLFromString(exp) if err != nil { - panic("Invalid URL template expansion") + panic("Invalid URL template expansion: " + err.Error()) } if base == nil { return u @@ -77,7 +84,8 @@ func (h HREF) IsTemplated() bool { // List of URI template parameter keys, if the HREF is templated. func (h HREF) Parameters() []string { if h.IsTemplated() { - return url.NewURITemplate(h.template).Parameters() + v, _ := uritemplates.Values(h.template) + return v } return []string{} } diff --git a/pkg/manifest/href_test.go b/pkg/manifest/href_test.go index d8f1cc8..d053c62 100644 --- a/pkg/manifest/href_test.go +++ b/pkg/manifest/href_test.go @@ -29,13 +29,13 @@ func TestConvertTemplatedHREFToURL(t *testing.T) { "foo": "bar", } - u, _ := url.URLFromString("url?x=&hello=&y=name") + u, _ := url.URLFromString("urlname") assert.Equal(t, u, template.Resolve(nil, nil)) - u, _ = url.URLFromString("http://readium/publication/url?x=&hello=&y=name") + u, _ = url.URLFromString("http://readium/publication/urlname") assert.Equal(t, u, template.Resolve(base, nil)) - u, _ = url.URLFromString("http://readium/publication/url?x=aaa&hello=Hello,%20world&y=bname") + u, _ = url.URLFromString("http://readium/publication/url?x=aaa&hello=Hello%2C%20world&y=bname") assert.Equal(t, u, template.Resolve(base, parameters)) } diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index cdf5c9b..e9e077a 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -2,6 +2,7 @@ package manifest import ( "encoding/json" + "slices" "github.com/pkg/errors" "github.com/readium/go-toolkit/pkg/internal/extensions" @@ -56,12 +57,75 @@ func (m Manifest) ConformsTo(profile Profile) bool { // Searches through (in order) the reading order, resources and links recursively following alternate and children links. // If there's no match, tries again after removing any query parameter and anchor from the given href. func (m Manifest) LinkWithHref(href url.URL) *Link { + href = href.Normalize() // Normalize HREF here instead of in the loop + var deepLinkWithHref func(ll LinkList, href url.URL) *Link deepLinkWithHref = func(ll LinkList, href url.URL) *Link { for _, l := range ll { - if l.URL(nil, nil).Equivalent(href) { + nu := l.URL(nil, nil).Normalize() // Normalized version of the href + + if nu.Equivalent(href) { + // Exactly equivalent after normalization return &l } else { + // Check if they have the same relative path after resolving, + // and no fragment, meaning only the query could be different + if nu.Path() == href.Path() && href.Fragment() == "" { + // Check for a possible fit in a templated href + // This is a special fast path for web services accepting arbitrary query parameters in the URL + if l.Href.IsTemplated() { // Templated URI + if params := l.Href.Parameters(); len(params) > 0 { + // At least one parameter in the URI template + matches := true + + // Check that every parameter in the URI template is present by key in the query + for _, p := range params { + if !href.Query().Has(p) { + matches = false + break + } + } + if matches { + // All template parameters are present in the query parameters + return &l + } + } + } else { + // Check for a possible fit in an href with query parameters + // This is a special fast path for web services accepting arbitrary query parameters in the URL + if len(nu.Query()) > 0 && len(href.Query()) > 0 { + // Both the give href and the one we're checking have query parameters + // If the given href has all the key/value pairs in the query that the + // one we're checking has, then they're equivalent! + matches := true + q := href.Query() + for k, v := range nu.Query() { + slices.Sort(v) + if qv, ok := q[k]; ok { + if len(qv) > 1 { + slices.Sort(qv) + if !slices.Equal(qv, v) { + matches = false + break + } + } else { + if qv[0] != v[0] { + matches = false + break + } + } + } else { + matches = false + break + } + } + if matches { + return &l + } + } + } + } + if link := deepLinkWithHref(l.Alternates, href); link != nil { return link } diff --git a/pkg/pub/service_guided_navigation.go b/pkg/pub/service_guided_navigation.go index 4792891..18234f2 100644 --- a/pkg/pub/service_guided_navigation.go +++ b/pkg/pub/service_guided_navigation.go @@ -33,7 +33,7 @@ type GuidedNavigationService interface { func GetForGuidedNavigationService(service GuidedNavigationService, link manifest.Link) (fetcher.Resource, bool) { u := link.URL(nil, nil) - if !u.Equivalent(resolvedGuidedNavigation) { + if u.Path() != resolvedGuidedNavigation.Path() { // Not the guided navigation link return nil, false } diff --git a/pkg/util/url/uri_template.go b/pkg/util/url/uri_template.go deleted file mode 100644 index 058d143..0000000 --- a/pkg/util/url/uri_template.go +++ /dev/null @@ -1,110 +0,0 @@ -package url - -import ( - "strings" - - "github.com/agext/regexp" - - "github.com/readium/go-toolkit/pkg/internal/extensions" -) - -/** - * A lightweight implementation of URI Template (RFC 6570). - * - * Only handles simple cases, fitting Readium's use cases. - * See https://tools.ietf.org/html/rfc6570 - */ - -type URITemplate struct { - uri string -} - -func NewURITemplate(uri string) URITemplate { - return URITemplate{ - uri: uri, - } -} - -var paramRegex = regexp.MustCompile(`\{\??([^}]+)\}`) -var expandRegex = regexp.MustCompile(`\{(\??)([^}]+)\}`) - -// List of URI template parameter keys. -func (u URITemplate) Parameters() []string { - params := paramRegex.FindAllStringSubmatch(u.uri, -1) - ret := make([]string, 0, len(params)) - for _, p := range params { - if len(p) != 2 { - continue - } - for _, v := range strings.Split(p[1], ",") { - ret = extensions.AddToSet(ret, v) - } - } - - return ret -} - -func expandSimpleString(s string, parameters map[string]string) string { - strs := strings.Split(s, ",") - for i, str := range strs { - v, _ := parameters[str] - strs[i] = v - } - return strings.Join(strs, ",") -} - -func expandFormStyle(s string, parameters map[string]string) string { - strs := strings.Split(s, ",") - var sb strings.Builder - sb.WriteRune('?') - var added bool - for i, str := range strs { - v := parameters[str] - if i != 0 { - sb.WriteRune('&') - } - added = true - sb.WriteString(str) - sb.WriteRune('=') - if v == "" { - continue - } - sb.WriteString(v) - } - if !added { - // Remove '?' if no params were actually added - s := sb.String() - return s[:len(s)-1] - } - return sb.String() -} - -// Expands the HREF by replacing URI template variables by the given parameters. -func (u URITemplate) Expand(parameters map[string]string) string { - // `+` is considered like an encoded space, and will not be properly encoded in parameters. - // This is an issue for ISO 8601 date for example. - // As a workaround, we encode manually this character. We don't do it in the full URI, - // because it could contain some legitimate +-as-space characters. - for k, v := range parameters { - parameters[k] = strings.Replace(extensions.AddPercentEncodingPath(v), "+", "~~+~~", -1) - } - - href := expandRegex.ReplaceAllStringSubmatchFunc(u.uri, func(s []string) string { - if len(s) != 3 { - return "" - } - if s[1] == "" { - return expandSimpleString(s[2], parameters) - } else { - return expandFormStyle(s[2], parameters) - } - }) - - xx := strings.ReplaceAll(strings.ReplaceAll(href, "~~%20~~", "%2B"), "~~+~~", "%2B") - return xx - -} - -func (u URITemplate) Description() string { - return u.uri -} diff --git a/pkg/util/url/uri_template_test.go b/pkg/util/url/uri_template_test.go deleted file mode 100644 index 5e90693..0000000 --- a/pkg/util/url/uri_template_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package url - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestUTParameters(t *testing.T) { - assert.Equal( - t, - []string{"x", "hello", "y", "z", "w"}, - NewURITemplate("/url{?x,hello,y}name{z,y,w}").Parameters(), - ) -} - -func TestUTExpandSimpleStringTemplates(t *testing.T) { - parameters := map[string]string{ - "x": "aaa", - "hello": "Hello, world", - "y": "b", - "z": "45", - "w": "w", - } - assert.Equal( - t, - "/urlaaa,Hello,%20world,bname45,b,w", - NewURITemplate("/url{x,hello,y}name{z,y,w}").Expand(parameters), - ) -} - -func TestUTExpandComplicatedTemplated(t *testing.T) { // form-style ampersand-separated templates - parameters := map[string]string{ - "x": "aaa", - "hello": "Hello, world", - "y": "b", - } - assert.Equal( - t, - "/url?x=aaa&hello=Hello,%20world&y=bname", - NewURITemplate("/url{?x,hello,y}name").Expand(parameters), - ) - - assert.Equal( - t, - "https://lsd-test.edrlab.org/licenses/39ef1ff2-cda2-4219-a26a-d504fbb24c17/renew?end=2020-11-12T16:02:00.000%2B01:00&id=38dfd7ba-a80b-4253-a047-e6aa9c21d6f0&name=Pixel%203a", - NewURITemplate( - "https://lsd-test.edrlab.org/licenses/39ef1ff2-cda2-4219-a26a-d504fbb24c17/renew{?end,id,name}", - ).Expand(map[string]string{ - "id": "38dfd7ba-a80b-4253-a047-e6aa9c21d6f0", - "name": "Pixel 3a", - "end": "2020-11-12T16:02:00.000+01:00", - }), - ) -} diff --git a/pkg/util/url/uritemplates/README.txt b/pkg/util/url/uritemplates/README.txt new file mode 100644 index 0000000..dde83e9 --- /dev/null +++ b/pkg/util/url/uritemplates/README.txt @@ -0,0 +1,2 @@ +Copied from https://github.com/googleapis/google-api-go-client/tree/main/internal/third_party/uritemplates +Modified to have a Values function \ No newline at end of file diff --git a/pkg/util/url/uritemplates/uritemplates.go b/pkg/util/url/uritemplates/uritemplates.go new file mode 100644 index 0000000..8c27d19 --- /dev/null +++ b/pkg/util/url/uritemplates/uritemplates.go @@ -0,0 +1,248 @@ +// Copyright 2013 Joshua Tacoma. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package uritemplates is a level 3 implementation of RFC 6570 (URI +// Template, http://tools.ietf.org/html/rfc6570). +// uritemplates does not support composite values (in Go: slices or maps) +// and so does not qualify as a level 4 implementation. +package uritemplates + +import ( + "bytes" + "errors" + "regexp" + "strconv" + "strings" +) + +var ( + unreserved = regexp.MustCompile("[^A-Za-z0-9\\-._~]") + reserved = regexp.MustCompile("[^A-Za-z0-9\\-._~:/?#[\\]@!$&'()*+,;=]") + validname = regexp.MustCompile("^([A-Za-z0-9_\\.]|%[0-9A-Fa-f][0-9A-Fa-f])+$") + hex = []byte("0123456789ABCDEF") +) + +func pctEncode(src []byte) []byte { + dst := make([]byte, len(src)*3) + for i, b := range src { + buf := dst[i*3 : i*3+3] + buf[0] = 0x25 + buf[1] = hex[b/16] + buf[2] = hex[b%16] + } + return dst +} + +// pairWriter is a convenience struct which allows escaped and unescaped +// versions of the template to be written in parallel. +type pairWriter struct { + escaped, unescaped bytes.Buffer +} + +// Write writes the provided string directly without any escaping. +func (w *pairWriter) Write(s string) { + w.escaped.WriteString(s) + w.unescaped.WriteString(s) +} + +// Escape writes the provided string, escaping the string for the +// escaped output. +func (w *pairWriter) Escape(s string, allowReserved bool) { + w.unescaped.WriteString(s) + if allowReserved { + w.escaped.Write(reserved.ReplaceAllFunc([]byte(s), pctEncode)) + } else { + w.escaped.Write(unreserved.ReplaceAllFunc([]byte(s), pctEncode)) + } +} + +// Escaped returns the escaped string. +func (w *pairWriter) Escaped() string { + return w.escaped.String() +} + +// Unescaped returns the unescaped string. +func (w *pairWriter) Unescaped() string { + return w.unescaped.String() +} + +// A uriTemplate is a parsed representation of a URI template. +type uriTemplate struct { + raw string + parts []templatePart +} + +// parse parses a URI template string into a uriTemplate object. +func parse(rawTemplate string) (*uriTemplate, error) { + split := strings.Split(rawTemplate, "{") + parts := make([]templatePart, len(split)*2-1) + for i, s := range split { + if i == 0 { + if strings.Contains(s, "}") { + return nil, errors.New("unexpected }") + } + parts[i].raw = s + continue + } + subsplit := strings.Split(s, "}") + if len(subsplit) != 2 { + return nil, errors.New("malformed template") + } + expression := subsplit[0] + var err error + parts[i*2-1], err = parseExpression(expression) + if err != nil { + return nil, err + } + parts[i*2].raw = subsplit[1] + } + return &uriTemplate{ + raw: rawTemplate, + parts: parts, + }, nil +} + +type templatePart struct { + raw string + terms []templateTerm + first string + sep string + named bool + ifemp string + allowReserved bool +} + +type templateTerm struct { + name string + explode bool + truncate int +} + +func parseExpression(expression string) (result templatePart, err error) { + switch expression[0] { + case '+': + result.sep = "," + result.allowReserved = true + expression = expression[1:] + case '.': + result.first = "." + result.sep = "." + expression = expression[1:] + case '/': + result.first = "/" + result.sep = "/" + expression = expression[1:] + case ';': + result.first = ";" + result.sep = ";" + result.named = true + expression = expression[1:] + case '?': + result.first = "?" + result.sep = "&" + result.named = true + result.ifemp = "=" + expression = expression[1:] + case '&': + result.first = "&" + result.sep = "&" + result.named = true + result.ifemp = "=" + expression = expression[1:] + case '#': + result.first = "#" + result.sep = "," + result.allowReserved = true + expression = expression[1:] + default: + result.sep = "," + } + rawterms := strings.Split(expression, ",") + result.terms = make([]templateTerm, len(rawterms)) + for i, raw := range rawterms { + result.terms[i], err = parseTerm(raw) + if err != nil { + break + } + } + return result, err +} + +func parseTerm(term string) (result templateTerm, err error) { + // TODO(djd): Remove "*" suffix parsing once we check that no APIs have + // mistakenly used that attribute. + if strings.HasSuffix(term, "*") { + result.explode = true + term = term[:len(term)-1] + } + split := strings.Split(term, ":") + if len(split) == 1 { + result.name = term + } else if len(split) == 2 { + result.name = split[0] + var parsed int64 + parsed, err = strconv.ParseInt(split[1], 10, 0) + result.truncate = int(parsed) + } else { + err = errors.New("multiple colons in same term") + } + if !validname.MatchString(result.name) { + err = errors.New("not a valid name: " + result.name) + } + if result.explode && result.truncate > 0 { + err = errors.New("both explode and prefix modifiers on same term") + } + return result, err +} + +// Expand expands a URI template with a set of values to produce the +// resultant URI. Two forms of the result are returned: one with all the +// elements escaped, and one with the elements unescaped. +func (t *uriTemplate) Expand(values map[string]string) (escaped, unescaped string) { + var w pairWriter + for _, p := range t.parts { + p.expand(&w, values) + } + return w.Escaped(), w.Unescaped() +} + +func (tp *templatePart) expand(w *pairWriter, values map[string]string) { + if len(tp.raw) > 0 { + w.Write(tp.raw) + return + } + var first = true + for _, term := range tp.terms { + value, exists := values[term.name] + if !exists { + continue + } + if first { + w.Write(tp.first) + first = false + } else { + w.Write(tp.sep) + } + tp.expandString(w, term, value) + } +} + +func (tp *templatePart) expandName(w *pairWriter, name string, empty bool) { + if tp.named { + w.Write(name) + if empty { + w.Write(tp.ifemp) + } else { + w.Write("=") + } + } +} + +func (tp *templatePart) expandString(w *pairWriter, t templateTerm, s string) { + if len(s) > t.truncate && t.truncate > 0 { + s = s[:t.truncate] + } + tp.expandName(w, t.name, len(s) == 0) + w.Escape(s, tp.allowReserved) +} diff --git a/pkg/util/url/uritemplates/uritemplates_test.go b/pkg/util/url/uritemplates/uritemplates_test.go new file mode 100644 index 0000000..1296d37 --- /dev/null +++ b/pkg/util/url/uritemplates/uritemplates_test.go @@ -0,0 +1,280 @@ +// Copyright 2013 Joshua Tacoma. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package uritemplates + +import ( + "fmt" + "log" + "net/url" + "testing" +) + +func ExampleExpand() { + values := map[string]string{ + "user": "golang", + "repo": "go", + } + expanded, _, err := Expand("https://api.github.com/repos{/user,repo}", values) + if err != nil { + log.Fatalf("Error expanding template: %v", err) + } + fmt.Println(expanded) + // Output: + // https://api.github.com/repos/golang/go +} + +func TestExpand(t *testing.T) { + testCases := []struct { + tmpl string + values map[string]string + want string + }{ + // These examples come from the RFC: + // http://tools.ietf.org/html/rfc6570 + { + tmpl: "http://www.example.com/foo{?query,number}", + values: map[string]string{"query": "mycelium", "number": "100"}, + want: "http://www.example.com/foo?query=mycelium&number=100", + }, + { + tmpl: "http://www.example.com/foo{?query,number}", + values: map[string]string{"query": "mycelium"}, + want: "http://www.example.com/foo?query=mycelium", + }, + { + tmpl: "http://www.example.com/foo{?query,number}", + values: map[string]string{}, + want: "http://www.example.com/foo", + }, + } + + for _, tt := range testCases { + exp, _, err := Expand(tt.tmpl, tt.values) + if err != nil { + t.Errorf("Expand(%q, %v) error: %v", tt.tmpl, tt.values, err) + continue + } + if exp != tt.want { + t.Errorf("Expand(%q, %v)\ngot %q\nwant %q", tt.tmpl, tt.values, exp, tt.want) + } + } +} + +func TestExpandRFCLevels(t *testing.T) { + values := map[string]string{ + "dub": "me/too", + "hello": "Hello World!", + "half": "50%", + "var": "value", + "who": "fred", + "base": "http://example.com/home/", + "path": "/foo/bar", + "semi": ";", + "v": "6", + "x": "1024", + "y": "768", + "empty": "", + // undef not mapped. + } + testCases := []struct { + tmpl, want string + }{ + // These examples come from the RFC levels specification. + // http://tools.ietf.org/html/rfc6570 + // Level 1 examples. + {tmpl: "{var}", want: "value"}, + {tmpl: "{hello}", want: "Hello%20World%21"}, + + // Level 2 examples. + {tmpl: "{+var}", want: "value"}, + {tmpl: "{+hello}", want: "Hello%20World!"}, + {tmpl: "{+path}/here", want: "/foo/bar/here"}, + {tmpl: "here?ref={+path}", want: "here?ref=/foo/bar"}, + {tmpl: "X{#var}", want: "X#value"}, + {tmpl: "X{#hello}", want: "X#Hello%20World!"}, + + // Level 3 examples. + {tmpl: "map?{x,y}", want: "map?1024,768"}, + {tmpl: "{x,hello,y}", want: "1024,Hello%20World%21,768"}, + {tmpl: "{+x,hello,y}", want: "1024,Hello%20World!,768"}, + {tmpl: "{+path,x}/here", want: "/foo/bar,1024/here"}, + {tmpl: "{#x,hello,y}", want: "#1024,Hello%20World!,768"}, + {tmpl: "{#path,x}/here", want: "#/foo/bar,1024/here"}, + {tmpl: "X{.var}", want: "X.value"}, + {tmpl: "X{.x,y}", want: "X.1024.768"}, + {tmpl: "{/var}", want: "/value"}, + {tmpl: "{/var,x}/here", want: "/value/1024/here"}, + {tmpl: "{;x,y}", want: ";x=1024;y=768"}, + {tmpl: "{;x,y,empty}", want: ";x=1024;y=768;empty"}, + {tmpl: "{?x,y}", want: "?x=1024&y=768"}, + {tmpl: "{?x,y,empty}", want: "?x=1024&y=768&empty="}, + {tmpl: "?fixed=yes{&x}", want: "?fixed=yes&x=1024"}, + {tmpl: "{&x,y,empty}", want: "&x=1024&y=768&empty="}, + + {tmpl: "{var:3}", want: "val"}, + {tmpl: "{var:30}", want: "value"}, + {tmpl: "{+path:6}/here", want: "/foo/b/here"}, + {tmpl: "{#path:6}/here", want: "#/foo/b/here"}, + {tmpl: "X{.var:3}", want: "X.val"}, + {tmpl: "{/var:1,var}", want: "/v/value"}, + {tmpl: "{;hello:5}", want: ";hello=Hello"}, + {tmpl: "{?var:3}", want: "?var=val"}, + {tmpl: "{&var:3}", want: "&var=val"}, + + // 2.4.1 Prefix values. + {tmpl: "{var}", want: "value"}, + {tmpl: "{var:20}", want: "value"}, + {tmpl: "{var:3}", want: "val"}, + {tmpl: "{semi}", want: "%3B"}, + {tmpl: "{semi:2}", want: "%3B"}, + // 3.2.2. Simple String Expansion: {var} + {tmpl: "{var}", want: "value"}, + {tmpl: "{hello}", want: "Hello%20World%21"}, + {tmpl: "{half}", want: "50%25"}, + {tmpl: "O{empty}X", want: "OX"}, + {tmpl: "O{undef}X", want: "OX"}, + {tmpl: "{x,y}", want: "1024,768"}, + {tmpl: "{x,hello,y}", want: "1024,Hello%20World%21,768"}, + {tmpl: "?{x,empty}", want: "?1024,"}, + {tmpl: "?{x,undef}", want: "?1024"}, + {tmpl: "?{undef,y}", want: "?768"}, + {tmpl: "{var:3}", want: "val"}, + {tmpl: "{var:30}", want: "value"}, + // 3.2.3. Reserved Expansion: {+var} + {tmpl: "{+var}", want: "value"}, + {tmpl: "{+hello}", want: "Hello%20World!"}, + {tmpl: "{+half}", want: "50%25"}, + {tmpl: "{base}index", want: "http%3A%2F%2Fexample.com%2Fhome%2Findex"}, + {tmpl: "{+base}index", want: "http://example.com/home/index"}, + {tmpl: "O{+empty}X", want: "OX"}, + {tmpl: "O{+undef}X", want: "OX"}, + {tmpl: "{+path}/here", want: "/foo/bar/here"}, + {tmpl: "here?ref={+path}", want: "here?ref=/foo/bar"}, + {tmpl: "up{+path}{var}/here", want: "up/foo/barvalue/here"}, + {tmpl: "{+x,hello,y}", want: "1024,Hello%20World!,768"}, + {tmpl: "{+path,x}/here", want: "/foo/bar,1024/here"}, + {tmpl: "{+path:6}/here", want: "/foo/b/here"}, + // 3.2.4. Fragment Expansion: {#var} + {tmpl: "{#var}", want: "#value"}, + {tmpl: "{#hello}", want: "#Hello%20World!"}, + {tmpl: "{#half}", want: "#50%25"}, + {tmpl: "foo{#empty}", want: "foo#"}, + {tmpl: "foo{#undef}", want: "foo"}, + {tmpl: "{#x,hello,y}", want: "#1024,Hello%20World!,768"}, + {tmpl: "{#path,x}/here", want: "#/foo/bar,1024/here"}, + {tmpl: "{#path:6}/here", want: "#/foo/b/here"}, + // 3.2.5. Label Expansion with Dot-Prefix: {.var} + {tmpl: "{.who}", want: ".fred"}, + {tmpl: "{.who,who}", want: ".fred.fred"}, + {tmpl: "{.half,who}", want: ".50%25.fred"}, + {tmpl: "X{.var}", want: "X.value"}, + {tmpl: "X{.empty}", want: "X."}, + {tmpl: "X{.undef}", want: "X"}, + {tmpl: "X{.var:3}", want: "X.val"}, + // 3.2.6. Path Segment Expansion: {/var} + {tmpl: "{/who}", want: "/fred"}, + {tmpl: "{/who,who}", want: "/fred/fred"}, + {tmpl: "{/half,who}", want: "/50%25/fred"}, + {tmpl: "{/who,dub}", want: "/fred/me%2Ftoo"}, + {tmpl: "{/var}", want: "/value"}, + {tmpl: "{/var,empty}", want: "/value/"}, + {tmpl: "{/var,undef}", want: "/value"}, + {tmpl: "{/var,x}/here", want: "/value/1024/here"}, + {tmpl: "{/var:1,var}", want: "/v/value"}, + // 3.2.7. Path-Style Parameter Expansion: {;var} + {tmpl: "{;who}", want: ";who=fred"}, + {tmpl: "{;half}", want: ";half=50%25"}, + {tmpl: "{;empty}", want: ";empty"}, + {tmpl: "{;v,empty,who}", want: ";v=6;empty;who=fred"}, + {tmpl: "{;v,bar,who}", want: ";v=6;who=fred"}, + {tmpl: "{;x,y}", want: ";x=1024;y=768"}, + {tmpl: "{;x,y,empty}", want: ";x=1024;y=768;empty"}, + {tmpl: "{;x,y,undef}", want: ";x=1024;y=768"}, + {tmpl: "{;hello:5}", want: ";hello=Hello"}, + // 3.2.8. Form-Style Query Expansion: {?var} + {tmpl: "{?who}", want: "?who=fred"}, + {tmpl: "{?half}", want: "?half=50%25"}, + {tmpl: "{?x,y}", want: "?x=1024&y=768"}, + {tmpl: "{?x,y,empty}", want: "?x=1024&y=768&empty="}, + {tmpl: "{?x,y,undef}", want: "?x=1024&y=768"}, + {tmpl: "{?var:3}", want: "?var=val"}, + // 3.2.9. Form-Style Query Continuation: {&var} + {tmpl: "{&who}", want: "&who=fred"}, + {tmpl: "{&half}", want: "&half=50%25"}, + {tmpl: "?fixed=yes{&x}", want: "?fixed=yes&x=1024"}, + {tmpl: "{&x,y,empty}", want: "&x=1024&y=768&empty="}, + {tmpl: "{&x,y,undef}", want: "&x=1024&y=768"}, + {tmpl: "{&var:3}", want: "&var=val"}, + } + for _, tt := range testCases { + esc, unesc, err := Expand(tt.tmpl, values) + if err != nil { + t.Errorf("Expand(%q) error: %v", tt.tmpl, err) + continue + } + if esc != tt.want { + t.Errorf("Expand(%q)\ngot %q\nwant %q", tt.tmpl, esc, tt.want) + } + // Check that the escaped form is equivalent to unescaped. + urlUnesc, err := url.QueryUnescape(esc) + if err != nil { + t.Errorf("Expand(%q) gave invalid escaping %q: %v", tt.tmpl, esc, err) + continue + } + if urlUnesc != unesc { + t.Errorf("Expand(%q) gave inconsistent escaped/unescaped\nunescaped %q\nescaped %q\nwhich is %q", tt.tmpl, unesc, esc, urlUnesc) + } + } +} + +func TestExpandUnescaped(t *testing.T) { + testCases := []struct { + tmpl, wantEsc, wantUnesc string + values map[string]string + }{ + { + tmpl: "/foo/{bucket}/bar", + values: map[string]string{ + "bucket": "simple", + }, + wantEsc: "/foo/simple/bar", + wantUnesc: "/foo/simple/bar", + }, + { + tmpl: "/foo/{bucket}/bar", + values: map[string]string{ + "bucket": "path/with/slash", + }, + wantEsc: "/foo/path%2Fwith%2Fslash/bar", + wantUnesc: "/foo/path/with/slash/bar", + }, + { + tmpl: "/foo/{+bucket}/bar", + values: map[string]string{ + "bucket": "path/with/slash", + }, + wantEsc: "/foo/path/with/slash/bar", + wantUnesc: "/foo/path/with/slash/bar", + }, + { + tmpl: "/foo/{bucket}/bar", + values: map[string]string{ + "bucket": "double%2Fescaped", + }, + wantEsc: "/foo/double%252Fescaped/bar", + wantUnesc: "/foo/double%2Fescaped/bar", + }, + } + for _, tt := range testCases { + esc, unesc, err := Expand(tt.tmpl, tt.values) + if err != nil { + t.Errorf("Expand(%q) error: %v", tt.tmpl, err) + continue + } + if esc != tt.wantEsc || unesc != tt.wantUnesc { + t.Errorf("Expand(%q)\ngot esc=%q, unesc=%q\nwant esc=%q, unesc=%q", tt.tmpl, esc, unesc, tt.wantEsc, tt.wantUnesc) + } + } +} diff --git a/pkg/util/url/uritemplates/utils.go b/pkg/util/url/uritemplates/utils.go new file mode 100644 index 0000000..7d44f37 --- /dev/null +++ b/pkg/util/url/uritemplates/utils.go @@ -0,0 +1,32 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package uritemplates + +// Expand parses then expands a URI template with a set of values to produce +// the resultant URI. Two forms of the result are returned: one with all the +// elements escaped, and one with the elements unescaped. +func Expand(path string, values map[string]string) (escaped, unescaped string, err error) { + template, err := parse(path) + if err != nil { + return "", "", err + } + escaped, unescaped = template.Expand(values) + return escaped, unescaped, nil +} + +// Values returns the names of the variables in the URI template. +func Values(path string) ([]string, error) { + template, err := parse(path) + if err != nil { + return []string{}, err + } + parts := []string{} + for _, part := range template.parts { + for _, term := range part.terms { + parts = append(parts, term.name) + } + } + return parts, nil +} diff --git a/pkg/util/url/url.go b/pkg/util/url/url.go index 088fca7..998e186 100644 --- a/pkg/util/url/url.go +++ b/pkg/util/url/url.go @@ -29,6 +29,7 @@ type URL interface { Relativize(url URL) URL // Relativizes the given [url] against this URL. Normalize() URL // Normalizes the URL using a subset of the RFC-3986 rules (https://datatracker.ietf.org/doc/html/rfc3986#section-6). String() string // Encodes the URL to a string. + Raw() gurl.URL // Returns the underlying Go URL. Equivalent(url URL) bool // Returns whether the receiver is equivalent to the given `url` after normalization. } @@ -192,6 +193,11 @@ func (u RelativeURL) String() string { return u.url.String() } +// Raw implements URL +func (u RelativeURL) Raw() gurl.URL { + return u.url +} + // Equivalent implements URL func (u RelativeURL) Equivalent(url URL) bool { return u.Normalize().String() == url.Normalize().String() @@ -337,6 +343,11 @@ func (u AbsoluteURL) String() string { return u.url.String() } +// Raw implements URL +func (u AbsoluteURL) Raw() gurl.URL { + return u.url +} + // Equivalent implements URL func (u AbsoluteURL) Equivalent(url URL) bool { return u.Normalize().String() == url.Normalize().String()