Repository: go-resty/resty (go)

Issue:
there is no way to use PathParams without escaping
### Discussed in https://github.com/go-resty/resty/discussions/570

<div type='discussions-op-text'>

<sup>Originally posted by **SVilgelm** August  3, 2022</sup>
Hi, as it is said in the subject. I want to use the PathParams without escaping. Is there anyway to do that?</div>

In our case the object Ids can include the `/` symbols, like `foo/bar/xyz`:
```go
resty.New().R().SetPathParam("id", "foo/bar/xyz").Get("https://example.com/{id}")
```

the composed url will be `https://example.com/foo%2Fbar%2Fxyz` instead of `https://example.com/foo/bar/xyz`


As a solution i would like to propose an additional `RawPathParams` option on client and request levels

Pull request description:
Support RawPathParams without escaping
Added `RawPathParams` options to `Client` and `Request` objects to support the path parameters with special characters, like `/`, without escaping.

Fix #663
Closes #570 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9595781..52cbefb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,11 +28,15 @@ jobs:
     steps:
       - name: Checkout
         uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
 
       - name: Setup Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go }}
+          cache: true
+          cache-dependency-path: go.sum
 
       - name: Format
         run: diff -u <(echo -n) <(go fmt $(go list ./...))
diff --git a/README.md b/README.md
index d88c426..b57b3ae 100644
--- a/README.md
+++ b/README.md
@@ -637,7 +637,7 @@ client.SetCertificates(cert1, cert2, cert3)
 
 ```go
 // Custom Root certificates from string
-// You can pass you certificates throught env variables as strings
+// You can pass you certificates through env variables as strings
 // you can add one or more root certificates, its get appended
 client.SetRootCertificateFromString("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----")
 client.SetRootCertificateFromString("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----")
@@ -702,8 +702,9 @@ client.
     })
 ```
 
-Above setup will result in resty retrying requests returned non nil error up to
-3 times with delay increased after each attempt.
+By default, resty will retry requests that return a non-nil error during execution. 
+Therefore, the above setup will result in resty retrying requests with non-nil errors up to 3 times, 
+with the delay increasing after each attempt.
 
 You can optionally provide client with [custom retry conditions](https://pkg.go.dev/github.com/go-resty/resty/v2#RetryConditionFunc):
 
@@ -720,10 +721,26 @@ client.AddRetryCondition(
 )
 ```
 
-Above example will make resty retry requests ended with `429 Too Many Requests`
-status code.
+The above example will make resty retry requests that end with a `429 Too Many Requests` status code.
+It's important to note that when you specify conditions using `AddRetryCondition`,
+it will override the default retry behavior, which retries on errors encountered during the request.
+If you want to retry on errors encountered during the request, similar to the default behavior,
+you'll need to configure it as follows:
 
-Multiple retry conditions can be added.
+```go
+// Create a Resty Client
+client := resty.New()
+
+client.AddRetryCondition(
+    func(r *resty.Response, err error) bool {
+        // Including "err != nil" emulates the default retry behavior for errors encountered during the request.
+        return err != nil || r.StatusCode() == http.StatusTooManyRequests
+    },
+)
+```
+
+Multiple retry conditions can be added. 
+Note that if multiple conditions are specified, a retry will occur if any of the conditions are met.
 
 It is also possible to use `resty.Backoff(...)` to get arbitrary retry scenarios
 implemented. [Reference](retry_test.go).
diff --git a/client.go b/client.go
index 6f53ef7..d72c5ce 100644
--- a/client.go
+++ b/client.go
@@ -103,6 +103,7 @@ type Client struct {
 	QueryParam            url.Values
 	FormData              url.Values
 	PathParams            map[string]string
+	RawPathParams         map[string]string
 	Header                http.Header
 	UserInfo              *User
 	Token                 string
@@ -441,6 +442,7 @@ func (c *Client) R() *Request {
 		multipartFiles:  []*File{},
 		multipartFields: []*MultipartField{},
 		PathParams:      map[string]string{},
+		RawPathParams:   map[string]string{},
 		jsonEscapeHTML:  true,
 		log:             c.log,
 	}
@@ -964,6 +966,7 @@ func (c *Client) SetDoNotParseResponse(parse bool) *Client {
 //	   Composed URL - /v1/users/sample@sample.com/details
 //
 // It replaces the value of the key while composing the request URL.
+// The value will be escaped using `url.PathEscape` function.
 //
 // Also it can be overridden at request level Path Params options,
 // see `Request.SetPathParam` or `Request.SetPathParams`.
@@ -976,15 +979,17 @@ func (c *Client) SetPathParam(param, value string) *Client {
 // Resty client instance.
 //
 //	client.SetPathParams(map[string]string{
-//	   "userId": "sample@sample.com",
-//	   "subAccountId": "100002",
+//		"userId":       "sample@sample.com",
+//		"subAccountId": "100002",
+//		"path":         "groups/developers",
 //	})
 //
 //	Result:
-//	   URL - /v1/users/{userId}/{subAccountId}/details
-//	   Composed URL - /v1/users/sample@sample.com/100002/details
+//	   URL - /v1/users/{userId}/{subAccountId}/{path}/details
+//	   Composed URL - /v1/users/sample@sample.com/100002/groups%2Fdevelopers/details
 //
 // It replaces the value of the key while composing the request URL.
+// The values will be escaped using `url.PathEscape` function.
 //
 // Also it can be overridden at request level Path Params options,
 // see `Request.SetPathParam` or `Request.SetPathParams`.
@@ -995,6 +1000,56 @@ func (c *Client) SetPathParams(params map[string]string) *Client {
 	return c
 }
 
+// SetRawPathParam method sets single URL path key-value pair in the
+// Resty client instance.
+//
+//	client.SetPathParam("userId", "sample@sample.com")
+//
+//	Result:
+//	   URL - /v1/users/{userId}/details
+//	   Composed URL - /v1/users/sample@sample.com/details
+//
+//	client.SetPathParam("path", "groups/developers")
+//
+//	Result:
+//	   URL - /v1/users/{userId}/details
+//	   Composed URL - /v1/users/groups%2Fdevelopers/details
+//
+// It replaces the value of the key while composing the request URL.
+// The value will be used as it is and will not be escaped.
+//
+// Also it can be overridden at request level Path Params options,
+// see `Request.SetPathParam` or `Request.SetPathParams`.
+func (c *Client) SetRawPathParam(param, value string) *Client {
+	c.RawPathParams[param] = value
+	return c
+}
+
+// SetRawPathParams method sets multiple URL path key-value pairs at one go in the
+// Resty client instance.
+//
+//	client.SetPathParams(map[string]string{
+//		"userId":       "sample@sample.com",
+//		"subAccountId": "100002",
+//		"path":         "groups/developers",
+//	})
+//
+//	Result:
+//	   URL - /v1/users/{userId}/{subAccountId}/{path}/details
+//	   Composed URL - /v1/users/sample@sample.com/100002/groups/developers/details
+//
+// It replaces the value of the key while composing the request URL.
+// The values will be used as they are and will not be escaped.
+//
+// Also it can be overridden at request level Path Params options,
+// see `Request.SetPathParam` or `Request.SetPathParams`.
+func (c *Client) SetRawPathParams(params map[string]string) *Client {
+	for p, v := range params {
+		c.SetRawPathParam(p, v)
+	}
+	return c
+}
+
 // SetJSONEscapeHTML method is to enable/disable the HTML escape on JSON marshal.
 //
 // Note: This option only applicable to standard JSON Marshaller.
@@ -1257,6 +1312,7 @@ func createClient(hc *http.Client) *Client {
 		RetryWaitTime:          defaultWaitTime,
 		RetryMaxWaitTime:       defaultMaxWaitTime,
 		PathParams:             make(map[string]string),
+		RawPathParams:          make(map[string]string),
 		JSONMarshal:            json.Marshal,
 		JSONUnmarshal:          json.Unmarshal,
 		XMLMarshal:             xml.Marshal,
diff --git a/middleware.go b/middleware.go
index 4ffb9db..3293854 100644
--- a/middleware.go
+++ b/middleware.go
@@ -38,6 +38,18 @@ func parseRequestURL(c *Client, r *Request) error {
 		}
 	}
 
+	// GitHub #663 Raw Path Params
+	if len(r.RawPathParams) > 0 {
+		for p, v := range r.RawPathParams {
+			r.URL = strings.Replace(r.URL, "{"+p+"}", v, -1)
+		}
+	}
+	if len(c.RawPathParams) > 0 {
+		for p, v := range c.RawPathParams {
+			r.URL = strings.Replace(r.URL, "{"+p+"}", v, -1)
+		}
+	}
+
 	// Parsing request URL
 	reqURL, err := url.Parse(r.URL)
 	if err != nil {
@@ -163,7 +175,9 @@ CL:
 
 func createHTTPRequest(c *Client, r *Request) (err error) {
 	if r.bodyBuf == nil {
-		if c.setContentLength || r.setContentLength {
+		if reader, ok := r.Body.(io.Reader); ok && isPayloadSupported(r.Method, c.AllowGetMethodPayload) {
+			r.RawRequest, err = http.NewRequest(r.Method, r.URL, reader)
+		} else if c.setContentLength || r.setContentLength {
 			r.RawRequest, err = http.NewRequest(r.Method, r.URL, http.NoBody)
 		} else {
 			r.RawRequest, err = http.NewRequest(r.Method, r.URL, nil)
@@ -441,9 +455,14 @@ func handleRequestBody(c *Client, r *Request) (err error) {
 	r.bodyBuf = nil
 
 	if reader, ok := r.Body.(io.Reader); ok {
-		r.bodyBuf = acquireBuffer()
-		_, err = r.bodyBuf.ReadFrom(reader)
-		r.Body = nil
+		if c.setContentLength || r.setContentLength { // keep backward compatibility
+			r.bodyBuf = acquireBuffer()
+			_, err = r.bodyBuf.ReadFrom(reader)
+			r.Body = nil
+		} else {
+			// Otherwise buffer less processing for `io.Reader`, sounds good.
+			return
+		}
 	} else if b, ok := r.Body.([]byte); ok {
 		bodyBytes = b
 	} else if s, ok := r.Body.(string); ok {
diff --git a/request.go b/request.go
index cd4e77c..1a8318a 100644
--- a/request.go
+++ b/request.go
@@ -27,22 +27,23 @@ import (
 // resty client. Request provides an options to override client level
 // settings and also an options for the request composition.
 type Request struct {
-	URL        string
-	Method     string
-	Token      string
-	AuthScheme string
-	QueryParam url.Values
-	FormData   url.Values
-	PathParams map[string]string
-	Header     http.Header
-	Time       time.Time
-	Body       interface{}
-	Result     interface{}
-	Error      interface{}
-	RawRequest *http.Request
-	SRV        *SRVRecord
-	UserInfo   *User
-	Cookies    []*http.Cookie
+	URL           string
+	Method        string
+	Token         string
+	AuthScheme    string
+	QueryParam    url.Values
+	FormData      url.Values
+	PathParams    map[string]string
+	RawPathParams map[string]string
+	Header        http.Header
+	Time          time.Time
+	Body          interface{}
+	Result        interface{}
+	Error         interface{}
+	RawRequest    *http.Request
+	SRV           *SRVRecord
+	UserInfo      *User
+	Cookies       []*http.Cookie
 
 	// Attempt is to represent the request attempt made during a Resty
 	// request execution flow, including retry count.
@@ -578,8 +579,17 @@ func (r *Request) SetDoNotParseResponse(parse bool) *Request {
 //	   URL - /v1/users/{userId}/details
 //	   Composed URL - /v1/users/sample@sample.com/details
 //
-// It replaces the value of the key while composing the request URL. Also you can
-// override Path Params value, which was set at client instance level.
+//	client.R().SetPathParam("path", "groups/developers")
+//
+//	Result:
+//	   URL - /v1/users/{userId}/details
+//	   Composed URL - /v1/users/groups%2Fdevelopers/details
+//
+// It replaces the value of the key while composing the request URL.
+// The values will be escaped using `url.PathEscape` function.
+//
+// Also you can override Path Params value, which was set at client instance
+// level.
 func (r *Request) SetPathParam(param, value string) *Request {
 	r.PathParams[param] = value
 	return r
@@ -589,16 +599,20 @@ func (r *Request) SetPathParam(param, value string) *Request {
 // Resty current request instance.
 //
 //	client.R().SetPathParams(map[string]string{
-//	   "userId": "sample@sample.com",
-//	   "subAccountId": "100002",
+//		"userId":       "sample@sample.com",
+//		"subAccountId": "100002",
+//		"path":         "groups/developers",
 //	})
 //
 //	Result:
-//	   URL - /v1/users/{userId}/{subAccountId}/details
-//	   Composed URL - /v1/users/sample@sample.com/100002/details
+//	   URL - /v1/users/{userId}/{subAccountId}/{path}/details
+//	   Composed URL - /v1/users/sample@sample.com/100002/groups%2Fdevelopers/details
 //
-// It replaces the value of the key while composing request URL. Also you can
-// override Path Params value, which was set at client instance level.
+// It replaces the value of the key while composing request URL.
+// The value will be used as it is and will not be escaped.
+//
+// Also you can override Path Params value, which was set at client instance
+// level.
 func (r *Request) SetPathParams(params map[string]string) *Request {
 	for p, v := range params {
 		r.SetPathParam(p, v)
@@ -606,6 +620,56 @@ func (r *Request) SetPathParams(params map[string]string) *Request {
 	return r
 }
 
+// SetRawPathParam method sets single URL path key-value pair in the
+// Resty current request instance.
+//
+//	client.R().SetPathParam("userId", "sample@sample.com")
+//
+//	Result:
+//	   URL - /v1/users/{userId}/details
+//	   Composed URL - /v1/users/sample@sample.com/details
+//
+//	client.R().SetPathParam("path", "groups/developers")
+//
+//	Result:
+//	   URL - /v1/users/{userId}/details
+//	   Composed URL - /v1/users/groups/developers/details
+//
+// It replaces the value of the key while composing the request URL.
+// The value will be used as it is and will not be escaped.
+//
+// Also you can override Path Params value, which was set at client instance
+// level.
+func (r *Request) SetRawPathParam(param, value string) *Request {
+	r.RawPathParams[param] = value
+	return r
+}
+
+// SetRawPathParams method sets multiple URL path key-value pairs at one go in the
+// Resty current request instance.
+//
+//	client.R().SetPathParams(map[string]string{
+//		"userId": "sample@sample.com",
+//		"subAccountId": "100002",
+//		"path":         "groups/developers",
+//	})
+//
+//	Result:
+//	   URL - /v1/users/{userId}/{subAccountId}/{path}/details
+//	   Composed URL - /v1/users/sample@sample.com/100002/groups/developers/details
+//
+// It replaces the value of the key while composing request URL.
+// The values will be used as they are and will not be escaped.
+//
+// Also you can override Path Params value, which was set at client instance
+// level.
+func (r *Request) SetRawPathParams(params map[string]string) *Request {
+	for p, v := range params {
+		r.SetRawPathParam(p, v)
+	}
+	return r
+}
+
 // ExpectContentType method allows to provide fallback `Content-Type` for automatic unmarshalling
 // when `Content-Type` response header is unavailable.
 func (r *Request) ExpectContentType(contentType string) *Request {


Test patch:
diff --git a/client_test.go b/client_test.go
index 72dfd08..966e029 100644
--- a/client_test.go
+++ b/client_test.go
@@ -543,6 +543,22 @@ func TestClientAllowsGetMethodPayload(t *testing.T) {
 	assertEqual(t, payload, resp.String())
 }
 
+func TestClientAllowsGetMethodPayloadIoReader(t *testing.T) {
+	ts := createGetServer(t)
+	defer ts.Close()
+
+	c := dc()
+	c.SetAllowGetMethodPayload(true)
+
+	payload := "test-payload"
+	body := bytes.NewReader([]byte(payload))
+	resp, err := c.R().SetBody(body).Get(ts.URL + "/get-method-payload-test")
+
+	assertError(t, err)
+	assertEqual(t, http.StatusOK, resp.StatusCode())
+	assertEqual(t, payload, resp.String())
+}
+
 func TestClientAllowsGetMethodPayloadDisabled(t *testing.T) {
 	ts := createGetServer(t)
 	defer ts.Close()
@@ -836,6 +852,7 @@ func TestClientOnResponseError(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
 			var assertErrorHook = func(r *Request, err error) {
 				assertNotNil(t, r)
 				v, ok := err.(*ResponseError)
diff --git a/request_test.go b/request_test.go
index 59809b4..2c06cae 100644
--- a/request_test.go
+++ b/request_test.go
@@ -1636,7 +1636,7 @@ func TestGetPathParamAndPathParams(t *testing.T) {
 	defer ts.Close()
 
 	c := dc().
-		SetHostURL(ts.URL).
+		SetBaseURL(ts.URL).
 		SetPathParam("userId", "sample@sample.com")
 
 	resp, err := c.R().SetPathParam("subAccountId", "100002").
@@ -1749,21 +1749,47 @@ func TestPathParamURLInput(t *testing.T) {
 	defer ts.Close()
 
 	c := dc().SetDebug(true).
-		SetHostURL(ts.URL).
+		SetBaseURL(ts.URL).
 		SetPathParams(map[string]string{
 			"userId": "sample@sample.com",
+			"path":   "users/developers",
 		})
 
 	resp, err := c.R().
 		SetPathParams(map[string]string{
 			"subAccountId": "100002",
 			"website":      "https://example.com",
-		}).Get("/v1/users/{userId}/{subAccountId}/{website}")
+		}).Get("/v1/users/{userId}/{subAccountId}/{path}/{website}")
 
 	assertError(t, err)
 	assertEqual(t, http.StatusOK, resp.StatusCode())
 	assertEqual(t, true, strings.Contains(resp.String(), "TestPathParamURLInput: text response"))
-	assertEqual(t, true, strings.Contains(resp.String(), "/v1/users/sample@sample.com/100002/https:%2F%2Fexample.com"))
+	assertEqual(t, true, strings.Contains(resp.String(), "/v1/users/sample@sample.com/100002/users%2Fdevelopers/https:%2F%2Fexample.com"))
+
+	logResponse(t, resp)
+}
+
+func TestRawPathParamURLInput(t *testing.T) {
+	ts := createGetServer(t)
+	defer ts.Close()
+
+	c := dc().SetDebug(true).
+		SetBaseURL(ts.URL).
+		SetRawPathParams(map[string]string{
+			"userId": "sample@sample.com",
+			"path":   "users/developers",
+		})
+
+	resp, err := c.R().
+		SetRawPathParams(map[string]string{
+			"subAccountId": "100002",
+			"website":      "https://example.com",
+		}).Get("/v1/users/{userId}/{subAccountId}/{path}/{website}")
+
+	assertError(t, err)
+	assertEqual(t, http.StatusOK, resp.StatusCode())
+	assertEqual(t, true, strings.Contains(resp.String(), "TestPathParamURLInput: text response"))
+	assertEqual(t, true, strings.Contains(resp.String(), "/v1/users/sample@sample.com/100002/users/developers/https://example.com"))
 
 	logResponse(t, resp)
 }