diff --git a/net/gclient/gclient.go b/net/gclient/gclient.go index 0720242aa1e..70363df761f 100644 --- a/net/gclient/gclient.go +++ b/net/gclient/gclient.go @@ -33,6 +33,7 @@ type Client struct { authPass string // HTTP basic authentication: pass. retryCount int // Retry count when request fails. noUrlEncode bool // No url encoding for request parameters. + queryParams map[string]any // Query parameters map. retryInterval time.Duration // Retry interval when request fails. middlewareHandler []HandlerFunc // Interceptor handlers discovery gsvc.Discovery // Discovery for service. @@ -83,10 +84,11 @@ func New() *Client { }).DialContext, }, }, - header: make(map[string]string), - cookies: make(map[string]string), - builder: gsel.GetBuilder(), - discovery: nil, + header: make(map[string]string), + cookies: make(map[string]string), + queryParams: make(map[string]any), + builder: gsel.GetBuilder(), + discovery: nil, } c.header[httpHeaderUserAgent] = defaultClientAgent // It enables OpenTelemetry for client in default. @@ -106,6 +108,10 @@ func (c *Client) Clone() *Client { for k, v := range c.cookies { newClient.cookies[k] = v } + newClient.queryParams = make(map[string]any, len(c.queryParams)) + for k, v := range c.queryParams { + newClient.queryParams[k] = v + } return newClient } diff --git a/net/gclient/gclient_chain.go b/net/gclient/gclient_chain.go index 7b0b6c643a2..cb7eaec377a 100644 --- a/net/gclient/gclient_chain.go +++ b/net/gclient/gclient_chain.go @@ -133,3 +133,25 @@ func (c *Client) NoUrlEncode() *Client { newClient.SetNoUrlEncode(true) return newClient } + +// Query is a chaining function, which sets query parameters with map for next request. +func (c *Client) Query(m map[string]any) *Client { + newClient := c.Clone() + newClient.SetQueryMap(m) + return newClient +} + +// QueryParams is a chaining function, which sets query parameters with struct or map object for next request. +// The `params` can be type of: string/[]byte/map/struct/*struct. +func (c *Client) QueryParams(params any) *Client { + newClient := c.Clone() + newClient.SetQueryParams(params) + return newClient +} + +// QueryPair is a chaining function, which sets a query parameter pair for next request. +func (c *Client) QueryPair(key string, value any) *Client { + newClient := c.Clone() + newClient.SetQuery(key, value) + return newClient +} diff --git a/net/gclient/gclient_config.go b/net/gclient/gclient_config.go index baa09022043..4d3a175fec4 100644 --- a/net/gclient/gclient_config.go +++ b/net/gclient/gclient_config.go @@ -24,6 +24,7 @@ import ( "github.com/gogf/gf/v2/net/gsvc" "github.com/gogf/gf/v2/text/gregex" "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" ) // SetBrowserMode enables browser mode of the client. @@ -216,3 +217,44 @@ func (c *Client) SetBuilder(builder gsel.Builder) { func (c *Client) SetDiscovery(discovery gsvc.Discovery) { c.discovery = discovery } + +// SetQuery sets a query parameter pair for the client. +func (c *Client) SetQuery(key string, value any) *Client { + if c.queryParams == nil { + c.queryParams = make(map[string]any) + } + // Filter out nil values to maintain consistency with SetQueryParams and mergeQueryParams + if value != nil { + c.queryParams[key] = value + } + return c +} + +// SetQueryMap sets query parameters with map. +func (c *Client) SetQueryMap(m map[string]any) *Client { + if c.queryParams == nil { + c.queryParams = make(map[string]any) + } + for k, v := range m { + // Filter out nil values to maintain consistency with SetQueryParams and mergeQueryParams + if v != nil { + c.queryParams[k] = v + } + } + return c +} + +// SetQueryParams sets query parameters with struct or map object. +// The `params` can be type of: string/[]byte/map/struct/*struct. +func (c *Client) SetQueryParams(params any) *Client { + if c.queryParams == nil { + c.queryParams = make(map[string]any) + } + m := gconv.Map(params) + for k, v := range m { + if v != nil { + c.queryParams[k] = v + } + } + return c +} diff --git a/net/gclient/gclient_request.go b/net/gclient/gclient_request.go index 59e22751c3e..6a564dd97c0 100644 --- a/net/gclient/gclient_request.go +++ b/net/gclient/gclient_request.go @@ -13,7 +13,9 @@ import ( "mime" "mime/multipart" "net/http" + "net/url" "os" + "reflect" "strings" "time" @@ -22,6 +24,7 @@ import ( "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/internal/httputil" + "github.com/gogf/gf/v2/internal/intlog" "github.com/gogf/gf/v2/internal/json" "github.com/gogf/gf/v2/internal/utils" "github.com/gogf/gf/v2/os/gfile" @@ -117,6 +120,16 @@ func (c *Client) PostForm(ctx context.Context, url string, data map[string]strin return c.ContentType(w.FormDataContentType()).Post(ctx, url, body) } +// GetMergedURL returns the merged URL after combining base URL, query parameters, and data parameters. +// This method allows you to inspect the final URL without actually sending the request. +func (c *Client) GetMergedURL(ctx context.Context, method, u string, data ...any) (mergedURL string, err error) { + req, err := c.prepareRequest(ctx, method, u, data...) + if err != nil { + return "", err + } + return req.URL.String(), nil +} + // DoRequest sends request with given HTTP method and data and returns the response object. // Note that the response object MUST be closed if it'll never be used. // @@ -160,185 +173,222 @@ func (c *Client) DoRequest( return resp, err } -// prepareRequest verifies request parameters, builds and returns http request. -func (c *Client) prepareRequest(ctx context.Context, method, url string, data ...any) (req *http.Request, err error) { - method = strings.ToUpper(method) - if len(c.prefix) > 0 { - url = c.prefix + gstr.Trim(url) - } - if !gstr.ContainsI(url, httpProtocolName) { - url = httpProtocolName + `://` + url +// mergeQueryParams merges URL query parameters, data parameters, and c.queryParams. +// Priority: c.queryParams > dataParams > urlParams +// This ensures consistent parameter handling and avoids multiple URL parsing operations. +func (c *Client) mergeQueryParams(u string, dataParams string) (string, error) { + parsedURL, err := url.Parse(u) + if err != nil { + return "", gerror.Wrapf(err, `url.Parse failed for URL "%s"`, u) } - var ( - params string - allowFileUploading = true - ) - if len(data) > 0 { - mediaType, _, err := mime.ParseMediaType(c.header[httpHeaderContentType]) - if err != nil { - // Fallback: use the raw header value if parsing fails. - mediaType = c.header[httpHeaderContentType] - } - switch mediaType { - case httpHeaderContentTypeJson: - switch data[0].(type) { - case string, []byte: - params = gconv.String(data[0]) - default: - if b, err := json.Marshal(data[0]); err != nil { - return nil, err - } else { - params = string(b) - } - } - allowFileUploading = false - - case httpHeaderContentTypeXml: - switch data[0].(type) { - case string, []byte: - params = gconv.String(data[0]) - default: - if b, err := gjson.New(data[0]).ToXml(); err != nil { - return nil, err - } else { - params = string(b) - } - } - allowFileUploading = false - default: - params = httputil.BuildParams(data[0], c.noUrlEncode) + // Start with existing URL parameters + queryValues := parsedURL.Query() + + // Remove empty value slices from URL parameters to prevent them from being + // encoded as "key=" which would override default values on the server side. + // This handles cases like "?age" or "?age=" + for k, v := range queryValues { + if len(v) == 0 || (len(v) == 1 && v[0] == "") { + delete(queryValues, k) } } - if method == http.MethodGet { - var bodyBuffer *bytes.Buffer - if params != "" { - mediaType, _, err := mime.ParseMediaType(c.header[httpHeaderContentType]) + + // Merge data parameters (for GET requests with default Content-Type) + if dataParams != "" { + var dataValues url.Values + if c.noUrlEncode { + // Parse params without URL decoding to avoid double encoding/decoding issues + dataValues = parseUnEncodedParams(dataParams) + } else { + dataValues, err = url.ParseQuery(dataParams) if err != nil { - // Fallback: use the raw header value if parsing fails. - mediaType = c.header[httpHeaderContentType] - } - switch mediaType { - case - httpHeaderContentTypeJson, - httpHeaderContentTypeXml: - bodyBuffer = bytes.NewBuffer([]byte(params)) - default: - // It appends the parameters to the url - // if http method is GET and Content-Type is not specified. - if gstr.Contains(url, "?") { - url = url + "&" + params - } else { - url = url + "?" + params - } - bodyBuffer = bytes.NewBuffer(nil) + return "", gerror.Wrapf(err, `url.ParseQuery failed for data params "%s"`, dataParams) } - } else { - bodyBuffer = bytes.NewBuffer(nil) } - if req, err = http.NewRequest(method, url, bodyBuffer); err != nil { - err = gerror.Wrapf(err, `http.NewRequest failed with method "%s" and URL "%s"`, method, url) - return nil, err + // Data params override URL params + for k, v := range dataValues { + queryValues[k] = v } - } else { - if allowFileUploading && strings.Contains(params, httpParamFileHolder) { - // File uploading request. - var ( - buffer = bytes.NewBuffer(nil) - writer = multipart.NewWriter(buffer) - isFileUploading = false - ) - for _, item := range strings.Split(params, "&") { - array := strings.SplitN(item, "=", 2) - if len(array) < 2 { + } + + // Merge c.queryParams (highest priority) + if len(c.queryParams) > 0 { + for k, v := range c.queryParams { + // Skip explicit nil values + if v == nil { + continue + } + + // Use reflection to handle slice/array types generically + reflectValue := reflect.Indirect(reflect.ValueOf(v)) + + // Check if the reflect value is valid (covers dereferenced nil pointers) + if !reflectValue.IsValid() { + continue + } + + // Check if it's a slice or array + if reflectValue.Kind() == reflect.Slice || reflectValue.Kind() == reflect.Array { + // Skip nil slices + if reflectValue.Kind() == reflect.Slice && reflectValue.IsNil() { continue } - if len(array[1]) > 6 && strings.Compare(array[1][0:6], httpParamFileHolder) == 0 { - path := array[1][6:] - if !gfile.Exists(path) { - return nil, gerror.NewCodef(gcode.CodeInvalidParameter, `"%s" does not exist`, path) - } - var ( - file io.Writer - formFileName = gfile.Basename(path) - formFieldName = array[0] - ) - // it sets post content type as `application/octet-stream` - if file, err = writer.CreateFormFile(formFieldName, formFileName); err != nil { - return nil, gerror.Wrapf( - err, `CreateFormFile failed with "%s", "%s"`, formFieldName, formFileName, - ) - } - var f *os.File - if f, err = gfile.Open(path); err != nil { - return nil, err - } - if _, err = io.Copy(file, f); err != nil { - _ = f.Close() - return nil, gerror.Wrapf( - err, `io.Copy failed from "%s" to form "%s"`, path, formFieldName, - ) - } - if err = f.Close(); err != nil { - return nil, gerror.Wrapf(err, `close file descriptor failed for "%s"`, path) + + if reflectValue.Len() > 0 { + // Clear existing values for this key + delete(queryValues, k) + for i := 0; i < reflectValue.Len(); i++ { + item := reflectValue.Index(i).Interface() + queryValues.Add(k, gconv.String(item)) } - isFileUploading = true } else { - var ( - fieldName = array[0] - fieldValue = array[1] - ) - // Decode URL-encoded field name and value. - // If decoding fails, use the original value. - if v, err := gurl.Decode(fieldName); err == nil { - fieldName = v - } - if v, err := gurl.Decode(fieldValue); err == nil { - fieldValue = v - } - if err = writer.WriteField(fieldName, fieldValue); err != nil { - return nil, gerror.Wrapf( - err, `write form field failed with "%s", "%s"`, fieldName, fieldValue, - ) - } + // Skip empty slices/arrays instead of adding empty value + continue } + } else { + // queryParams override previous values + queryValues.Set(k, gconv.String(v)) } - // Close finishes the multipart message and writes the trailing - // boundary end line to the output. - if err = writer.Close(); err != nil { - return nil, gerror.Wrapf(err, `form writer close failed`) - } + } + } - if req, err = http.NewRequest(method, url, buffer); err != nil { - return nil, gerror.Wrapf( - err, `http.NewRequest failed for method "%s" and URL "%s"`, method, url, - ) - } - if isFileUploading { - req.Header.Set(httpHeaderContentType, writer.FormDataContentType()) + // Update URL with merged parameters + // Respect noUrlEncode flag + if c.noUrlEncode { + parsedURL.RawQuery = buildUnEncodedQuery(queryValues) + } else { + parsedURL.RawQuery = queryValues.Encode() + } + return parsedURL.String(), nil +} + +// normalizeURL normalizes the URL by adding prefix and protocol if needed. +// It returns the normalized URL string. +func (c *Client) normalizeURL(u string) string { + if len(c.prefix) > 0 { + u = c.prefix + gstr.Trim(u) + } + if !gstr.ContainsI(u, httpProtocolName) { + u = httpProtocolName + `://` + u + } + return u +} + +// getMediaType safely parses the Content-Type header and returns the media type. +// If parsing fails, it logs the error and returns the raw header value as fallback. +func (c *Client) getMediaType(ctx context.Context) string { + contentType := c.header[httpHeaderContentType] + if contentType == "" { + return "" + } + + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + // Log the parsing error for debugging purposes + intlog.Errorf(ctx, + `mime.ParseMediaType failed for Content-Type "%s": %v, using raw value as fallback`, + contentType, err, + ) + return contentType + } + return mediaType +} + +// parseUnEncodedParams parses URL-encoded parameter string without decoding. +// This is used when noUrlEncode flag is true to avoid double encoding/decoding. +// It splits the params by '&' and '=' and returns a url.Values map. +func parseUnEncodedParams(params string) url.Values { + values := make(url.Values) + if params == "" { + return values + } + + for _, pair := range strings.Split(params, "&") { + if pair == "" { + continue + } + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + values[parts[0]] = []string{parts[1]} + } else if len(parts) == 1 { + // Handle key without value (e.g., "?flag") + values[parts[0]] = []string{""} + } + } + return values +} + +// buildUnEncodedQuery builds a query string from url.Values without encoding. +// This is used when noUrlEncode flag is true. +func buildUnEncodedQuery(values url.Values) string { + if len(values) == 0 { + return "" + } + + var queryParts []string + for k, vals := range values { + for _, v := range vals { + if v == "" { + queryParts = append(queryParts, k) + } else { + queryParts = append(queryParts, k+"="+v) } - } else { - // Normal request. - paramBytes := []byte(params) - if req, err = http.NewRequest(method, url, bytes.NewReader(paramBytes)); err != nil { - err = gerror.Wrapf(err, `http.NewRequest failed for method "%s" and URL "%s"`, method, url) - return nil, err + } + } + return strings.Join(queryParts, "&") +} + +// buildRequestParams converts data to request parameters based on Content-Type. +// It returns: +// - params: serialized parameter string +// - allowFileUploading: whether file uploading is allowed for this request +// - err: error if serialization fails +func (c *Client) buildRequestParams(ctx context.Context, data ...any) (params string, allowFileUploading bool, err error) { + allowFileUploading = true + if len(data) == 0 { + return "", allowFileUploading, nil + } + + mediaType := c.getMediaType(ctx) + + switch mediaType { + case httpHeaderContentTypeJson: + switch data[0].(type) { + case string, []byte: + params = gconv.String(data[0]) + default: + if b, err := json.Marshal(data[0]); err != nil { + return "", false, err + } else { + params = string(b) } - if v, ok := c.header[httpHeaderContentType]; ok { - // Custom Content-Type. - req.Header.Set(httpHeaderContentType, v) - } else if len(paramBytes) > 0 { - if (paramBytes[0] == '[' || paramBytes[0] == '{') && json.Valid(paramBytes) { - // Auto-detecting and setting the post content format: JSON. - req.Header.Set(httpHeaderContentType, httpHeaderContentTypeJson) - } else if gregex.IsMatchString(httpRegexParamJson, params) { - // If the parameters passed like "name=value", it then uses form type. - req.Header.Set(httpHeaderContentType, httpHeaderContentTypeForm) - } + } + allowFileUploading = false + + case httpHeaderContentTypeXml: + switch data[0].(type) { + case string, []byte: + params = gconv.String(data[0]) + default: + if b, err := gjson.New(data[0]).ToXml(); err != nil { + return "", false, err + } else { + params = string(b) } } + allowFileUploading = false + + default: + params = httputil.BuildParams(data[0], c.noUrlEncode) } + return params, allowFileUploading, nil +} + +// enhanceRequest enhances the request with context, headers, cookies and authentication. +// It modifies the request in-place and returns the enhanced request. +func (c *Client) enhanceRequest(req *http.Request, ctx context.Context) *http.Request { // Context. if ctx != nil { req = req.WithContext(ctx) @@ -349,7 +399,7 @@ func (c *Client) prepareRequest(ctx context.Context, method, url string, data .. req.Header.Set(k, v) } } - // It's necessary set the req.Host if you want to custom the host value of the request. + // It's necessary to set the req.Host if you want to customize the host value of the request. // It uses the "Host" value from header if it's not empty. if reqHeaderHost := req.Header.Get(httpHeaderHost); reqHeaderHost != "" { req.Host = reqHeaderHost @@ -371,6 +421,196 @@ func (c *Client) prepareRequest(ctx context.Context, method, url string, data .. if len(c.authUser) > 0 { req.SetBasicAuth(c.authUser, c.authPass) } + return req +} + +// createNormalPostRequest creates http.Request with normal body for POST/PUT/DELETE. +// It auto-detects Content-Type based on body content if not specified. +func (c *Client) createNormalPostRequest(method, u string, params string) (*http.Request, error) { + // Normal request. + paramBytes := []byte(params) + req, err := http.NewRequest(method, u, bytes.NewReader(paramBytes)) + if err != nil { + return nil, gerror.Wrapf(err, `http.NewRequest failed for method "%s" and URL "%s"`, method, u) + } + + if v, ok := c.header[httpHeaderContentType]; ok { + // Custom Content-Type. + req.Header.Set(httpHeaderContentType, v) + } else if len(paramBytes) > 0 { + if (paramBytes[0] == '[' || paramBytes[0] == '{') && json.Valid(paramBytes) { + // Auto-detecting and setting the post content format: JSON. + req.Header.Set(httpHeaderContentType, httpHeaderContentTypeJson) + } else if gregex.IsMatchString(httpRegexParamJson, params) { + // If the parameters passed like "name=value", it then uses form type. + req.Header.Set(httpHeaderContentType, httpHeaderContentTypeForm) + } + } + + return req, nil +} + +// createMultipartRequest creates http.Request with multipart form data for file uploading. +// It processes @file: markers and creates multipart form with files and fields. +func (c *Client) createMultipartRequest(method, u string, params string) (*http.Request, error) { + // File uploading request. + var ( + buffer = bytes.NewBuffer(nil) + writer = multipart.NewWriter(buffer) + isFileUploading = false + ) + for _, item := range strings.Split(params, "&") { + array := strings.SplitN(item, "=", 2) + if len(array) < 2 { + continue + } + if len(array[1]) > 6 && strings.Compare(array[1][0:6], httpParamFileHolder) == 0 { + path := array[1][6:] + if !gfile.Exists(path) { + return nil, gerror.NewCodef(gcode.CodeInvalidParameter, `"%s" does not exist`, path) + } + var ( + formFileName = gfile.Basename(path) + formFieldName = array[0] + ) + // It sets post content type as `application/octet-stream` + if file, err := writer.CreateFormFile(formFieldName, formFileName); err != nil { + return nil, gerror.Wrapf( + err, `CreateFormFile failed with "%s", "%s"`, formFieldName, formFileName, + ) + } else { + var f *os.File + if f, err = gfile.Open(path); err != nil { + return nil, err + } + if _, err = io.Copy(file, f); err != nil { + _ = f.Close() + return nil, gerror.Wrapf( + err, `io.Copy failed from "%s" to form "%s"`, path, formFieldName, + ) + } + if err = f.Close(); err != nil { + return nil, gerror.Wrapf(err, `close file descriptor failed for "%s"`, path) + } + isFileUploading = true + } + } else { + var ( + fieldName = array[0] + fieldValue = array[1] + ) + // Decode URL-encoded field name and value. + // If decoding fails, use the original value. + if v, err := gurl.Decode(fieldName); err == nil { + fieldName = v + } + if v, err := gurl.Decode(fieldValue); err == nil { + fieldValue = v + } + if err := writer.WriteField(fieldName, fieldValue); err != nil { + return nil, gerror.Wrapf( + err, `write form field failed with "%s", "%s"`, fieldName, fieldValue, + ) + } + } + } + // Close finishes the multipart message and writes the trailing + // boundary end line to the output. + if err := writer.Close(); err != nil { + return nil, gerror.Wrapf(err, `form writer close failed`) + } + + req, err := http.NewRequest(method, u, buffer) + if err != nil { + return nil, gerror.Wrapf( + err, `http.NewRequest failed for method "%s" and URL "%s"`, method, u, + ) + } + if isFileUploading { + req.Header.Set(httpHeaderContentType, writer.FormDataContentType()) + } + + return req, nil +} + +// createGetRequest creates http.Request for GET method. +// It merges query parameters and handles different Content-Types. +func (c *Client) createGetRequest(ctx context.Context, u string, params string) (*http.Request, error) { + var bodyBuffer *bytes.Buffer + if params != "" { + mediaType := c.getMediaType(ctx) + switch mediaType { + case + httpHeaderContentTypeJson, + httpHeaderContentTypeXml: + bodyBuffer = bytes.NewBuffer([]byte(params)) + default: + // Merge all query parameters before creating http.Request + // This includes: URL params + data params + c.queryParams + var err error + if u, err = c.mergeQueryParams(u, params); err != nil { + return nil, err + } + bodyBuffer = bytes.NewBuffer(nil) + } + } else { + // Only merge URL params and c.queryParams + var err error + if u, err = c.mergeQueryParams(u, ""); err != nil { + return nil, err + } + bodyBuffer = bytes.NewBuffer(nil) + } + req, err := http.NewRequest(http.MethodGet, u, bodyBuffer) + if err != nil { + return nil, gerror.Wrapf(err, `http.NewRequest failed with method "GET" and URL "%s"`, u) + } + return req, nil +} + +// createPostRequest creates http.Request for POST/PUT/DELETE methods. +// It dispatches to multipart or normal request handler based on params content. +func (c *Client) createPostRequest(method, u string, params string, allowFileUploading bool) (*http.Request, error) { + // POST/PUT/DELETE etc: merge c.queryParams into URL + if len(c.queryParams) > 0 { + var err error + if u, err = c.mergeQueryParams(u, ""); err != nil { + return nil, err + } + } + + if allowFileUploading && strings.Contains(params, httpParamFileHolder) { + return c.createMultipartRequest(method, u, params) + } + return c.createNormalPostRequest(method, u, params) +} + +// prepareRequest verifies request parameters, builds and returns http request. +func (c *Client) prepareRequest(ctx context.Context, method, u string, data ...any) (req *http.Request, err error) { + method = strings.ToUpper(method) + + // 1. Normalize URL + u = c.normalizeURL(u) + + // 2. Build request parameters + params, allowFileUploading, err := c.buildRequestParams(ctx, data...) + if err != nil { + return nil, err + } + + // 3. Create request based on method + if method == http.MethodGet { + req, err = c.createGetRequest(ctx, u, params) + } else { + req, err = c.createPostRequest(method, u, params, allowFileUploading) + } + if err != nil { + return nil, err + } + + // 4. Enhance request with context, headers, cookies, auth + req = c.enhanceRequest(req, ctx) + return req, nil } diff --git a/net/gclient/gclient_request_obj.go b/net/gclient/gclient_request_obj.go index 9f6d006a72f..38bde0c8a58 100644 --- a/net/gclient/gclient_request_obj.go +++ b/net/gclient/gclient_request_obj.go @@ -13,6 +13,8 @@ import ( "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/net/goai" + "github.com/gogf/gf/v2/os/gstructs" "github.com/gogf/gf/v2/text/gregex" "github.com/gogf/gf/v2/text/gstr" "github.com/gogf/gf/v2/util/gconv" @@ -24,27 +26,54 @@ import ( // DoRequestObj does HTTP request using standard request/response object. // The request object `req` is defined like: // -// type UseCreateReq struct { -// g.Meta `path:"/user" method:"put"` -// // other fields.... +// type UserCreateReq struct { +// g.Meta `path:"/user/{id}" method:"post" mime:"application/json"` +// Id int `in:"path"` // Path parameter +// Token string `in:"header"` // Header parameter +// Page int `in:"query"` // Query parameter +// Session string `in:"cookie"` // Cookie parameter +// Name string `json:"name"` // Body parameter (default) +// Age int `json:"age"` // Body parameter (default) // } // // The response object `res` should be a pointer type. It automatically converts result -// to given object `res` is success. +// to given object `res` if success. // -// Example: -// var ( +// Supported g.Meta tags: +// - "path": Request path (required) +// - "method": HTTP method (required) +// - "mime": Content-Type header (optional, e.g., "application/json") // -// req = UseCreateReq{} -// res *UseCreateRes +// Supported `in` tag values: +// - "path": URL path parameters (e.g., /user/{id}) +// - "query": URL query parameters (e.g., ?page=1) +// - "header": HTTP request headers +// - "cookie": HTTP cookies +// - (empty): Request body (default) // -// ) +// Example: // -// err := DoRequestObj(ctx, req, &res) +// var ( +// req = &UserCreateReq{ +// Id: 123, +// Token: "Bearer xxx", +// Page: 1, +// Session: "session-id", +// Name: "John", +// Age: 25, +// } +// res *UserCreateRes +// ) +// err := client.DoRequestObj(ctx, req, &res) +// // Actual request: POST /user/123?page=1 +// // Headers: Token: Bearer xxx, Content-Type: application/json +// // Cookies: Session=session-id +// // Body: {"name":"John","age":25} func (c *Client) DoRequestObj(ctx context.Context, req, res any) error { var ( - method = gmeta.Get(req, gtag.Method).String() - path = gmeta.Get(req, gtag.Path).String() + method = gmeta.Get(req, gtag.Method).String() + path = gmeta.Get(req, gtag.Path).String() + contentType = gmeta.Get(req, gtag.Mime).String() ) if method == "" { return gerror.NewCodef( @@ -60,7 +89,48 @@ func (c *Client) DoRequestObj(ctx context.Context, req, res any) error { gtag.Path, reflect.TypeOf(req).String(), ) } - path = c.handlePathForObjRequest(path, req) + + // Classify request parameters by `in` tag + params, err := c.classifyRequestParams(req) + if err != nil { + return err + } + + // Backward compatibility: if path has placeholders but no path params were classified, + // try to extract from all fields (for requests without `in` tags) + if gstr.Contains(path, "{") && len(params.path) == 0 { + allParamsMap := gconv.Map(req) + path = c.handlePathForObjRequest(path, allParamsMap) + } else { + // Replace path parameters + path = c.handlePathForObjRequest(path, params.path) + } + + // Build client with parameters + client := c + if len(params.query) > 0 { + client = client.SetQueryMap(params.query) + } + if len(params.header) > 0 { + client = client.SetHeaderMap(params.header) + } + if len(params.cookie) > 0 { + for k, v := range params.cookie { + client = client.SetCookie(k, v) + } + } + // Set Content-Type from mime tag if specified + if contentType != "" { + client = client.ContentType(contentType) + } + + // Prepare body data + var data any + if len(params.body) > 0 { + data = params.body + } + + // Send request switch gstr.ToUpper(method) { case http.MethodGet, @@ -72,7 +142,7 @@ func (c *Client) DoRequestObj(ctx context.Context, req, res any) error { http.MethodConnect, http.MethodOptions, http.MethodTrace: - if result := c.RequestVar(ctx, method, path, req); res != nil && !result.IsEmpty() { + if result := client.RequestVar(ctx, method, path, data); res != nil && !result.IsEmpty() { return result.Scan(res) } return nil @@ -82,16 +152,15 @@ func (c *Client) DoRequestObj(ctx context.Context, req, res any) error { } } -// handlePathForObjRequest replaces parameters in `path` with parameters from request object. +// handlePathForObjRequest replaces parameters in `path` with parameters from pathParams map. // Eg: // /order/{id} -> /order/1 -// /user/{name} -> /order/john -func (c *Client) handlePathForObjRequest(path string, req any) string { +// /user/{name} -> /user/john +func (c *Client) handlePathForObjRequest(path string, pathParams map[string]any) string { if gstr.Contains(path, "{") { - requestParamsMap := gconv.Map(req) - if len(requestParamsMap) > 0 { + if len(pathParams) > 0 { path, _ = gregex.ReplaceStringFuncMatch(`\{(\w+)\}`, path, func(match []string) string { - foundKey, foundValue := gutil.MapPossibleItemByKey(requestParamsMap, match[1]) + foundKey, foundValue := gutil.MapPossibleItemByKey(pathParams, match[1]) if foundKey != "" { return gconv.String(foundValue) } @@ -101,3 +170,106 @@ func (c *Client) handlePathForObjRequest(path string, req any) string { } return path } + +// requestParams holds classified request parameters by location +type requestParams struct { + path map[string]any + query map[string]any + header map[string]string + cookie map[string]string + body map[string]any +} + +// classifyRequestParams classifies request parameters by `in` tag. +// It returns parameters categorized into path, query, header, cookie, and body. +// +// Supported `in` tag values: +// - "path": URL path parameters (primitive types only) +// - "query": URL query parameters (supports primitive types and slice/array) +// - "header": HTTP request headers (string values only) +// - "cookie": HTTP cookies (string values only) +// - (empty): Request body parameters (default, supports all types) +// +// Type restrictions: +// - Struct and Map types are NOT supported for path/query/header/cookie parameters +// - Only primitive types, slices, and arrays are allowed for query parameters +// - Struct fields without `in` tag will be placed in the request body +// +// Embedded struct handling: +// - Anonymous embedded structs without tags: fields are flattened into body +// - Named struct fields: kept as nested structure in body +func (c *Client) classifyRequestParams(req any) (*requestParams, error) { + params := &requestParams{ + path: make(map[string]any), + query: make(map[string]any), + header: make(map[string]string), + cookie: make(map[string]string), + body: make(map[string]any), + } + + // Process direct fields first, then handle embedded structs for body parameters + fields, err := gstructs.Fields(gstructs.FieldsInput{ + Pointer: req, + RecursiveOption: gstructs.RecursiveOptionEmbeddedNoTag, + }) + if err != nil { + return nil, err + } + + for _, field := range fields { + // Skip Meta field and unexported fields + if field.Name() == "Meta" || !field.IsExported() { + continue + } + + fieldValue := field.Value.Interface() + fieldName := field.TagPriorityName() + inTag := field.TagIn() + + // Get reflect value for type checking + reflectValue := reflect.Indirect(field.Value) + + // Handle named struct fields (non-embedded) + if !field.IsEmbedded() && reflectValue.IsValid() && reflectValue.Kind() == reflect.Struct { + // Struct fields with `in` tag are not supported + if inTag != "" { + return nil, gerror.Newf( + `field "%s" with in:"%s" cannot be a struct type`, + fieldName, inTag, + ) + } + // Struct field without `in` tag goes to body + params.body[fieldName] = fieldValue + continue + } + + // Handle regular fields (including flattened embedded fields) + switch inTag { + case goai.ParameterInPath: + params.path[fieldName] = fieldValue + + case goai.ParameterInQuery: + // Map type is not supported for query parameters + if reflectValue.IsValid() && reflectValue.Kind() == reflect.Map { + return nil, gerror.Newf( + `field "%s" with in:"query" cannot be a map type, please use struct fields instead`, + fieldName, + ) + } + // Slice/array/primitive types are handled by SetQueryMap + params.query[fieldName] = fieldValue + + case goai.ParameterInHeader: + params.header[fieldName] = gconv.String(fieldValue) + + case goai.ParameterInCookie: + params.cookie[fieldName] = gconv.String(fieldValue) + + default: + // No `in` tag, goes to body + params.body[fieldName] = fieldValue + } + } + + return params, nil +} diff --git a/net/gclient/gclient_request_obj_struct_test.go b/net/gclient/gclient_request_obj_struct_test.go new file mode 100644 index 00000000000..268e517cfb6 --- /dev/null +++ b/net/gclient/gclient_request_obj_struct_test.go @@ -0,0 +1,168 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_DoRequestObj_EmbeddedStruct_Flattened tests anonymous embedded struct fields flattened to body +func Test_DoRequestObj_EmbeddedStruct_Flattened(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/user", func(r *ghttp.Request) { + // Verify query parameter + queryPage := r.URL.Query().Get("page") + + // Verify body parameters (should be flattened) + bodyMap := r.GetBodyMap() + bodyAge := gconv.Int(bodyMap["age"]) + bodyEmail := gconv.String(bodyMap["email"]) + bodyName := gconv.String(bodyMap["name"]) + + r.Response.Writef("query_page=%s,body_age=%d,body_email=%s,body_name=%s", + queryPage, bodyAge, bodyEmail, bodyName) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type UserInfo struct { + Age int `json:"age"` + Email string `json:"email"` + } + + type Req struct { + g.Meta `path:"/user" method:"post"` + Page int `in:"query" json:"page"` + UserInfo // Anonymous embedded, should flatten to body + Name string `json:"name"` // Direct field, should go to body + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + Page: 1, + UserInfo: UserInfo{Age: 25, Email: "test@example.com"}, + Name: "John", + }, &res) + + t.AssertNil(err) + // Verify: page in query, age/email/name flattened in body + t.Assert(res, "query_page=1,body_age=25,body_email=test@example.com,body_name=John") + }) +} + +// Test_DoRequestObj_NamedStruct_Nested tests named struct field kept as nested in body +func Test_DoRequestObj_NamedStruct_Nested(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/user", func(r *ghttp.Request) { + // Verify query parameter + queryPage := r.URL.Query().Get("page") + + // Get form data (gclient sends map as form data by default) + userJsonStr := r.GetForm("user").String() + bodyName := r.GetForm("name").String() + + // Parse user JSON string + var userMap map[string]interface{} + if userJsonStr != "" { + gconv.Struct(userJsonStr, &userMap) + } + + bodyAge := gconv.Int(userMap["age"]) + bodyEmail := gconv.String(userMap["email"]) + + r.Response.Writef("query_page=%s,body_user_age=%d,body_user_email=%s,body_name=%s", + queryPage, bodyAge, bodyEmail, bodyName) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type UserInfo struct { + Age int `json:"age"` + Email string `json:"email"` + } + + type Req struct { + g.Meta `path:"/user" method:"post"` + Page int `in:"query" json:"page"` + User UserInfo `json:"user"` // Named struct field, should keep nested + Name string `json:"name"` // Direct field, should go to body + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + Page: 1, + User: UserInfo{Age: 25, Email: "test@example.com"}, + Name: "John", + }, &res) + + t.AssertNil(err) + // Verify: page in query, user nested in body (as JSON string in form data), name in body + t.Assert(res, "query_page=1,body_user_age=25,body_user_email=test@example.com,body_name=John") + }) +} + +// Test_DoRequestObj_MimeTag_JSON tests mime tag for JSON content type +func Test_DoRequestObj_MimeTag_JSON(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/user", func(r *ghttp.Request) { + // Verify Content-Type header + contentType := r.Header.Get("Content-Type") + + // Verify body is JSON + bodyMap := r.GetBodyMap() + bodyName := gconv.String(bodyMap["name"]) + bodyAge := gconv.Int(bodyMap["age"]) + + r.Response.Writef("content_type=%s,name=%s,age=%d", contentType, bodyName, bodyAge) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type Req struct { + g.Meta `path:"/user" method:"post" mime:"application/json"` + Name string `json:"name"` + Age int `json:"age"` + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + Name: "John", + Age: 25, + }, &res) + + t.AssertNil(err) + // Verify Content-Type is set to application/json + t.Assert(gstr.Contains(res, "content_type=application/json"), true) + t.Assert(gstr.Contains(res, "name=John"), true) + t.Assert(gstr.Contains(res, "age=25"), true) + }) +} diff --git a/net/gclient/gclient_z_unit_get_url_test.go b/net/gclient/gclient_z_unit_get_url_test.go new file mode 100644 index 00000000000..c8d95e85f5c --- /dev/null +++ b/net/gclient/gclient_z_unit_get_url_test.go @@ -0,0 +1,61 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_Client_GetMergedURL tests the GetMergedURL method with different HTTP methods +func Test_Client_GetMergedURL(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/test", func(r *ghttp.Request) { + r.Response.Write("OK") + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test GetMergedURL with GET method - parameters are added to URL as query parameters + url, err := c.GetMergedURL(context.Background(), http.MethodGet, "/test", g.Map{ + "page": 1, + "size": 10, + }) + t.AssertNil(err) + t.Assert(strings.Contains(url, "http://127.0.0.1:"), true) + t.Assert(strings.Contains(url, "/test"), true) + t.Assert(strings.Contains(url, "page=1"), true) + t.Assert(strings.Contains(url, "size=10"), true) + + // Test GetMergedURL with POST method - parameters typically go in request body, not URL + url, err = c.GetMergedURL(context.Background(), http.MethodPost, "/test", g.Map{ + "action": "create", + "name": "test", + }) + t.AssertNil(err) + t.Assert(strings.Contains(url, "http://127.0.0.1:"), true) + t.Assert(strings.Contains(url, "/test"), true) + t.Assert(!strings.Contains(url, "action"), true) + t.Assert(!strings.Contains(url, "name"), true) + }) +} diff --git a/net/gclient/gclient_z_unit_query_nil_test.go b/net/gclient/gclient_z_unit_query_nil_test.go new file mode 100644 index 00000000000..921f9f24ae7 --- /dev/null +++ b/net/gclient/gclient_z_unit_query_nil_test.go @@ -0,0 +1,430 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_Client_Query_NilValue tests nil value handling in query parameters +func Test_Client_Query_NilValue(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + // Return all query parameters as string + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 1: Explicit nil value should be skipped + resp := c.Query(g.Map{ + "key": nil, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "key="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 2: Nil pointer should be skipped + var nilPtr *string + resp := c.Query(g.Map{ + "value": nilPtr, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "value="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 3: Nil slice should be skipped + var nilSlice []string + resp := c.Query(g.Map{ + "tags": nilSlice, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "tags="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 4: Empty slice should be skipped + emptySlice := []string{} + resp := c.Query(g.Map{ + "items": emptySlice, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "items="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 5: Empty array should be skipped + emptyArray := [0]string{} + resp := c.Query(g.Map{ + "arr": emptyArray, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "arr="), false) + }) +} + +// Test_Client_Query_MixedNilValues tests mixed nil and valid values +func Test_Client_Query_MixedNilValues(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + var nilSlice []string + var nilPtr *int + emptySlice := []int{} + + resp := c.Query(g.Map{ + "tags": nilSlice, // nil slice - should be skipped + "ids": emptySlice, // empty slice - should be skipped + "name": "test", // normal value - should appear + "ptr": nilPtr, // nil pointer - should be skipped + "active": true, // bool value - should appear + "count": 0, // zero value - should appear + }).GetContent(context.Background(), "/query") + + // Valid values should appear + t.Assert(strings.Contains(resp, "name="), true) + t.Assert(strings.Contains(resp, "active="), true) + t.Assert(strings.Contains(resp, "count="), true) + + // Nil and empty values should be skipped + t.Assert(strings.Contains(resp, "tags="), false) + t.Assert(strings.Contains(resp, "ids="), false) + t.Assert(strings.Contains(resp, "ptr="), false) + }) +} + +// Test_Client_Query_PointerToSlice tests pointer to slice/array handling +func Test_Client_Query_PointerToSlice(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 1: Pointer to valid slice + slice := []string{"go", "goframe"} + resp := c.Query(g.Map{ + "tags": &slice, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "tags="), true) + t.Assert(strings.Contains(resp, "go"), true) + t.Assert(strings.Contains(resp, "goframe"), true) + t.Assert(strings.Contains(resp, "page="), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 2: Pointer to nil slice + var nilSlice []string + resp := c.Query(g.Map{ + "tags": &nilSlice, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "tags="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 3: Pointer to array + arr := [3]int{1, 2, 3} + resp := c.Query(g.Map{ + "values": &arr, + "page": 1, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "values="), true) + t.Assert(strings.Contains(resp, "1"), true) + t.Assert(strings.Contains(resp, "2"), true) + t.Assert(strings.Contains(resp, "3"), true) + t.Assert(strings.Contains(resp, "page="), true) + }) +} + +// Test_Client_Query_NilComparison tests different nil scenarios +func Test_Client_Query_NilComparison(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + count := 0 + for range r.URL.Query() { + count++ + } + r.Response.Write(fmt.Sprintf("params_count=%d", count)) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 1: All nil values - no parameters should appear + var nilSlice []string + var nilPtr *string + resp := c.Query(g.Map{ + "key1": nil, + "key2": nilPtr, + "key3": nilSlice, + }).GetContent(context.Background(), "/query") + + t.Assert(resp, "params_count=0") + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 2: Mix of nil and valid values + var nilSlice []string + resp := c.Query(g.Map{ + "key1": nil, + "key2": "value", + "key3": nilSlice, + "key4": 123, + }).GetContent(context.Background(), "/query") + + // Only 2 valid parameters should appear + t.Assert(resp, "params_count=2") + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 3: Empty values vs nil values + emptySlice := []string{} + emptyString := "" + resp := c.Query(g.Map{ + "nil": nil, + "emptySlice": emptySlice, + "emptyString": emptyString, + "zero": 0, + }).GetContent(context.Background(), "/query") + + // empty string and zero value should appear, nil and empty slice should not + t.Assert(resp, "params_count=2") + }) +} + +// Test_Client_QueryPair_NilValue tests QueryPair method with nil values +func Test_Client_QueryPair_NilValue(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test QueryPair with nil value + resp := c.QueryPair("key", nil). + QueryPair("page", 1). + GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "key="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test QueryPair with nil pointer + var nilPtr *string + resp := c.QueryPair("value", nilPtr). + QueryPair("name", "test"). + GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "name="), true) + t.Assert(strings.Contains(resp, "value="), false) + }) +} + +// Test_Client_SetQuery_NilValue tests SetQuery method with nil values +func Test_Client_SetQuery_NilValue(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQuery with nil value + c.SetQuery("key", nil).SetQuery("page", 1) + resp := c.GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page="), true) + t.Assert(strings.Contains(resp, "key="), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQueryMap with mixed nil values + var nilSlice []string + c.SetQueryMap(g.Map{ + "tags": nilSlice, + "name": "test", + "val": nil, + "id": 100, + }) + resp := c.GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "name="), true) + t.Assert(strings.Contains(resp, "id="), true) + t.Assert(strings.Contains(resp, "tags="), false) + t.Assert(strings.Contains(resp, "val="), false) + }) +} + +// Test_Client_Query_DifferentNilTypes tests different types of nil values +func Test_Client_Query_DifferentNilTypes(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + count := 0 + for range r.URL.Query() { + count++ + } + r.Response.Write(fmt.Sprintf("count=%d", count)) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test various nil types + var nilStringSlice []string + var nilIntSlice []int + var nilBoolSlice []bool + var nilAnySlice []any + var nilStringPtr *string + var nilIntPtr *int + var nilBoolPtr *bool + + resp := c.Query(g.Map{ + "nilStringSlice": nilStringSlice, + "nilIntSlice": nilIntSlice, + "nilBoolSlice": nilBoolSlice, + "nilAnySlice": nilAnySlice, + "nilStringPtr": nilStringPtr, + "nilIntPtr": nilIntPtr, + "nilBoolPtr": nilBoolPtr, + "explicitNil": nil, + }).GetContent(context.Background(), "/query") + + // All nil values should be skipped + t.Assert(resp, "count=0") + }) +} diff --git a/net/gclient/gclient_z_unit_query_params_other_methods_test.go b/net/gclient/gclient_z_unit_query_params_other_methods_test.go new file mode 100644 index 00000000000..b82d1f194d7 --- /dev/null +++ b/net/gclient/gclient_z_unit_query_params_other_methods_test.go @@ -0,0 +1,557 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_Client_Query_Params_OtherMethods tests query parameters functionality with all HTTP methods +func Test_Client_Query_Params_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + // Test basic query parameters with different HTTP methods + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST with query parameters + resp := c.Query(g.Map{ + "name": "golang", + "year": 2023, + }).PostContent(context.Background(), "/query") + t.Assert(strings.Contains(resp, "name=[golang]"), true) + t.Assert(strings.Contains(resp, "year=[2023]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT with query parameters + resp := c.Query(g.Map{ + "title": "update", + "flag": true, + }).PutContent(context.Background(), "/query") + t.Assert(strings.Contains(resp, "title=[update]"), true) + t.Assert(strings.Contains(resp, "flag=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test DELETE with query parameters + resp := c.Query(g.Map{ + "id": 123, + }).DeleteContent(context.Background(), "/query") + t.Assert(strings.Contains(resp, "id=[123]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PATCH with query parameters + resp := c.Query(g.Map{ + "status": "updated", + "value": 456, + }).PatchContent(context.Background(), "/query") + t.Assert(strings.Contains(resp, "status=[updated]"), true) + t.Assert(strings.Contains(resp, "value=[456]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test HEAD with query parameters + resp, err := c.Query(g.Map{ + "method": "head", + }).Head(context.Background(), "/query") + t.AssertNil(err) + // HEAD responses don't have body content, but the request should be processed + t.Assert(resp.StatusCode, 200) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test OPTIONS with query parameters + resp := c.Query(g.Map{ + "option": "test", + }).OptionsContent(context.Background(), "/query") + t.Assert(strings.Contains(resp, "option=[test]"), true) + }) +} + +// Test_Client_Query_Params_SliceArray_OtherMethods tests slice/array query parameters with other HTTP methods +func Test_Client_Query_Params_SliceArray_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST with slice parameters + resp := c.Query(g.Map{ + "tags": []string{"go", "programming"}, + }).PostContent(context.Background(), "/query") + fmt.Println(resp) + + // For slice parameters in query, they may be formatted as [go programming] + t.Assert(strings.Contains(resp, "tags=["), true) + t.Assert(strings.Contains(resp, "go"), true) + t.Assert(strings.Contains(resp, "programming"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT with int slice parameters + resp := c.Query(g.Map{ + "ids": []int{1, 2, 3}, + }).PutContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "ids=["), true) + t.Assert(strings.Contains(resp, "1"), true) + t.Assert(strings.Contains(resp, "2"), true) + t.Assert(strings.Contains(resp, "3"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test DELETE with array parameters + resp := c.Query(g.Map{ + "values": [3]int{10, 20, 30}, + }).DeleteContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "values=["), true) + t.Assert(strings.Contains(resp, "10"), true) + t.Assert(strings.Contains(resp, "20"), true) + t.Assert(strings.Contains(resp, "30"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PATCH with mixed slice parameters + resp := c.Query(g.Map{ + "data": []any{"text", 123, true}, + }).PatchContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "data=["), true) + t.Assert(strings.Contains(resp, "text"), true) + t.Assert(strings.Contains(resp, "123"), true) + t.Assert(strings.Contains(resp, "true"), true) + }) +} + +// Test_Client_Query_Params_Struct_OtherMethods tests struct query parameters with other HTTP methods +func Test_Client_Query_Params_Struct_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test struct with json tags using POST + type UserQuery struct { + Page int `json:"page"` + Size int `json:"size"` + Sort string `json:"sort"` + Keyword string `json:"keyword"` + Featured bool `json:"featured"` + } + + params := UserQuery{ + Page: 1, + Size: 20, + Sort: "created_at", + Keyword: "golang", + Featured: true, + } + + resp := c.QueryParams(params).PostContent(context.Background(), "/query") + fmt.Println(resp) + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[20]"), true) + t.Assert(strings.Contains(resp, "sort=[created_at]"), true) + t.Assert(strings.Contains(resp, "keyword=[golang]"), true) + t.Assert(strings.Contains(resp, "featured=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test struct pointer using PUT + type SearchParams struct { + Query string `json:"q"` + Limit int `json:"limit"` + } + + params := &SearchParams{ + Query: "test", + Limit: 10, + } + + resp := c.QueryParams(params).PutContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "q=[test]"), true) + t.Assert(strings.Contains(resp, "limit=[10]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test struct with DELETE + type FilterParams struct { + Category string `json:"category"` + Status string `json:"status"` + } + + params := FilterParams{ + Category: "tech", + Status: "active", + } + + resp := c.QueryParams(params).DeleteContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "category=[tech]"), true) + t.Assert(strings.Contains(resp, "status=[active]"), true) + }) +} + +// Test_Client_Query_Params_URLMerge_OtherMethods tests URL parameter merging with other HTTP methods +func Test_Client_Query_Params_URLMerge_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST: API parameters should override URL parameters with same key + resp := c.Query(g.Map{ + "page": 2, + }).PostContent(context.Background(), "/query?page=1&size=10") + + // Check that page=2 overrides page=1, and size=10 is preserved + t.Assert(strings.Contains(resp, "page=[2]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(!strings.Contains(resp, "page=[1]"), true) // page=1 should not appear + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT: Different keys should be merged + resp := c.Query(g.Map{ + "sort": "updated_at", + }).PutContent(context.Background(), "/query?page=1&size=10") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "sort=[updated_at]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test DELETE: Multiple URL values should be replaced by single API value + resp := c.Query(g.Map{ + "tag": "go", + }).DeleteContent(context.Background(), "/query?tag=java&tag=python&category=tech") + + t.Assert(strings.Contains(resp, "tag=[go]"), true) + t.Assert(strings.Contains(resp, "category=[tech]"), true) + t.Assert(!strings.Contains(resp, "java"), true) + t.Assert(!strings.Contains(resp, "python"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PATCH: Slice parameters with URL parameters + resp := c.Query(g.Map{ + "tags": []string{"go", "goframe"}, + }).PatchContent(context.Background(), "/query?page=1&size=10") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "tags=["), true) + }) +} + +// Test_Client_Query_Pair_Chain_OtherMethods tests chaining of query methods with other HTTP methods +func Test_Client_Query_Pair_Chain_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST with QueryPair chaining + resp := c.QueryPair("status", "published"). + QueryPair("page", 1). + QueryPair("featured", true). + PostContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "status=[published]"), true) + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "featured=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT mixing Query and QueryPair + resp := c.Query(g.Map{ + "page": 1, + "size": 10, + }).QueryPair("sort", "created_at").PutContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "sort=[created_at]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test DELETE with chaining creates new instances + client1 := c.QueryPair("key1", "value1") + client2 := client1.QueryPair("key2", "value2") + + resp1 := client1.DeleteContent(context.Background(), "/query") + resp2 := client2.DeleteContent(context.Background(), "/query") + + // client1 should only have key1 + t.Assert(strings.Contains(resp1, "key1=["), true) + t.Assert(!strings.Contains(resp1, "key2=["), true) + + // client2 should have both + t.Assert(strings.Contains(resp2, "key1=["), true) + t.Assert(strings.Contains(resp2, "key2=["), true) + }) +} + +// Test_Client_SetQuery_Methods_OtherMethods tests SetQuery, SetQueryMap, and SetQueryParams with other HTTP methods +func Test_Client_SetQuery_Methods_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQuery with POST + c.SetQuery("key1", "value1").SetQuery("key2", 123) + resp := c.PostContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "key1=[value1]"), true) + t.Assert(strings.Contains(resp, "key2=[123]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQueryMap with PUT + c.SetQueryMap(g.Map{ + "name": "test", + "count": 5, + "active": true, + }) + resp := c.PutContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "name=[test]"), true) + t.Assert(strings.Contains(resp, "count=[5]"), true) + t.Assert(strings.Contains(resp, "active=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQueryParams with struct using DELETE + type Params struct { + Page int `json:"page"` + Name string `json:"name"` + } + + c.SetQueryParams(Params{Page: 1, Name: "test"}) + resp := c.DeleteContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "name=[test]"), true) + }) +} + +// Test_Client_Query_WithOtherConfigs_OtherMethods tests query parameters with other client configurations and other methods +func Test_Client_Query_WithOtherConfigs_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + + // Test query parameters with headers using POST + resp := c.SetHeader("X-Custom-Header", "test-value"). + SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + Query(g.Map{ + "page": 1, + "size": 10, + }).PostContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + + // Test query parameters with headers using PUT + resp := c.SetHeader("Authorization", "Bearer token123"). + SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + Query(g.Map{ + "sort": "created_at", + }).PutContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "sort=[created_at]"), true) + }) +} + +// Test_Client_Query_NilValues_OtherMethods tests nil value handling with other HTTP methods +func Test_Client_Query_NilValues_OtherMethods(t *testing.T) { + s := createOtherMethodQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST: Explicit nil value should be skipped + resp := c.Query(g.Map{ + "key": nil, + "page": 1, + }).PostContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(!strings.Contains(resp, "key=["), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT: Nil pointer should be skipped + var nilPtr *string + resp := c.Query(g.Map{ + "value": nilPtr, + "page": 1, + }).PutContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(!strings.Contains(resp, "value=["), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test DELETE: Nil slice should be skipped + var nilSlice []string + resp := c.Query(g.Map{ + "tags": nilSlice, + "page": 1, + }).DeleteContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(!strings.Contains(resp, "tags=["), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PATCH: Empty slice should be skipped + emptySlice := []string{} + resp := c.Query(g.Map{ + "items": emptySlice, + "page": 1, + }).PatchContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=["), true) + t.Assert(!strings.Contains(resp, "items=["), true) + }) +} + +// createOtherMethodQueryParamsServer creates a server for testing other HTTP methods with query parameters +func createOtherMethodQueryParamsServer() *ghttp.Server { + s := g.Server(guid.S()) + s.BindHandler("POST:/query", queryHandler) + s.BindHandler("PUT:/query", queryHandler) + s.BindHandler("DELETE:/query", queryHandler) + s.BindHandler("PATCH:/query", queryHandler) + s.BindHandler("HEAD:/query", queryHandler) + s.BindHandler("OPTIONS:/query", queryHandler) + s.SetDumpRouterMap(false) + s.Start() + return s +} + +// queryHandler is a common handler to extract and display query parameters +func queryHandler(r *ghttp.Request) { + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) +} diff --git a/net/gclient/gclient_z_unit_query_params_test.go b/net/gclient/gclient_z_unit_query_params_test.go new file mode 100644 index 00000000000..c6a36bf3419 --- /dev/null +++ b/net/gclient/gclient_z_unit_query_params_test.go @@ -0,0 +1,677 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "net/url" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_Client_Query_BasicTypes tests basic data types in query parameters +func Test_Client_Query_BasicTypes(t *testing.T) { + s := createQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test string type + resp := c.Query(g.Map{ + "name": "golang", + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "name=[golang]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test integer types + resp := c.Query(g.Map{ + "int": 123, + "int64": int64(456), + "int32": int32(789), + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "int=[123]"), true) + t.Assert(strings.Contains(resp, "int64=[456]"), true) + t.Assert(strings.Contains(resp, "int32=[789]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test boolean type + resp := c.Query(g.Map{ + "active": true, + "disabled": false, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "active=[true]"), true) + t.Assert(strings.Contains(resp, "disabled=[false]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test float types + resp := c.Query(g.Map{ + "price": 3.14, + "rating": float32(4.5), + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "price="), true) + t.Assert(strings.Contains(resp, "rating="), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test zero values + resp := c.Query(g.Map{ + "zero_int": 0, + "zero_string": "", + "zero_bool": false, + }).GetContent(context.Background(), "/query") + + // Zero values should still be added to URL + t.Assert(strings.Contains(resp, "zero_int=[0]"), true) + t.Assert(strings.Contains(resp, "zero_string=[]"), true) + t.Assert(strings.Contains(resp, "zero_bool=[false]"), true) + }) +} + +// Test_Client_Query_Struct tests struct parameter conversion +func Test_Client_Query_Struct(t *testing.T) { + s := createQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test struct with json tags + type UserQuery struct { + Page int `json:"page"` + Size int `json:"size"` + Sort string `json:"sort"` + Keyword string `json:"keyword"` + Featured bool `json:"featured"` + } + + params := UserQuery{ + Page: 1, + Size: 20, + Sort: "created_at", + Keyword: "golang", + Featured: true, + } + + resp := c.QueryParams(params).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[20]"), true) + t.Assert(strings.Contains(resp, "sort=[created_at]"), true) + t.Assert(strings.Contains(resp, "keyword=[golang]"), true) + t.Assert(strings.Contains(resp, "featured=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test struct pointer + type SearchParams struct { + Query string `json:"q"` + Limit int `json:"limit"` + } + + params := &SearchParams{ + Query: "test", + Limit: 10, + } + + resp := c.QueryParams(params).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "q=[test]"), true) + t.Assert(strings.Contains(resp, "limit=[10]"), true) + }) +} + +// Test_Client_Query_SliceArray tests slice and array types +func Test_Client_Query_SliceArray(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + // Return the raw query string to check multiple values + r.Response.Write(r.URL.RawQuery) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test string slice - should generate multiple parameters + resp := c.Query(g.Map{ + "tags": []string{"go", "programming", "web"}, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "tags=go"), true) + t.Assert(strings.Contains(resp, "tags=programming"), true) + t.Assert(strings.Contains(resp, "tags=web"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test int slice + resp := c.Query(g.Map{ + "ids": []int{1, 2, 3, 4}, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "ids=1"), true) + t.Assert(strings.Contains(resp, "ids=2"), true) + t.Assert(strings.Contains(resp, "ids=3"), true) + t.Assert(strings.Contains(resp, "ids=4"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test array type + resp := c.Query(g.Map{ + "fixed": [3]int{10, 20, 30}, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "fixed=10"), true) + t.Assert(strings.Contains(resp, "fixed=20"), true) + t.Assert(strings.Contains(resp, "fixed=30"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test mixed slice types + resp := c.Query(g.Map{ + "data": []any{"text", 123, true}, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "data=text"), true) + t.Assert(strings.Contains(resp, "data=123"), true) + t.Assert(strings.Contains(resp, "data=true"), true) + }) +} + +// Test_Client_Query_URLMerge tests URL parameter merging +func Test_Client_Query_URLMerge(t *testing.T) { + s := createQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 1: API parameters should override URL parameters with same key + resp := c.Query(g.Map{ + "page": 2, + }).GetContent(context.Background(), "/query?page=1&size=10") + + t.Assert(strings.Contains(resp, "page=[2]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "page=[1]"), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 2: Different keys should be merged + resp := c.Query(g.Map{ + "sort": "created_at", + }).GetContent(context.Background(), "/query?page=1&size=10") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "sort=[created_at]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 3: Multiple URL values should be replaced by single API value + resp := c.Query(g.Map{ + "tag": "go", + }).GetContent(context.Background(), "/query?tag=java&tag=python&category=tech") + + t.Assert(strings.Contains(resp, "tag=[go]"), true) + t.Assert(strings.Contains(resp, "category=[tech]"), true) + t.Assert(strings.Contains(resp, "java"), false) + t.Assert(strings.Contains(resp, "python"), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 4: Slice parameters with URL parameters + urlWithParams := "/query?page=1&size=10" + resp := c.Query(g.Map{ + "tags": []string{"go", "goframe"}, + }).GetContent(context.Background(), urlWithParams) + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "tags="), true) + }) +} + +// Test_Client_Query_ChainCalls tests chaining of query methods +func Test_Client_Query_ChainCalls(t *testing.T) { + s := createQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test QueryPair chaining + resp := c.QueryPair("status", "published"). + QueryPair("page", 1). + QueryPair("featured", true). + GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "status=[published]"), true) + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "featured=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test mixing Query and QueryPair + resp := c.Query(g.Map{ + "page": 1, + "size": 10, + }).QueryPair("sort", "created_at").GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "sort=[created_at]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test that chaining creates new instances + client1 := c.QueryPair("key1", "value1") + client2 := client1.QueryPair("key2", "value2") + + resp1 := client1.GetContent(context.Background(), "/query") + resp2 := client2.GetContent(context.Background(), "/query") + + // client1 should only have key1 + t.Assert(strings.Contains(resp1, "key1=[value1]"), true) + t.Assert(strings.Contains(resp1, "key2="), false) + + // client2 should have both + t.Assert(strings.Contains(resp2, "key1=[value1]"), true) + t.Assert(strings.Contains(resp2, "key2=[value2]"), true) + }) +} + +// Test_Client_Query_SpecialCharacters tests URL encoding of special characters +func Test_Client_Query_SpecialCharacters(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + // Return decoded values + query := r.URL.Query() + for k, v := range query { + r.Response.Writef("%s=%s;", k, v[0]) + } + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test special characters that need encoding + resp := c.Query(g.Map{ + "query": "hello world", + "email": "test@example.com", + "path": "/data/file.txt", + }).GetContent(context.Background(), "/query") + + // Server should receive decoded values + t.Assert(strings.Contains(resp, "query=hello world"), true) + t.Assert(strings.Contains(resp, "email=test@example.com"), true) + t.Assert(strings.Contains(resp, "path=/data/file.txt"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test Chinese characters + resp := c.Query(g.Map{ + "name": "张三", + "city": "北京", + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "name=张三"), true) + t.Assert(strings.Contains(resp, "city=北京"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test symbols + resp := c.Query(g.Map{ + "symbols": "!@#$%^&*()", + }).GetContent(context.Background(), "/query") + + // Should be properly encoded and decoded + t.Assert(strings.Contains(resp, "symbols="), true) + }) +} + +// Test_Client_SetQuery_Methods tests SetQuery, SetQueryMap, and SetQueryParams +func Test_Client_SetQuery_Methods(t *testing.T) { + s := createQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQuery + c.SetQuery("key1", "value1").SetQuery("key2", 123) + resp := c.GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "key1=[value1]"), true) + t.Assert(strings.Contains(resp, "key2=[123]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQueryMap + c.SetQueryMap(g.Map{ + "name": "test", + "count": 5, + "active": true, + }) + resp := c.GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "name=[test]"), true) + t.Assert(strings.Contains(resp, "count=[5]"), true) + t.Assert(strings.Contains(resp, "active=[true]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test SetQueryParams with struct + type Params struct { + Page int `json:"page"` + Name string `json:"name"` + } + + c.SetQueryParams(Params{Page: 1, Name: "test"}) + resp := c.GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "name=[test]"), true) + }) +} + +// Test_Client_Query_WithOtherConfigs tests query parameters with other client configurations +func Test_Client_Query_WithOtherConfigs(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + // Check both headers and query params + auth := r.Header.Get("Authorization") + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Writef("auth=%s;params=%s", auth, strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test query parameters with headers + resp := c.SetHeader("Authorization", "Bearer token123"). + Query(g.Map{ + "page": 1, + "size": 10, + }).GetContent(context.Background(), "/query") + + t.Assert(strings.Contains(resp, "auth=Bearer token123"), true) + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + }) +} + +// Test_Client_Query_URLParsing tests URL parsing correctness +func Test_Client_Query_URLParsing(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/api/users", func(r *ghttp.Request) { + r.Response.Write("ok") + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + + // Test with full URL + fullURL := fmt.Sprintf("http://127.0.0.1:%d/api/users", s.GetListenedPort()) + resp := c.Query(g.Map{"id": 1}).GetContent(context.Background(), fullURL) + t.Assert(resp, "ok") + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test with path only + resp := c.Query(g.Map{"id": 1}).GetContent(context.Background(), "/api/users") + t.Assert(resp, "ok") + }) +} + +// Test_Client_Query_RawQueryString tests that the generated URL is correct +func Test_Client_Query_RawQueryString(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + r.Response.Write(r.URL.RawQuery) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test query string format + resp := c.Query(g.Map{ + "page": 1, + "size": 10, + }).GetContent(context.Background(), "/query") + + // Parse and validate + values, err := url.ParseQuery(resp) + t.AssertNil(err) + t.Assert(values.Get("page"), "1") + t.Assert(values.Get("size"), "10") + }) +} + +// Test_Client_Query_InteractionWithGetDataParams tests the interaction between query parameters +// set via Query/QueryParams/QueryPair methods and data parameters passed to Get method +func Test_Client_Query_InteractionWithGetDataParams(t *testing.T) { + s := createQueryParamsServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 1: Query method with data parameters passed to Get method + resp := c.Query(g.Map{"key1": "value1"}).GetContent(context.Background(), "/query", g.Map{"key2": "value2"}) + + // Both parameters should be present + t.Assert(strings.Contains(resp, "key1=[value1]"), true) + t.Assert(strings.Contains(resp, "key2=[value2]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 2: QueryParams method with data parameters passed to Get method + type QueryParams struct { + Page int `json:"page"` + Name string `json:"name"` + } + + params := QueryParams{Page: 1, Name: "test"} + + resp := c.QueryParams(params).GetContent(context.Background(), "/query", g.Map{"key3": "value3"}) + + // Both sets of parameters should be present + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "name=[test]"), true) + t.Assert(strings.Contains(resp, "key3=[value3]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 3: QueryPair method with data parameters passed to Get method + resp := c.QueryPair("pair_key", "pair_value"). + GetContent(context.Background(), "/query", g.Map{"data_key": "data_value"}) + + // Both parameters should be present + t.Assert(strings.Contains(resp, "pair_key=[pair_value]"), true) + t.Assert(strings.Contains(resp, "data_key=[data_value]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 4: Conflict resolution - query params should override data params (higher priority) + resp := c.Query(g.Map{"conflict": "from_query"}). + GetContent(context.Background(), "/query", g.Map{"conflict": "from_data"}) + + // Query parameter should override data parameter (queryParams have higher priority) + t.Assert(strings.Contains(resp, "conflict=[from_query]"), true) + t.Assert(strings.Contains(resp, "conflict=[from_data]"), false) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 5: Mixed scenario with URL parameters, query params, and data params + resp := c.Query(g.Map{"query_param": "query_val"}). + GetContent(context.Background(), "/query?url_param=url_val", g.Map{"data_param": "data_val"}) + + // All three types should be present + t.Assert(strings.Contains(resp, "url_param=[url_val]"), true) + t.Assert(strings.Contains(resp, "query_param=[query_val]"), true) + t.Assert(strings.Contains(resp, "data_param=[data_val]"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test 6: Slice/array parameters with data params + resp := c.Query(g.Map{"slice_query": []string{"a", "b"}}). + GetContent(context.Background(), "/query", g.Map{"slice_data": []int{1, 2}}) + + // Based on debug, query slices become [a b] format, data slices become [[[1,2]]] format + t.Assert(strings.Contains(resp, "slice_query=[a b]"), true) + // Data slice gets JSON encoded differently + t.Assert(strings.Contains(resp, "slice_data="), true) // Just check that it exists + }) +} + +// createQueryParamsServer creates a simple server for testing query parameters +func createQueryParamsServer() *ghttp.Server { + s := g.Server(guid.S()) + s.BindHandler("/query", func(r *ghttp.Request) { + params := make([]string, 0) + for k, v := range r.URL.Query() { + params = append(params, fmt.Sprintf("%s=%v", k, v)) + } + r.Response.Write(strings.Join(params, "&")) + }) + s.SetDumpRouterMap(false) + s.Start() + return s +} diff --git a/net/gclient/gclient_z_unit_query_params_with_body_test.go b/net/gclient/gclient_z_unit_query_params_with_body_test.go new file mode 100644 index 00000000000..47ada2b25a6 --- /dev/null +++ b/net/gclient/gclient_z_unit_query_params_with_body_test.go @@ -0,0 +1,352 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_Client_Query_Params_With_Body tests query parameters functionality combined with request body +func Test_Client_Query_Params_With_Body(t *testing.T) { + s := g.Server(guid.S()) + // Bind handlers for methods that typically use request bodies + s.BindHandler("POST:/query", queryBodyAndQueryHandler) + s.BindHandler("PUT:/query", queryBodyAndQueryHandler) + s.BindHandler("PATCH:/query", queryBodyAndQueryHandler) + s.BindHandler("DELETE:/query", queryBodyAndQueryHandler) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + // Test POST with query parameters and body data + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST with query parameters and body data + resp := c.Query(g.Map{ + "name": "golang", + "year": 2023, + }).PostContent(context.Background(), "/query", g.Map{ + "body_field": "body_value", + "body_num": 123, + }) + + // Response should contain both query parameters and body data + t.Assert(strings.Contains(resp, "name=[golang]"), true) + t.Assert(strings.Contains(resp, "year=[2023]"), true) + t.Assert(strings.Contains(resp, "body_field=body_value"), true) + t.Assert(strings.Contains(resp, "body_num=123"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT with query parameters and body data + resp := c.Query(g.Map{ + "title": "update", + "flag": true, + }).PutContent(context.Background(), "/query", g.Map{ + "update_field": "update_value", + }) + + t.Assert(strings.Contains(resp, "title=[update]"), true) + t.Assert(strings.Contains(resp, "flag=[true]"), true) + t.Assert(strings.Contains(resp, "update_field=update_value"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PATCH with query parameters and body data + resp := c.Query(g.Map{ + "status": "partial_update", + }).PatchContent(context.Background(), "/query", g.Map{ + "patch_field": "patch_value", + }) + + t.Assert(strings.Contains(resp, "status=[partial_update]"), true) + t.Assert(strings.Contains(resp, "patch_field=patch_value"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test DELETE with query parameters and body data + resp := c.Query(g.Map{ + "id": 456, + }).DeleteContent(context.Background(), "/query", g.Map{ + "reason": "deletion_reason", + }) + + t.Assert(strings.Contains(resp, "id=[456]"), true) + t.Assert(strings.Contains(resp, "reason=deletion_reason"), true) + }) +} + +// Test_Client_Query_Params_With_Body_Struct tests query parameters with struct body +func Test_Client_Query_Params_With_Body_Struct(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("POST:/query", queryBodyAndQueryHandler) + s.BindHandler("PUT:/query", queryBodyAndQueryHandler) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Define struct for body data + type BodyData struct { + ID int `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + } + + body := BodyData{ + ID: 100, + Name: "test_name", + Value: "test_value", + } + + // Test POST with query parameters and struct body + resp := c.Query(g.Map{ + "action": "create", + "page": 1, + }).PostContent(context.Background(), "/query", body) + + t.Assert(strings.Contains(resp, "action=[create]"), true) + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "id=100"), true) + t.Assert(strings.Contains(resp, "name=test_name"), true) + t.Assert(strings.Contains(resp, "value=test_value"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Define struct for body data + type UpdateData struct { + Status string `json:"status"` + Fields []string `json:"fields"` + } + + body := UpdateData{ + Status: "active", + Fields: []string{"field1", "field2"}, + } + + // Test PUT with query parameters and struct body + resp := c.Query(g.Map{ + "type": "update", + }).PutContent(context.Background(), "/query", body) + + t.Assert(strings.Contains(resp, "type=[update]"), true) + t.Assert(strings.Contains(resp, "status=active"), true) + t.Assert(strings.Contains(resp, "fields=[\"field1\",\"field2\"]"), true) + }) +} + +// Test_Client_Query_Params_With_Body_JSON tests query parameters with JSON body +func Test_Client_Query_Params_With_Body_JSON(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("POST:/query", queryBodyAndQueryHandler) + s.BindHandler("PUT:/query", queryBodyAndQueryHandler) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST with query parameters and JSON string body + jsonBody := `{"user_id": 123, "action": "login", "metadata": {"source": "mobile"}}` + + resp := c.Query(g.Map{ + "session": "abc123", + "locale": "en_US", + }).PostContent(context.Background(), "/query", jsonBody) + + t.Assert(strings.Contains(resp, "session=[abc123]"), true) + t.Assert(strings.Contains(resp, "locale=[en_US]"), true) + t.Assert(strings.Contains(resp, "user_id=123"), true) + t.Assert(strings.Contains(resp, "action=login"), true) + t.Assert(strings.Contains(resp, "metadata={\"source\":\"mobile\"}"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT with query parameters and JSON body using map + jsonMap := g.Map{ + "settings": g.Map{ + "theme": "dark", + "lang": "zh-CN", + }, + "profile": g.Map{ + "public": true, + "avatar": "avatar.jpg", + }, + } + + resp := c.Query(g.Map{ + "user": "johndoe", + "role": "admin", + }).PutContent(context.Background(), "/query", jsonMap) + + t.Assert(strings.Contains(resp, "user=[johndoe]"), true) + t.Assert(strings.Contains(resp, "role=[admin]"), true) + t.Assert(strings.Contains(resp, "settings={\"lang\":\"zh-CN\",\"theme\":\"dark\"}"), true) + t.Assert(strings.Contains(resp, "profile={\"avatar\":\"avatar.jpg\",\"public\":true}"), true) + }) +} + +// Test_Client_Query_Params_With_Body_Form tests query parameters with form body +func Test_Client_Query_Params_With_Body_Form(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("POST:/query", queryBodyAndQueryHandler) + s.BindHandler("PUT:/query", queryBodyAndQueryHandler) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST with query parameters and form data body + formData := g.Map{ + "username": "testuser", + "password": "secret", + "remember": "true", + } + + resp := c.Query(g.Map{ + "redirect": "/dashboard", + "token": "xyz789", + }).PostContent(context.Background(), "/query", formData) + + t.Assert(strings.Contains(resp, "redirect=[/dashboard]"), true) + t.Assert(strings.Contains(resp, "token=[xyz789]"), true) + + t.Assert(strings.Contains(resp, "remember=true"), true) + t.Assert(strings.Contains(resp, "password=secret"), true) + t.Assert(strings.Contains(resp, "username=testuser"), true) + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT with query parameters and form data body + formData := g.Map{ + "email": "test@example.com", + "verified": "false", + } + + resp := c.Query(g.Map{ + "action": "verify", + "method": "email", + }).PutContent(context.Background(), "/query", formData) + + t.Assert(strings.Contains(resp, "action=[verify]"), true) + t.Assert(strings.Contains(resp, "method=[email]"), true) + t.Assert(strings.Contains(resp, "email=test@example.com"), true) + t.Assert(strings.Contains(resp, "verified=false"), true) + }) +} + +// Test_Client_Query_Params_With_Body_URLMerge tests URL parameter merging with body data +func Test_Client_Query_Params_With_Body_URLMerge(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("POST:/query", queryBodyAndQueryHandler) + s.BindHandler("PUT:/query", queryBodyAndQueryHandler) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test POST: Query parameters should merge with URL parameters and body data + resp := c.Query(g.Map{ + "page": 2, // This should override URL parameter page=1 + }).PostContent(context.Background(), "/query?page=1&size=10", g.Map{ + "body_param": "body_value", + }) + + // Query parameters should override URL parameters + t.Assert(strings.Contains(resp, "page=[2]"), true) // From query, not URL + t.Assert(!strings.Contains(resp, "page=[1]"), true) // URL param should be overridden + t.Assert(strings.Contains(resp, "size=[10]"), true) // From URL + t.Assert(strings.Contains(resp, "body_param=body_value"), true) // From body + }) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test PUT: Different keys should be merged from all sources + resp := c.Query(g.Map{ + "sort": "updated_at", + }).PutContent(context.Background(), "/query?page=1&filter=active", g.Map{ + "action": "update", + }) + + t.Assert(strings.Contains(resp, "page=[1]"), true) // From URL + t.Assert(strings.Contains(resp, "filter=[active]"), true) // From URL + t.Assert(strings.Contains(resp, "sort=[updated_at]"), true) // From query + t.Assert(strings.Contains(resp, "action=update"), true) // From body + }) +} + +// queryBodyHandler is a common handler to extract and display both query parameters and body data +func queryBodyAndQueryHandler(r *ghttp.Request) { + var result []string + + // Add query parameters + for k, v := range r.URL.Query() { + result = append(result, fmt.Sprintf("%s=%v", k, v)) + } + + // Add body data + bodyData := r.GetBodyMap() + for k, v := range bodyData { + result = append(result, fmt.Sprintf("%s=%s", k, gconv.String(v))) + + } + + r.Response.Write(strings.Join(result, "&")) +} diff --git a/net/gclient/gclient_z_unit_request_obj_in_tag_test.go b/net/gclient/gclient_z_unit_request_obj_in_tag_test.go new file mode 100644 index 00000000000..51a275ad1ee --- /dev/null +++ b/net/gclient/gclient_z_unit_request_obj_in_tag_test.go @@ -0,0 +1,393 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_DoRequestObj_InTag_Mixed tests mixed parameter types with in tag +func Test_DoRequestObj_InTag_Mixed(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/user/{id}", func(r *ghttp.Request) { + // Verify path parameter + pathId := r.Get("id").String() + + // Verify query parameter + queryPage := r.URL.Query().Get("page") + + // Verify header parameter + headerToken := r.Header.Get("Authorization") + + // Verify cookie parameter + cookieSession := r.Cookie.Get("session") + + // Verify body parameters + bodyMap := r.GetBodyMap() + bodyName := gconv.String(bodyMap["name"]) + bodyAge := gconv.Int(bodyMap["age"]) + + // Return verification result + r.Response.Writef("path_id=%s,query_page=%s,header_token=%s,cookie_session=%s,body_name=%s,body_age=%d", + pathId, queryPage, headerToken, cookieSession, bodyName, bodyAge) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type Req struct { + g.Meta `path:"/user/{id}" method:"post"` + Id int `in:"path"` + Page int `in:"query" json:"page"` + Token string `in:"header" json:"Authorization"` + Session string `in:"cookie" json:"session"` + Name string `json:"name"` + Age int `json:"age"` + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + Id: 123, + Page: 1, + Token: "Bearer xxx", + Session: "session-id", + Name: "john", + Age: 25, + }, &res) + + t.AssertNil(err) + // Verify each parameter is in the correct location + t.Assert(res, "path_id=123,query_page=1,header_token=Bearer xxx,cookie_session=session-id,body_name=john,body_age=25") + }) +} + +// Test_DoRequestObj_InTag_QuerySlice tests slice query parameters +func Test_DoRequestObj_InTag_QuerySlice(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/filter", func(r *ghttp.Request) { + // Get slice parameters from URL query (not from r.Get which only returns first value) + ids := r.URL.Query()["ids"] + tags := r.URL.Query()["tags"] + + // Verify we got all values + r.Response.Writef("ids_count=%d,ids_values=%v,tags_count=%d,tags_values=%v", + len(ids), ids, len(tags), tags) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type Req struct { + g.Meta `path:"/filter" method:"get"` + Ids []int `in:"query" json:"ids"` + Tags []string `in:"query" json:"tags"` + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + Ids: []int{1, 2, 3}, + Tags: []string{"go", "web"}, + }, &res) + + t.AssertNil(err) + // Verify all slice values are sent + t.Assert(res, "ids_count=3,ids_values=[1 2 3],tags_count=2,tags_values=[go web]") + }) +} + +// Test_DoRequestObj_InTag_RequestInspection demonstrates how to inspect the full request +func Test_DoRequestObj_InTag_RequestInspection(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/inspect/{id}", func(r *ghttp.Request) { + // Comprehensive request inspection + type RequestInfo struct { + // Path parameters + PathId string `json:"path_id"` + + // Query parameters + QueryPage string `json:"query_page"` + QueryTags []string `json:"query_tags"` + QueryRaw string `json:"query_raw"` + + // Headers + HeaderToken string `json:"header_token"` + HeaderVersion string `json:"header_version"` + + // Cookies + CookieSession string `json:"cookie_session"` + + // Body + BodyContent string `json:"body_content"` + BodyName string `json:"body_name"` + BodyAge int `json:"body_age"` + + // Request metadata + Method string `json:"method"` + URL string `json:"url"` + ContentType string `json:"content_type"` + } + + info := RequestInfo{ + // Extract path parameter + PathId: r.Get("id").String(), + + // Extract query parameters + QueryPage: r.URL.Query().Get("page"), + QueryTags: r.URL.Query()["tags"], + QueryRaw: r.URL.RawQuery, + + // Extract headers + HeaderToken: r.Header.Get("Authorization"), + HeaderVersion: r.Header.Get("X-Version"), + + // Extract cookies + CookieSession: r.Cookie.Get("session").String(), + + // Extract body + BodyContent: string(r.GetBody()), + + // Request metadata + Method: r.Method, + URL: r.URL.String(), + ContentType: r.Header.Get("Content-Type"), + } + + // Parse body JSON + bodyMap := r.GetBodyMap() + info.BodyName = gconv.String(bodyMap["name"]) + info.BodyAge = gconv.Int(bodyMap["age"]) + + r.Response.WriteJson(info) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type Req struct { + g.Meta `path:"/inspect/{id}" method:"post"` + Id int `in:"path"` + Page int `in:"query" json:"page"` + Tags []string `in:"query" json:"tags"` + Token string `in:"header" json:"Authorization"` + Version string `in:"header" json:"X-Version"` + Session string `in:"cookie" json:"session"` + Name string `json:"name"` + Age int `json:"age"` + } + + type RequestInfo struct { + PathId string `json:"path_id"` + QueryPage string `json:"query_page"` + QueryTags []string `json:"query_tags"` + QueryRaw string `json:"query_raw"` + HeaderToken string `json:"header_token"` + HeaderVersion string `json:"header_version"` + CookieSession string `json:"cookie_session"` + BodyContent string `json:"body_content"` + BodyName string `json:"body_name"` + BodyAge int `json:"body_age"` + Method string `json:"method"` + URL string `json:"url"` + ContentType string `json:"content_type"` + } + + var res RequestInfo + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())).ContentJson(). + DoRequestObj(context.Background(), &Req{ + Id: 123, + Page: 1, + Tags: []string{"go", "web", "api"}, + Token: "Bearer secret-token", + Version: "v2.0", + Session: "session-abc123", + Name: "Alice", + Age: 30, + }, &res) + + t.AssertNil(err) + + // Verify path parameter + t.Assert(res.PathId, "123") + + // Verify query parameters + t.Assert(res.QueryPage, "1") + t.Assert(len(res.QueryTags), 3) + t.Assert(res.QueryTags[0], "go") + t.Assert(res.QueryTags[1], "web") + t.Assert(res.QueryTags[2], "api") + t.Assert(strings.Contains(res.QueryRaw, "page=1"), true) + t.Assert(strings.Contains(res.QueryRaw, "tags=go"), true) + t.Assert(strings.Contains(res.QueryRaw, "tags=web"), true) + t.Assert(strings.Contains(res.QueryRaw, "tags=api"), true) + + // Verify headers + t.Assert(res.HeaderToken, "Bearer secret-token") + t.Assert(res.HeaderVersion, "v2.0") + + // Verify cookies + t.Assert(res.CookieSession, "session-abc123") + + // Verify body + t.Assert(res.BodyName, "Alice") + t.Assert(res.BodyAge, 30) + t.Assert(strings.Contains(res.BodyContent, `"name":"Alice"`), true) + t.Assert(strings.Contains(res.BodyContent, `"age":30`), true) + + // Verify request metadata + t.Assert(res.Method, "POST") + t.Assert(strings.Contains(res.URL, "/inspect/123"), true) + t.Assert(strings.Contains(res.ContentType, "application/json"), true) + }) +} + +// Test_DoRequestObj_InTag_FileUpload tests file upload with in tag +func Test_DoRequestObj_InTag_FileUpload(t *testing.T) { + // Create test file + testFile := gfile.Temp(guid.S()) + defer gfile.Remove(testFile) + gfile.PutContents(testFile, "test file content for upload") + + s := g.Server(guid.S()) + s.BindHandler("/upload/{id}", func(r *ghttp.Request) { + // Verify path parameter + pathId := r.Get("id").String() + + // Verify query parameter + queryCategory := r.URL.Query().Get("category") + + // Verify header + headerToken := r.Header.Get("Authorization") + + // Verify file upload + file := r.GetUploadFile("file") + var fileContent string + if file != nil { + content, _ := file.Open() + defer content.Close() + data := make([]byte, file.Size) + content.Read(data) + fileContent = string(data) + } + + // Verify other form field + description := r.Get("description").String() + + r.Response.Writef("path_id=%s,query_category=%s,header_token=%s,file_content=%s,description=%s", + pathId, queryCategory, headerToken, fileContent, description) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type Req struct { + g.Meta `path:"/upload/{id}" method:"post"` + Id int `in:"path"` + Category string `in:"query" json:"category"` + Token string `in:"header" json:"Authorization"` + File string `json:"file"` // File upload with @file: prefix + Description string `json:"description"` // Regular form field + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + Id: 123, + Category: "documents", + Token: "Bearer upload-token", + File: "@file:" + testFile, + Description: "Test document", + }, &res) + + t.AssertNil(err) + t.Assert(res, "path_id=123,query_category=documents,header_token=Bearer upload-token,file_content=test file content for upload,description=Test document") + }) +} + +// Test_DoRequestObj_InTag_MultipleFileUpload tests multiple file upload +func Test_DoRequestObj_InTag_MultipleFileUpload(t *testing.T) { + // Create test files + testFile1 := gfile.Temp(guid.S()) + testFile2 := gfile.Temp(guid.S()) + defer func() { + gfile.Remove(testFile1) + gfile.Remove(testFile2) + }() + gfile.PutContents(testFile1, "content1") + gfile.PutContents(testFile2, "content2") + + s := g.Server(guid.S()) + s.BindHandler("/upload", func(r *ghttp.Request) { + file1 := r.GetUploadFile("file1") + file2 := r.GetUploadFile("file2") + + var content1, content2 string + if file1 != nil { + f, _ := file1.Open() + defer f.Close() + data := make([]byte, file1.Size) + f.Read(data) + content1 = string(data) + } + if file2 != nil { + f, _ := file2.Open() + defer f.Close() + data := make([]byte, file2.Size) + f.Read(data) + content2 = string(data) + } + + r.Response.Writef("file1=%s,file2=%s", content1, content2) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + type Req struct { + g.Meta `path:"/upload" method:"post"` + File1 string `json:"file1"` + File2 string `json:"file2"` + } + + var res string + err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())). + DoRequestObj(context.Background(), &Req{ + File1: "@file:" + testFile1, + File2: "@file:" + testFile2, + }, &res) + + t.AssertNil(err) + t.Assert(res, "file1=content1,file2=content2") + }) +} diff --git a/net/gclient/gclient_z_unit_upload_query_params_test.go b/net/gclient/gclient_z_unit_upload_query_params_test.go new file mode 100644 index 00000000000..af85b5b7c04 --- /dev/null +++ b/net/gclient/gclient_z_unit_upload_query_params_test.go @@ -0,0 +1,462 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gclient_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/test/gtest" + "github.com/gogf/gf/v2/util/guid" +) + +// Test_Client_Upload_QueryParams_Basic tests basic file upload with query parameters +func Test_Client_Upload_QueryParams_Basic(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "test content for upload "+guid.S()) + defer gfile.Remove(tempDir) + + // Test 1: Basic file upload with query parameters + data := g.Map{ + "file": "@file:" + uploadFilePath, + } + + // Add query parameters using Query method + resp := c.Query(g.Map{ + "category": "documents", + "type": "text", + }).PostContent(context.Background(), "/upload", data) + + // Verify that both query parameters and file content are present + t.Assert(strings.Contains(resp, "category=[documents]"), true) + t.Assert(strings.Contains(resp, "type=[text]"), true) + t.Assert(strings.Contains(resp, "test content for upload"), true) // actual file content + }) +} + +// Test_Client_Upload_QueryParams_MixedScenarios tests various combinations of file upload with query parameters +func Test_Client_Upload_QueryParams_MixedScenarios(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/upload", func(r *ghttp.Request) { + tmpPath := gfile.Temp(guid.S()) + err := gfile.Mkdir(tmpPath) + gtest.AssertNil(err) + defer gfile.Remove(tmpPath) + + file := r.GetUploadFile("file") + _, err = file.Save(tmpPath) + gtest.AssertNil(err) + + // Get query parameters from URL + queryParams := r.URL.Query() + var queryResult string + for k, v := range queryParams { + queryResult += fmt.Sprintf("%s=%v,", k, v) + } + + r.Response.Write( + "query_params=" + queryResult + + "file_content=" + gfile.GetContents(gfile.Join(tmpPath, gfile.Basename(file.Filename))) + + "title=" + r.Get("title").String(), + ) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "upload test content "+guid.S()) + defer gfile.Remove(tempDir) + + // Test 1: File upload with URL query parameters and client query parameters + resp := c.Query(g.Map{ + "page": 1, + "size": 10, + }).PostContent(context.Background(), "/upload?filter=all&sort=date", g.Map{ + "file": "@file:" + uploadFilePath, + "title": "test file", + }) + + // Check that URL parameters, query parameters, and form fields are all present + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[10]"), true) + t.Assert(strings.Contains(resp, "filter=[all]"), true) + t.Assert(strings.Contains(resp, "sort=[date]"), true) + t.Assert(strings.Contains(resp, "upload test content"), true) // actual file content + t.Assert(strings.Contains(resp, "title=test file"), true) // form field should be present + }) +} + +// Test_Client_Upload_QueryParams_Conflicts tests conflict resolution between different parameter sources +func Test_Client_Upload_QueryParams_Conflicts(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "conflict test "+guid.S()) + defer gfile.Remove(tempDir) + + // Test conflict resolution: queryParams should override URL params + resp := c.Query(g.Map{ + "conflict": "from_query", // Higher priority + }).PostContent(context.Background(), "/upload?conflict=from_url", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Query parameters should override URL parameters + t.Assert(strings.Contains(resp, "conflict=[from_query]"), true) + t.Assert(strings.Contains(resp, "conflict=[from_url]"), false) + t.Assert(strings.Contains(resp, "conflict"), true) // Should appear once with correct value + }) +} + +// Test_Client_Upload_QueryParams_SliceArrays tests file upload with slice/array query parameters +func Test_Client_Upload_QueryParams_SliceArrays(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "slice test content "+guid.S()) + defer gfile.Remove(tempDir) + + // Test slice/array query parameters with file upload + resp := c.Query(g.Map{ + "tags": []string{"tag1", "tag2", "tag3"}, + "ids": []int{1, 2, 3}, + }).PostContent(context.Background(), "/upload", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Check that slice parameters are properly expanded + t.Assert(strings.Contains(resp, "tag1"), true) + t.Assert(strings.Contains(resp, "tag2"), true) + t.Assert(strings.Contains(resp, "tag3"), true) + t.Assert(strings.Contains(resp, "1"), true) + t.Assert(strings.Contains(resp, "2"), true) + t.Assert(strings.Contains(resp, "3"), true) + t.Assert(strings.Contains(resp, "slice test content"), true) // actual file content + }) +} + +// Test_Client_Upload_QueryParams_Chaining tests file upload with chained query parameter methods +func Test_Client_Upload_QueryParams_Chaining(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "chaining test content "+guid.S()) + defer gfile.Remove(tempDir) + + // Test chained query parameter methods with file upload + chainedClient := c.QueryPair("status", "active"). + QueryPair("priority", "high"). + SetQuery("category", "important") + + resp := chainedClient.PostContent(context.Background(), "/upload", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Check that all chained query parameters are present + t.Assert(strings.Contains(resp, "status=[active]"), true) + t.Assert(strings.Contains(resp, "priority=[high]"), true) + t.Assert(strings.Contains(resp, "category=[important]"), true) + t.Assert(strings.Contains(resp, "chaining test content"), true) // actual file content + }) +} + +// Test_Client_Upload_QueryParams_SpecialCharacters tests file upload with special characters in query parameters +func Test_Client_Upload_QueryParams_SpecialCharacters(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "special chars test "+guid.S()) + defer gfile.Remove(tempDir) + + // Test special characters in query parameters with file upload + resp := c.Query(g.Map{ + "query": "hello world", + "email": "test@example.com", + "path": "/data/file.txt", + "chinese": "中文测试", + "symbols": "!@#$%^&*()", + }).PostContent(context.Background(), "/upload", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Check that special characters are properly handled + t.Assert(strings.Contains(resp, "hello world"), true) + t.Assert(strings.Contains(resp, "test@example.com"), true) + t.Assert(strings.Contains(resp, "/data/file.txt"), true) + t.Assert(strings.Contains(resp, "中文测试"), true) + t.Assert(strings.Contains(resp, "!@#$"), true) // At least some symbols + t.Assert(strings.Contains(resp, "special chars test"), true) // actual file content + }) +} + +// Test_Client_Upload_QueryParams_GetMergedURL tests GetMergedURL functionality with file upload scenarios +func Test_Client_Upload_QueryParams_GetMergedURL(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/upload", func(r *ghttp.Request) { + r.Response.Write("ok") + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Test GetMergedURL with query parameters but no actual upload (since upload changes the request method to multipart) + // We'll test the URL building functionality separately + + // For GET requests with query parameters + mergedURL, err := c.Query(g.Map{ + "page": 1, + "size": 10, + }).GetMergedURL(context.Background(), "GET", "/api/data", g.Map{ + "filter": "active", + }) + + t.AssertNil(err) + t.Assert(strings.Contains(mergedURL, "page=1"), true) + t.Assert(strings.Contains(mergedURL, "size=10"), true) + t.Assert(strings.Contains(mergedURL, "filter=active"), true) + }) +} + +// Test_Client_Upload_QueryParams_NestedStruct tests file upload with nested struct query parameters +func Test_Client_Upload_QueryParams_NestedStruct(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "nested test content "+guid.S()) + defer gfile.Remove(tempDir) + + // Define a struct for query parameters + type FilterParams struct { + Page int `json:"page"` + Size int `json:"size"` + Category string `json:"category"` + Tags []string `json:"tags"` + } + + params := FilterParams{ + Page: 1, + Size: 20, + Category: "documents", + Tags: []string{"important", "review"}, + } + + // Test struct query parameters with file upload + resp := c.QueryParams(params).PostContent(context.Background(), "/upload", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Check that struct fields are properly converted to query parameters + t.Assert(strings.Contains(resp, "page=[1]"), true) + t.Assert(strings.Contains(resp, "size=[20]"), true) + t.Assert(strings.Contains(resp, "category=[documents]"), true) + t.Assert(strings.Contains(resp, "important"), true) // Tag value + t.Assert(strings.Contains(resp, "review"), true) // Tag value + t.Assert(strings.Contains(resp, "nested test content"), true) // actual file content + }) +} + +// Test_Client_Upload_QueryParams_EmptyValues tests file upload with empty/nil query parameter values +func Test_Client_Upload_QueryParams_EmptyValues(t *testing.T) { + s := createUploadServer() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "empty values test "+guid.S()) + defer gfile.Remove(tempDir) + + // Test with empty and nil values in query parameters + resp := c.Query(g.Map{ + "empty_string": "", + "zero_int": 0, + "nil_value": nil, // This should be skipped + "normal": "value", + }).PostContent(context.Background(), "/upload", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Empty values should be included, nil values should be skipped + t.Assert(strings.Contains(resp, "empty_string=[]"), true) // Empty string becomes [] + t.Assert(strings.Contains(resp, "zero_int=[0]"), true) // Zero value should be included + t.Assert(strings.Contains(resp, "nil_value="), false) // Nil value should be skipped + t.Assert(strings.Contains(resp, "normal=[value]"), true) // Normal value should be present + t.Assert(strings.Contains(resp, "empty values test"), true) // actual file content + }) +} + +// Test_Client_Upload_QueryParams_NoUrlEncode tests file upload with no URL encoding enabled +func Test_Client_Upload_QueryParams_NoUrlEncode(t *testing.T) { + s := g.Server(guid.S()) + s.BindHandler("/upload", func(r *ghttp.Request) { + tmpPath := gfile.Temp(guid.S()) + err := gfile.Mkdir(tmpPath) + gtest.AssertNil(err) + defer gfile.Remove(tmpPath) + + file := r.GetUploadFile("file") + _, err = file.Save(tmpPath) + gtest.AssertNil(err) + + r.Response.Write( + "raw_query=" + r.URL.RawQuery + + "file_content=" + gfile.GetContents(gfile.Join(tmpPath, gfile.Basename(file.Filename))), + ) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + + gtest.C(t, func(t *gtest.T) { + // Create temporary file with random name for upload + tempDir := gfile.Temp(guid.S()) + gfile.Mkdir(tempDir) + uploadFilePath := gfile.Join(tempDir, "random_upload_"+guid.S()+".txt") + gfile.PutContents(uploadFilePath, "no encode test "+guid.S()) + defer gfile.Remove(tempDir) + + // Test with no URL encoding + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + c.SetNoUrlEncode(true) + + resp := c.Query(g.Map{ + "path": "/data/binlog", + "query": "user=admin&role=super", + }).PostContent(context.Background(), "/upload", g.Map{ + "file": "@file:" + uploadFilePath, + }) + + // Check that special characters are not encoded + t.Assert(strings.Contains(resp, "path=/data/binlog"), true) // Not %2Fdata%2Fbinlog + t.Assert(strings.Contains(resp, "query=user=admin&role=super"), true) // Not encoded + t.Assert(strings.Contains(resp, "no encode test"), true) // actual file content + }) +} + +// createUploadServer creates a server for file upload testing +func createUploadServer() *ghttp.Server { + s := g.Server(guid.S()) + s.BindHandler("/upload", func(r *ghttp.Request) { + tmpPath := gfile.Temp(guid.S()) + err := gfile.Mkdir(tmpPath) + gtest.AssertNil(err) + defer gfile.Remove(tmpPath) + + file := r.GetUploadFile("file") + _, err = file.Save(tmpPath) + gtest.AssertNil(err) + + // Get query parameters from URL + queryParams := r.URL.Query() + var queryResult string + for k, v := range queryParams { + queryResult += fmt.Sprintf("%s=%v,", k, v) + } + + r.Response.Write( + "query_params=" + queryResult + + "file_content=" + gfile.GetContents(gfile.Join(tmpPath, gfile.Basename(file.Filename))), + ) + }) + s.SetDumpRouterMap(false) + s.Start() + return s +} diff --git a/net/ghttp/ghttp_request_param.go b/net/ghttp/ghttp_request_param.go index 4ff6475df30..895ac8ea1ba 100644 --- a/net/ghttp/ghttp_request_param.go +++ b/net/ghttp/ghttp_request_param.go @@ -189,6 +189,31 @@ func (r *Request) GetJson() (*gjson.Json, error) { }) } +// GetBodyMap returns only the parameters from request body, without mixing with query, form, or router parameters. +// This method is useful when you want to distinguish body parameters from other parameter sources. +func (r *Request) GetBodyMap(def ...map[string]any) map[string]any { + r.parseBody() + if r.bodyMap == nil { + if len(def) > 0 && def[0] != nil { + return def[0] + } + return make(map[string]any) + } + m := make(map[string]any) + for k, v := range r.bodyMap { + m[k] = v + } + // Check none exist parameters and assign it with default value. + if len(def) > 0 && def[0] != nil { + for k, v := range def[0] { + if _, ok := m[k]; !ok { + m[k] = v + } + } + } + return m +} + // GetMap is an alias and convenient function for GetRequestMap. // See GetRequestMap. func (r *Request) GetMap(def ...map[string]any) map[string]any {