Repository: imroc/req

Issue:
Feature Request: Add `SetQueryParamsFromValues` Methods for Query Parameter Configuration
Hello @imroc and the `req` team,

Thank you for maintaining this excellent HTTP client library for Go. It’s been incredibly useful due to its simplicity and powerful features. I would like to propose two new methods to enhance the query parameter configuration functionality in the `req` library, inspired by common use cases and existing patterns in other libraries like `resty`.

## 1. Add `SetQueryParamsFromValues` Method

### Motivation

The `resty` library provides a `SetQueryParamsFromValues` method (reference: [[resty documentation](https://resty.dev/docs/request-query-params/#query-params-from-urlvalues)](https://resty.dev/docs/request-query-params/#query-params-from-urlvalues)), which allows users to directly set query parameters using a `url.Values` map. This is a common and convenient approach, especially when working with libraries like `go-querystring` (https://github.com/google/go-querystring). The `go-querystring` library can serialize a struct into a `url.Values` map, which is often used to construct query parameters.

Currently, to achieve this in `req`, users must convert a struct to `url.Values` using `go-querystring`, decode the `url.Values` into a raw query string, and then pass it to `SetQueryString`. This process is cumbersome and error-prone. A `SetQueryParamsFromValues` method would streamline this workflow by allowing direct configuration of query parameters from a `url.Values` map.

### Proposed API

```go
// SetQueryParamsFromValues sets query parameters from a url.Values map.
func (r *Request) SetQueryParamsFromValues(params url.Values) *Request
```

### Use Case Example

```go
import (
    "github.com/google/go-querystring/query"
    "github.com/imroc/req/v3"
    "net/url"
)

type QueryParams struct {
    Name  string `url:"name"`
    Limit int    `url:"limit"`
}

func main() {
    client := req.C()
    params := QueryParams{
        Name:  "example",
        Limit: 10,
    }
    values, _ := query.Values(params) // Convert struct to url.Values using go-querystring
    client.R().SetQueryParamsFromValues(values).Get("https://example.com/api")
}
```

This would append `?name=example&limit=10` to the request URL, making the process more straightforward.

## 2. Add `SetQueryParamsFromStruct` Method

### Motivation

Building on the `SetQueryParamsFromValues` method, a `SetQueryParamsFromStruct` method would provide an even higher-level abstraction by allowing users to directly pass a struct to configure query parameters. This would internally leverage `go-querystring` to convert the struct to `url.Values` and then use `SetQueryParamsFromValues` to set the query parameters. This approach would further simplify the API for users who prefer working directly with structs.

### Proposed API

```go
// SetQueryParamsFromStruct sets query parameters from a struct using go-querystring.
func (r *Request) SetQueryParamsFromStruct(v interface{}) *Request
```

### Use Case Example

```go
import (
    "github.com/imroc/req/v3"
)

type QueryParams struct {
    Name  string `url:"name"`
    Limit int    `url:"limit"`
}

func main() {
    client := req.C()
    params := QueryParams{
        Name:  "example",
        Limit: 10,
    }
    client.R().SetQueryParamsFromStruct(params).Get("https://example.com/api")
}
```
This would produce the same result as the previous example (`?name=example&limit=10`) but with a simpler, more idiomatic API.


I’d be happy to discuss the implementation details further or even contribute a pull request if this proposal aligns with the project’s roadmap. Thank you for considering this feature request, and please let me know if you need any clarification!

Best regards.

Hints from issue comments:
@imroc: Good idea, PR is welcomed@gitctrlx: @imroc I've submitted a PR (#462) to address this feature request. Please review!

Solution patch:
diff --git a/client.go b/client.go
index bf9af8c..04c477a 100644
--- a/client.go
+++ b/client.go
@@ -24,6 +24,8 @@ import (
 	"github.com/imroc/req/v3/http2"
 	"github.com/imroc/req/v3/internal/header"
 	"github.com/imroc/req/v3/internal/util"
+
+	"github.com/google/go-querystring/query"
 )
 
 // DefaultClient returns the global default Client.
@@ -501,6 +503,31 @@ func (c *Client) SetCommonQueryString(query string) *Client {
 	return c
 }
 
+// SetCommonQueryParamsFromValues set URL query parameters from a url.Values map
+// for requests fired from the client.
+func (c *Client) SetCommonQueryParamsFromValues(params urlpkg.Values) *Client {
+	if c.QueryParams == nil {
+		c.QueryParams = make(urlpkg.Values)
+	}
+	for p, v := range params {
+		for _, pv := range v {
+			c.QueryParams.Add(p, pv)
+		}
+	}
+	return c
+}
+
+// SetCommonQueryParamsFromStruct set URL query parameters from a struct using go-querystring
+// for requests fired from the client.
+func (c *Client) SetCommonQueryParamsFromStruct(v any) *Client {
+	values, err := query.Values(v)
+	if err != nil {
+		c.log.Warnf("failed to convert struct to query parameters: %v", err)
+		return c
+	}
+	return c.SetCommonQueryParamsFromValues(values)
+}
+
 // SetCommonCookies set HTTP cookies for requests fired from the client.
 func (c *Client) SetCommonCookies(cookies ...*http.Cookie) *Client {
 	c.Cookies = append(c.Cookies, cookies...)
diff --git a/client_wrapper.go b/client_wrapper.go
index 1d6368f..74c5312 100644
--- a/client_wrapper.go
+++ b/client_wrapper.go
@@ -195,6 +195,18 @@ func SetCommonQueryString(query string) *Client {
 	return defaultClient.SetCommonQueryString(query)
 }
 
+// SetCommonQueryParamsFromValues is a global wrapper methods which delegated
+// to the default client's Client.SetCommonQueryParamsFromValues.
+func SetCommonQueryParamsFromValues(params url.Values) *Client {
+	return defaultClient.SetCommonQueryParamsFromValues(params)
+}
+
+// SetCommonQueryParamsFromStruct is a global wrapper methods which delegated
+// to the default client's Client.SetCommonQueryParamsFromStruct.
+func SetCommonQueryParamsFromStruct(v any) *Client {
+	return defaultClient.SetCommonQueryParamsFromStruct(v)
+}
+
 // SetCommonCookies is a global wrapper methods which delegated
 // to the default client's Client.SetCommonCookies.
 func SetCommonCookies(cookies ...*http.Cookie) *Client {
diff --git a/go.mod b/go.mod
index 04a76d4..effe1f9 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.24
 
 require (
 	github.com/andybalholm/brotli v1.2.0
+	github.com/google/go-querystring v1.1.0
 	github.com/icholy/digest v1.1.0
 	github.com/klauspost/compress v1.18.0
 	github.com/quic-go/qpack v0.5.1
diff --git a/go.sum b/go.sum
index b67b187..622f7c2 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,11 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ
 github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
 github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -38,6 +41,7 @@ golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
 golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
 golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
 golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
diff --git a/request.go b/request.go
index 1dc70e8..e74319d 100644
--- a/request.go
+++ b/request.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/google/go-querystring/query"
 	"github.com/imroc/req/v3/internal/dump"
 	"github.com/imroc/req/v3/internal/header"
 	"github.com/imroc/req/v3/internal/util"
@@ -231,6 +232,34 @@ func (r *Request) SetQueryString(query string) *Request {
 	return r
 }
 
+// SetQueryParamsFromValues sets query parameters from a url.Values map.
+// This method allows direct configuration of query parameters from url.Values,
+// which is commonly used with libraries like go-querystring.
+func (r *Request) SetQueryParamsFromValues(params urlpkg.Values) *Request {
+	if r.QueryParams == nil {
+		r.QueryParams = make(urlpkg.Values)
+	}
+	for p, v := range params {
+		for _, pv := range v {
+			r.QueryParams.Add(p, pv)
+		}
+	}
+	return r
+}
+
+// SetQueryParamsFromStruct sets query parameters from a struct using go-querystring.
+// This method provides a higher-level abstraction by allowing users to directly pass
+// a struct to configure query parameters. The struct should use `url` tags to specify
+// parameter names.
+func (r *Request) SetQueryParamsFromStruct(v any) *Request {
+	values, err := query.Values(v)
+	if err != nil {
+		r.client.log.Warnf("failed to convert struct to query parameters: %v", err)
+		return r
+	}
+	return r.SetQueryParamsFromValues(values)
+}
+
 // SetFileReader set up a multipart form with a reader to upload file.
 func (r *Request) SetFileReader(paramName, filename string, reader io.Reader) *Request {
 	r.SetFileUpload(FileUpload{
diff --git a/request_wrapper.go b/request_wrapper.go
index 7ac2580..035991d 100644
--- a/request_wrapper.go
+++ b/request_wrapper.go
@@ -50,6 +50,18 @@ func SetQueryString(query string) *Request {
 	return defaultClient.R().SetQueryString(query)
 }
 
+// SetQueryParamsFromValues is a global wrapper methods which delegated
+// to the default client, create a request and SetQueryParamsFromValues for request.
+func SetQueryParamsFromValues(params url.Values) *Request {
+	return defaultClient.R().SetQueryParamsFromValues(params)
+}
+
+// SetQueryParamsFromStruct is a global wrapper methods which delegated
+// to the default client, create a request and SetQueryParamsFromStruct for request.
+func SetQueryParamsFromStruct(v any) *Request {
+	return defaultClient.R().SetQueryParamsFromStruct(v)
+}
+
 // SetFileReader is a global wrapper methods which delegated
 // to the default client, create a request and SetFileReader for request.
 func SetFileReader(paramName, filePath string, reader io.Reader) *Request {
diff --git a/response.go b/response.go
index 0c17619..69aee12 100644
--- a/response.go
+++ b/response.go
@@ -73,7 +73,7 @@ func (r *Response) GetContentType() string {
 }
 
 // ResultState returns the result state.
-// By default, it returns SuccessState if HTTP status `code >= 400`, and returns
+// By default, it returns SuccessState if HTTP status `code >= 200 && code <= 299`, and returns
 // ErrorState if HTTP status `code >= 400`, otherwise returns UnknownState.
 // You can also use Client.SetResultStateCheckFunc to customize the result
 // state check logic.



Test patch:
diff --git a/.testdata/tmp_dumpfile_req b/.testdata/tmp_dumpfile_req
new file mode 100644
index 0000000..f20d04f
--- /dev/null
+++ b/.testdata/tmp_dumpfile_req
@@ -0,0 +1,14 @@
+:authority: 127.0.0.1:62895
+:method: GET
+:path: /
+:scheme: https
+accept-encoding: gzip
+user-agent: req/v3 (https://github.com/imroc/req)
+
+:status: 200
+method: GET
+content-type: text/plain; charset=utf-8
+content-length: 22
+date: Thu, 04 Sep 2025 18:21:57 GMT
+
+TestGet: text response
diff --git a/.testdata/tmp_test_dump_file b/.testdata/tmp_test_dump_file
new file mode 100644
index 0000000..9fcc4d1
--- /dev/null
+++ b/.testdata/tmp_test_dump_file
@@ -0,0 +1,18 @@
+:authority: 127.0.0.1:62895
+:method: POST
+:path: /
+:scheme: https
+content-type: text/plain; charset=utf-8
+content-length: 9
+accept-encoding: gzip
+user-agent: req/v3 (https://github.com/imroc/req)
+
+test body
+
+:status: 200
+method: POST
+content-type: text/plain; charset=utf-8
+content-length: 23
+date: Thu, 04 Sep 2025 18:21:57 GMT
+
+TestPost: text response
diff --git a/client_test.go b/client_test.go
index 456c108..8f7bc9b 100644
--- a/client_test.go
+++ b/client_test.go
@@ -331,6 +331,29 @@ func TestSetCommonQueryParams(t *testing.T) {
 	tests.AssertEqual(t, "test=test", resp.String())
 }
 
+func TestSetCommonQueryParamsFromValues(t *testing.T) {
+	values := url.Values{}
+	values.Add("test", "test")
+	values.Add("key", "value")
+	resp, err := tc().SetCommonQueryParamsFromValues(values).R().Get("/query-parameter")
+	assertSuccess(t, resp, err)
+	tests.AssertEqual(t, "key=value&test=test", resp.String())
+}
+
+func TestSetCommonQueryParamsFromStruct(t *testing.T) {
+	type QueryParams struct {
+		Test string `url:"test"`
+		Key  string `url:"key"`
+	}
+	params := QueryParams{
+		Test: "test",
+		Key:  "value",
+	}
+	resp, err := tc().SetCommonQueryParamsFromStruct(params).R().Get("/query-parameter")
+	assertSuccess(t, resp, err)
+	tests.AssertEqual(t, "key=value&test=test", resp.String())
+}
+
 func TestInsecureSkipVerify(t *testing.T) {
 	c := tc().EnableInsecureSkipVerify()
 	tests.AssertEqual(t, true, c.TLSClientConfig.InsecureSkipVerify)
diff --git a/request_test.go b/request_test.go
index 0275980..c6415ff 100644
--- a/request_test.go
+++ b/request_test.go
@@ -636,6 +636,49 @@ func testQueryParam(t *testing.T, c *Client) {
 		Get("/query-parameter")
 	assertSuccess(t, resp, err)
 	tests.AssertEqual(t, "key1=value1&key1=value11&key2=value2&key2=value22&key3=value3&key4=value4&key4=value44&key5=value5&key6=value6&key6=value66", resp.String())
+
+	// SetQueryParamsFromValues
+	values := url.Values{}
+	values.Add("key1", "value1")
+	values.Add("key2", "value2")
+	values.Add("key3", "value3")
+	resp, err = c.R().
+		SetQueryParamsFromValues(values).
+		Get("/query-parameter")
+	assertSuccess(t, resp, err)
+	tests.AssertEqual(t, "key1=value1&key2=value2&key3=value3&key4=client&key5=client&key5=extra", resp.String())
+
+	// SetQueryParamsFromStruct
+	type QueryParams struct {
+		Key1 string `url:"key1"`
+		Key2 string `url:"key2"`
+		Key3 string `url:"key3"`
+	}
+	params := QueryParams{
+		Key1: "value1",
+		Key2: "value2",
+		Key3: "value3",
+	}
+	resp, err = c.R().
+		SetQueryParamsFromStruct(params).
+		Get("/query-parameter")
+	assertSuccess(t, resp, err)
+	tests.AssertEqual(t, "key1=value1&key2=value2&key3=value3&key4=client&key5=client&key5=extra", resp.String())
+
+	// SetQueryParamsFromStruct with slice
+	type QueryParamsWithSlice struct {
+		Key1 string   `url:"key1"`
+		Tags []string `url:"tags"`
+	}
+	paramsWithSlice := QueryParamsWithSlice{
+		Key1: "value1",
+		Tags: []string{"tag1", "tag2"},
+	}
+	resp, err = c.R().
+		SetQueryParamsFromStruct(paramsWithSlice).
+		Get("/query-parameter")
+	assertSuccess(t, resp, err)
+	tests.AssertEqual(t, "key1=value1&key2=client&key3=client&key4=client&key5=client&key5=extra&tags=tag1&tags=tag2", resp.String())
 }
 
 func TestPathParam(t *testing.T) {