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.

Discussion:
@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) {