From 3bad71ce10fa3309481cb3c029da5fe751b85dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henry=20Sequeda=20Gonz=C3=A1lez?= Date: Mon, 20 Apr 2026 11:29:18 -0400 Subject: [PATCH 1/3] [TASK] TRK-6572 Add urlx package --- urlx/url.go | 49 ++++++++++++++++ urlx/url_test.go | 132 ++++++++++++++++++++++++++++++++++++++++++++ urlx/values.go | 37 +++++++++++++ urlx/values_test.go | 77 ++++++++++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 urlx/url.go create mode 100644 urlx/url_test.go create mode 100644 urlx/values.go create mode 100644 urlx/values_test.go diff --git a/urlx/url.go b/urlx/url.go new file mode 100644 index 0000000..1204cd2 --- /dev/null +++ b/urlx/url.go @@ -0,0 +1,49 @@ +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) (v Values, err error) { + for query != "" { + var key string + key, query, _ = strings.Cut(query, "&") + if strings.Contains(key, ";") { + err = fmt.Errorf("invalid semicolon separator in query") + continue + } + if key == "" { + continue + } + key, value, _ := strings.Cut(key, "=") + key, err1 := url.QueryUnescape(key) + if err1 != nil { + if err == nil { + err = err1 + } + continue + } + value, err1 = url.QueryUnescape(value) + if err1 != nil { + if err == nil { + err = err1 + } + continue + } + + v = append(v, Param{Key: key, Val: value}) + } + + return v, err +} diff --git a/urlx/url_test.go b/urlx/url_test.go new file mode 100644 index 0000000..d24a4ce --- /dev/null +++ b/urlx/url_test.go @@ -0,0 +1,132 @@ +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: urlx.Values{ + {Key: "a", Val: "1"}, + {Key: "d", Val: "4"}, + }, + 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: urlx.Values{ + {Key: "b", Val: "2"}, + }, + 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) + } + }) + } +} From 6aee15b1baff595d1d685efab55c9932d1f64507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henry=20Sequeda=20Gonz=C3=A1lez?= Date: Tue, 21 Apr 2026 09:17:07 -0400 Subject: [PATCH 2/3] [TASK] TRK-6572 Solve CR --- urlx/url.go | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/urlx/url.go b/urlx/url.go index 1204cd2..208a48f 100644 --- a/urlx/url.go +++ b/urlx/url.go @@ -15,35 +15,29 @@ import ( // 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) (v Values, err error) { +func ParseQuery(query string) (Values, error) { + var v Values for query != "" { var key string key, query, _ = strings.Cut(query, "&") if strings.Contains(key, ";") { - err = fmt.Errorf("invalid semicolon separator in query") - continue + return nil, fmt.Errorf("invalid semicolon separator in query") } if key == "" { continue } key, value, _ := strings.Cut(key, "=") - key, err1 := url.QueryUnescape(key) - if err1 != nil { - if err == nil { - err = err1 - } - continue + key, err := url.QueryUnescape(key) + if err != nil { + return nil, fmt.Errorf("unescaping key %q: %w", key, err) } - value, err1 = url.QueryUnescape(value) - if err1 != nil { - if err == nil { - err = err1 - } - continue + 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, err + return v, nil } From 0e56f89099fb82dcd1d22eed8fb19713088137f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henry=20Sequeda=20Gonz=C3=A1lez?= Date: Tue, 21 Apr 2026 10:48:34 -0400 Subject: [PATCH 3/3] [TASK] TRK-6572 Fix tests --- urlx/url_test.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/urlx/url_test.go b/urlx/url_test.go index d24a4ce..327cee9 100644 --- a/urlx/url_test.go +++ b/urlx/url_test.go @@ -70,10 +70,7 @@ func TestParseQuery(t *testing.T) { { name: "semicolon skips bad segment but keeps valid ones", in: "a=1&b=2;c&d=4", - want: urlx.Values{ - {Key: "a", Val: "1"}, - {Key: "d", Val: "4"}, - }, + want: nil, wantErr: "semicolon", }, { @@ -91,9 +88,7 @@ func TestParseQuery(t *testing.T) { { name: "first error reported but valid pairs still returned", in: "a=%ZZ&b=2", - want: urlx.Values{ - {Key: "b", Val: "2"}, - }, + want: nil, wantErr: "invalid URL escape", }, }