diff --git a/urlx/url.go b/urlx/url.go new file mode 100644 index 0000000..208a48f --- /dev/null +++ b/urlx/url.go @@ -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 +} diff --git a/urlx/url_test.go b/urlx/url_test.go new file mode 100644 index 0000000..327cee9 --- /dev/null +++ b/urlx/url_test.go @@ -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) + } +} diff --git a/urlx/values.go b/urlx/values.go new file mode 100644 index 0000000..c7c4b2a --- /dev/null +++ b/urlx/values.go @@ -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() +} diff --git a/urlx/values_test.go b/urlx/values_test.go new file mode 100644 index 0000000..bddd32c --- /dev/null +++ b/urlx/values_test.go @@ -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) + } + }) + } +}