Repository: go-resty/resty (go)

Issue:
Support query parameters without encoding
When calling the SetQueryParam method, the request request will encode the query in the URL, for example: registry=nacos://test:6801, which will be encoded as: registry=nacos%3A%2F%2Ftest%3A6801; is there any way? To set query parameters without encoding, for example: SetQueryEscape(false)

Pull request description:
feat: option to set URL query params without encoding
Closes #797 

Patch:
diff --git a/client.go b/client.go
index 87a88da..48c69f1 100644
--- a/client.go
+++ b/client.go
@@ -155,6 +155,7 @@ type Client struct {
 	panicHooks          []ErrorHook
 	rateLimiter         RateLimiter
 	generateCurlOnDebug bool
+	unescapeQueryParams bool
 }
 
 // User type is to hold an username and password information
@@ -325,6 +326,17 @@ func (c *Client) SetQueryParams(params map[string]string) *Client {
 	return c
 }
 
+// SetUnescapeQueryParams method sets the unescape query parameters choice for request URL.
+// To prevent broken URL, resty replaces space (" ") with "+" in the query parameters.
+//
+// See [Request.SetUnescapeQueryParams]
+//
+// NOTE: Request failure is possible due to non-standard usage of Unescaped Query Parameters.
+func (c *Client) SetUnescapeQueryParams(unescape bool) *Client {
+	c.unescapeQueryParams = unescape
+	return c
+}
+
 // SetFormData method sets Form parameters and their values in the client instance.
 // It applies only to HTTP methods `POST` and `PUT`, and the request content type would be set as
 // `application/x-www-form-urlencoded`. These form data will be added to all the requests raised from
@@ -446,6 +458,7 @@ func (c *Client) R() *Request {
 		log:                 c.log,
 		responseBodyLimit:   c.ResponseBodyLimit,
 		generateCurlOnDebug: c.generateCurlOnDebug,
+		unescapeQueryParams: c.unescapeQueryParams,
 	}
 	return r
 }
diff --git a/middleware.go b/middleware.go
index 4f57915..c4ed8d9 100644
--- a/middleware.go
+++ b/middleware.go
@@ -154,6 +154,15 @@ func parseRequestURL(c *Client, r *Request) error {
 		}
 	}
 
+	// GH#797 Unescape query parameters
+	if r.unescapeQueryParams && len(reqURL.RawQuery) > 0 {
+		// at this point, all errors caught up in the above operations
+		// so ignore the return error on query unescape; I realized
+		// while writing the unit test
+		unescapedQuery, _ := url.QueryUnescape(reqURL.RawQuery)
+		reqURL.RawQuery = strings.ReplaceAll(unescapedQuery, " ", "+") // otherwise request becomes bad request
+	}
+
 	r.URL = reqURL.String()
 
 	return nil
diff --git a/request.go b/request.go
index 1834071..9075ead 100644
--- a/request.go
+++ b/request.go
@@ -73,6 +73,7 @@ type Request struct {
 	retryConditions     []RetryConditionFunc
 	responseBodyLimit   int
 	generateCurlOnDebug bool
+	unescapeQueryParams bool
 }
 
 // GenerateCurlCommand method generates the CURL command for the request.
@@ -210,6 +211,17 @@ func (r *Request) SetQueryParams(params map[string]string) *Request {
 	return r
 }
 
+// SetUnescapeQueryParams method sets the unescape query parameters choice for request URL.
+// To prevent broken URL, resty replaces space (" ") with "+" in the query parameters.
+//
+// This method overrides the value set by [Client.SetUnescapeQueryParams]
+//
+// NOTE: Request failure is possible due to non-standard usage of Unescaped Query Parameters.
+func (r *Request) SetUnescapeQueryParams(unescape bool) *Request {
+	r.unescapeQueryParams = unescape
+	return r
+}
+
 // SetQueryParamsFromValues method appends multiple parameters with multi-value
 // ([url.Values]) at one go in the current request. It will be formed as
 // query string for the request.


Test patch:
diff --git a/middleware_test.go b/middleware_test.go
index 49733e9..d85514e 100644
--- a/middleware_test.go
+++ b/middleware_test.go
@@ -265,6 +265,23 @@ func Test_parseRequestURL(t *testing.T) {
 			},
 			expectedURL: "https://example.com/?foo=1&foo=2",
 		},
+		{
+			name: "unescape query params",
+			init: func(c *Client, r *Request) {
+				c.SetBaseURL("https://example.com/").
+					SetUnescapeQueryParams(true). // this line is just code coverage; I will restructure this test in v3 for the client and request the respective init method
+					SetQueryParam("fromclient", "hey unescape").
+					SetQueryParam("initone", "cáfe")
+
+				r.SetUnescapeQueryParams(true) // this line takes effect
+				r.SetQueryParams(
+					map[string]string{
+						"registry": "nacos://test:6801", // GH #797
+					},
+				)
+			},
+			expectedURL: "https://example.com?initone=cáfe&fromclient=hey+unescape&registry=nacos://test:6801",
+		},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			c := New()
@@ -292,6 +309,29 @@ func Test_parseRequestURL(t *testing.T) {
 	}
 }
 
+func TestRequestURL_GH797(t *testing.T) {
+	ts := createGetServer(t)
+	defer ts.Close()
+
+	c := dc().
+		SetBaseURL(ts.URL).
+		SetUnescapeQueryParams(true). // this line is just code coverage; I will restructure this test in v3 for the client and request the respective init method
+		SetQueryParam("fromclient", "hey unescape").
+		SetQueryParam("initone", "cáfe")
+
+	resp, err := c.R().
+		SetUnescapeQueryParams(true). // this line takes effect
+		SetQueryParams(
+			map[string]string{
+				"registry": "nacos://test:6801", // GH #797
+			},
+		).
+		Get("/unescape-query-params")
+
+	assertError(t, err)
+	assertEqual(t, "query params looks good", resp.String())
+}
+
 func Benchmark_parseRequestURL_PathParams(b *testing.B) {
 	c := New().SetPathParams(map[string]string{
 		"foo": "1",
diff --git a/resty_test.go b/resty_test.go
index e10421c..a148b0a 100644
--- a/resty_test.go
+++ b/resty_test.go
@@ -121,6 +121,14 @@ func createGetServer(t *testing.T) *httptest.Server {
 			case "/not-found-no-error":
 				w.Header().Set(hdrContentTypeKey, "application/json")
 				w.WriteHeader(http.StatusNotFound)
+			case "/unescape-query-params":
+				initOne := r.URL.Query().Get("initone")
+				fromClient := r.URL.Query().Get("fromclient")
+				registry := r.URL.Query().Get("registry")
+				assertEqual(t, "cáfe", initOne)
+				assertEqual(t, "hey unescape", fromClient)
+				assertEqual(t, "nacos://test:6801", registry)
+				_, _ = w.Write([]byte(`query params looks good`))
 			}
 
 			switch {