Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
43 changes: 43 additions & 0 deletions urlx/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package urlx

import (
"fmt"
"net/url"
"strings"
)

// ParseQuery parses the URL-encoded query string and returns a Values
// listing the values specified for each key, preserving their original
// order. The returned Values contains every valid query parameter found;
// err describes the first decoding error encountered, if any.
//
// Query is expected to be a list of key=value settings separated by
// ampersands. A setting without an equals sign is interpreted as a key
// set to an empty value. Settings containing a non-URL-encoded semicolon
// are considered invalid.
func ParseQuery(query string) (Values, error) {
var v Values
for query != "" {
var key string
key, query, _ = strings.Cut(query, "&")
if strings.Contains(key, ";") {
return nil, fmt.Errorf("invalid semicolon separator in query")
}
if key == "" {
continue
}
key, value, _ := strings.Cut(key, "=")
key, err := url.QueryUnescape(key)
if err != nil {
return nil, fmt.Errorf("unescaping key %q: %w", key, err)
}
value, err = url.QueryUnescape(value)
if err != nil {
return nil, fmt.Errorf("unescaping value %q: %w", value, err)
}

v = append(v, Param{Key: key, Val: value})
}

return v, nil
}
127 changes: 127 additions & 0 deletions urlx/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package urlx_test

import (
"reflect"
"strings"
"testing"

"github.com/msales/gox/urlx"
)

func TestParseQuery(t *testing.T) {
tests := []struct {
name string
in string
want urlx.Values
wantErr string // substring match; empty means no error expected
}{
{
name: "empty query",
in: "",
want: nil,
},
{
name: "single pair",
in: "foo=bar",
want: urlx.Values{{Key: "foo", Val: "bar"}},
},
{
name: "multiple pairs preserve order",
in: "b=2&a=1&c=3",
want: urlx.Values{
{Key: "b", Val: "2"},
{Key: "a", Val: "1"},
{Key: "c", Val: "3"},
},
},
{
name: "duplicate keys kept in order",
in: "k=1&j=x&k=2",
want: urlx.Values{
{Key: "k", Val: "1"},
{Key: "j", Val: "x"},
{Key: "k", Val: "2"},
},
},
{
name: "key without equals gets empty value",
in: "foo",
want: urlx.Values{{Key: "foo", Val: ""}},
},
{
name: "empty segments are skipped",
in: "a=1&&b=2",
want: urlx.Values{
{Key: "a", Val: "1"},
{Key: "b", Val: "2"},
},
},
{
name: "decodes plus and percent escapes",
in: "a+b=c%20d",
want: urlx.Values{{Key: "a b", Val: "c d"}},
},
{
name: "semicolon in single pair returns error",
in: "a=1;b=2",
want: nil,
wantErr: "semicolon",
},
{
name: "semicolon skips bad segment but keeps valid ones",
in: "a=1&b=2;c&d=4",
want: nil,
wantErr: "semicolon",
},
{
name: "invalid escape in key",
in: "a%ZZ=1",
want: nil,
wantErr: "invalid URL escape",
},
{
name: "invalid escape in value",
in: "a=%ZZ",
want: nil,
wantErr: "invalid URL escape",
},
{
name: "first error reported but valid pairs still returned",
in: "a=%ZZ&b=2",
want: nil,
wantErr: "invalid URL escape",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := urlx.ParseQuery(tt.in)

if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
} else {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want substring %q", err, tt.wantErr)
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got = %#v, want %#v", got, tt.want)
}
})
}
}

func TestParseQuery_EncodeRoundTrip(t *testing.T) {
in := "b=2&a=1&k=1&k=2&empty="
v, err := urlx.ParseQuery(in)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got := v.Encode(); got != in {
t.Errorf("round-trip: got %q, want %q", got, in)
}
}
37 changes: 37 additions & 0 deletions urlx/values.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package urlx

import (
"net/url"
"strings"
)

// Param is a single URL query parameter.
type Param struct {
Key string
Val string
}

// Values is an ordered list of URL query parameters. Unlike net/url.Values,
// it preserves the insertion order of every key/value pair, which is useful
// when downstream systems treat query order as significant.
//
// Values is not safe for concurrent use.
type Values []Param

// Encode encodes the values into "URL encoded" form ("bar=baz&foo=quux"),
// preserving the order in which entries were added.
func (v Values) Encode() string {
if len(v) == 0 {
return ""
}
var buf strings.Builder
for i, p := range v {
if i > 0 {
buf.WriteByte('&')
}
buf.WriteString(url.QueryEscape(p.Key))
buf.WriteByte('=')
buf.WriteString(url.QueryEscape(p.Val))
}
return buf.String()
}
77 changes: 77 additions & 0 deletions urlx/values_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package urlx_test

import (
"testing"

"github.com/msales/gox/urlx"
)

func TestValues_Encode(t *testing.T) {
tests := []struct {
name string
in urlx.Values
want string
}{
{
name: "nil slice",
in: nil,
want: "",
},
{
name: "empty slice",
in: urlx.Values{},
want: "",
},
{
name: "single pair",
in: urlx.Values{{Key: "foo", Val: "bar"}},
want: "foo=bar",
},
{
name: "preserves insertion order",
in: urlx.Values{
{Key: "b", Val: "2"},
{Key: "a", Val: "1"},
{Key: "c", Val: "3"},
},
want: "b=2&a=1&c=3",
},
{
name: "duplicate keys kept in order",
in: urlx.Values{
{Key: "k", Val: "1"},
{Key: "j", Val: "x"},
{Key: "k", Val: "2"},
},
want: "k=1&j=x&k=2",
},
{
name: "escapes space in key and value",
in: urlx.Values{{Key: "a b", Val: "c d"}},
want: "a+b=c+d",
},
{
name: "escapes reserved characters",
in: urlx.Values{{Key: "&", Val: "="}},
want: "%26=%3D",
},
{
name: "empty value keeps equals sign",
in: urlx.Values{{Key: "k", Val: ""}},
want: "k=",
},
{
name: "empty key",
in: urlx.Values{{Key: "", Val: "v"}},
want: "=v",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.in.Encode()
if got != tt.want {
t.Errorf("Encode() = %q, want %q", got, tt.want)
}
})
}
}
Loading