Skip to content

Commit 09402ed

Browse files
committed
feat(gclient): 增强请求对象功能支持MIME类型设置和参数分类优化
1 parent 231722d commit 09402ed

File tree

2 files changed

+207
-71
lines changed

2 files changed

+207
-71
lines changed

net/gclient/gclient_request_obj.go

Lines changed: 39 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,11 @@ package gclient
88

99
import (
1010
"context"
11-
"fmt"
1211
"net/http"
1312
"reflect"
1413

1514
"github.com/gogf/gf/v2/errors/gcode"
1615
"github.com/gogf/gf/v2/errors/gerror"
17-
"github.com/gogf/gf/v2/internal/json"
1816
"github.com/gogf/gf/v2/net/goai"
1917
"github.com/gogf/gf/v2/os/gstructs"
2018
"github.com/gogf/gf/v2/text/gregex"
@@ -29,7 +27,7 @@ import (
2927
// The request object `req` is defined like:
3028
//
3129
// type UserCreateReq struct {
32-
// g.Meta `path:"/user/{id}" method:"post"`
30+
// g.Meta `path:"/user/{id}" method:"post" mime:"application/json"`
3331
// Id int `in:"path"` // Path parameter
3432
// Token string `in:"header"` // Header parameter
3533
// Page int `in:"query"` // Query parameter
@@ -41,6 +39,11 @@ import (
4139
// The response object `res` should be a pointer type. It automatically converts result
4240
// to given object `res` if success.
4341
//
42+
// Supported g.Meta tags:
43+
// - "path": Request path (required)
44+
// - "method": HTTP method (required)
45+
// - "mime": Content-Type header (optional, e.g., "application/json")
46+
//
4447
// Supported `in` tag values:
4548
// - "path": URL path parameters (e.g., /user/{id})
4649
// - "query": URL query parameters (e.g., ?page=1)
@@ -63,13 +66,14 @@ import (
6366
// )
6467
// err := client.DoRequestObj(ctx, req, &res)
6568
// // Actual request: POST /user/123?page=1
66-
// // Headers: Token: Bearer xxx
69+
// // Headers: Token: Bearer xxx, Content-Type: application/json
6770
// // Cookies: Session=session-id
6871
// // Body: {"name":"John","age":25}
6972
func (c *Client) DoRequestObj(ctx context.Context, req, res any) error {
7073
var (
71-
method = gmeta.Get(req, gtag.Method).String()
72-
path = gmeta.Get(req, gtag.Path).String()
74+
method = gmeta.Get(req, gtag.Method).String()
75+
path = gmeta.Get(req, gtag.Path).String()
76+
contentType = gmeta.Get(req, gtag.Mime).String()
7377
)
7478
if method == "" {
7579
return gerror.NewCodef(
@@ -115,6 +119,10 @@ func (c *Client) DoRequestObj(ctx context.Context, req, res any) error {
115119
client = client.SetCookie(k, v)
116120
}
117121
}
122+
// Set Content-Type from mime tag if specified
123+
if contentType != "" {
124+
client = client.ContentType(contentType)
125+
}
118126

119127
// Prepare body data
120128
var data any
@@ -176,16 +184,20 @@ type requestParams struct {
176184
// It returns parameters categorized into path, query, header, cookie, and body.
177185
//
178186
// Supported `in` tag values:
179-
// - "path": URL path parameters
180-
// - "query": URL query parameters (supports slice/array/map types)
187+
// - "path": URL path parameters (primitive types only)
188+
// - "query": URL query parameters (supports primitive types and slice/array)
181189
// - "header": HTTP request headers (string values only)
182190
// - "cookie": HTTP cookies (string values only)
183-
// - (empty): Request body parameters (default)
191+
// - (empty): Request body parameters (default, supports all types)
192+
//
193+
// Type restrictions:
194+
// - Struct and Map types are NOT supported for path/query/header/cookie parameters
195+
// - Only primitive types, slices, and arrays are allowed for query parameters
196+
// - Struct fields without `in` tag will be placed in the request body
184197
//
185-
// For embedded structs:
186-
// - Anonymous embedded structs are automatically flattened
187-
// - Named struct fields with `in:"query"` are flattened to query parameters
188-
// - Named struct fields without `in` tag are placed in body as-is
198+
// Embedded struct handling:
199+
// - Anonymous embedded structs without tags: fields are flattened into body
200+
// - Named struct fields: kept as nested structure in body
189201
func (c *Client) classifyRequestParams(req any) (*requestParams, error) {
190202
params := &requestParams{
191203
path: make(map[string]any),
@@ -195,10 +207,10 @@ func (c *Client) classifyRequestParams(req any) (*requestParams, error) {
195207
body: make(map[string]any),
196208
}
197209

198-
// Use RecursiveOptionEmbedded to automatically flatten anonymous embedded structs
210+
// Process direct fields first, then handle embedded structs for body parameters
199211
fields, err := gstructs.Fields(gstructs.FieldsInput{
200212
Pointer: req,
201-
RecursiveOption: gstructs.RecursiveOptionEmbedded,
213+
RecursiveOption: gstructs.RecursiveOptionEmbeddedNoTag,
202214
})
203215
if err != nil {
204216
return nil, err
@@ -219,29 +231,12 @@ func (c *Client) classifyRequestParams(req any) (*requestParams, error) {
219231

220232
// Handle named struct fields (non-embedded)
221233
if !field.IsEmbedded() && reflectValue.IsValid() && reflectValue.Kind() == reflect.Struct {
222-
// If struct field has `in` tag, special handling is required
234+
// Struct fields with `in` tag are not supported
223235
if inTag != "" {
224-
switch inTag {
225-
case goai.ParameterInQuery:
226-
// Flatten struct fields to query parameters
227-
if err := flattenStructToMap(params.query, fieldValue); err != nil {
228-
return nil, err
229-
}
230-
continue
231-
232-
case goai.ParameterInHeader:
233-
// Header doesn't support struct, serialize to JSON
234-
jsonBytes, _ := json.Marshal(fieldValue)
235-
params.header[fieldName] = string(jsonBytes)
236-
continue
237-
238-
case goai.ParameterInPath, goai.ParameterInCookie:
239-
// Path and Cookie don't support struct type
240-
return nil, gerror.Newf(
241-
`field "%s" with in:"%s" cannot be a struct type`,
242-
fieldName, inTag,
243-
)
244-
}
236+
return nil, gerror.Newf(
237+
`field "%s" with in:"%s" cannot be a struct type`,
238+
fieldName, inTag,
239+
)
245240
}
246241
// Struct field without `in` tag goes to body
247242
params.body[fieldName] = fieldValue
@@ -254,16 +249,15 @@ func (c *Client) classifyRequestParams(req any) (*requestParams, error) {
254249
params.path[fieldName] = fieldValue
255250

256251
case goai.ParameterInQuery:
257-
// Handle map type (flatten to key[subkey] format)
252+
// Map type is not supported for query parameters
258253
if reflectValue.IsValid() && reflectValue.Kind() == reflect.Map {
259-
for _, key := range reflectValue.MapKeys() {
260-
mapKey := fmt.Sprintf("%s[%s]", fieldName, key.String())
261-
params.query[mapKey] = reflectValue.MapIndex(key).Interface()
262-
}
263-
} else {
264-
// Slice/array/primitive types are handled by SetQueryMap
265-
params.query[fieldName] = fieldValue
254+
return nil, gerror.Newf(
255+
`field "%s" with in:"query" cannot be a map type, please use struct fields instead`,
256+
fieldName,
257+
)
266258
}
259+
// Slice/array/primitive types are handled by SetQueryMap
260+
params.query[fieldName] = fieldValue
267261

268262
case goai.ParameterInHeader:
269263
params.header[fieldName] = gconv.String(fieldValue)
@@ -279,29 +273,3 @@ func (c *Client) classifyRequestParams(req any) (*requestParams, error) {
279273

280274
return params, nil
281275
}
282-
283-
// flattenStructToMap flattens struct fields to target map.
284-
// It's used for flattening named struct fields with `in:"query"` tag.
285-
func flattenStructToMap(targetMap map[string]any, structValue any) error {
286-
fields, err := gstructs.Fields(gstructs.FieldsInput{
287-
Pointer: structValue,
288-
RecursiveOption: gstructs.RecursiveOptionEmbedded,
289-
})
290-
if err != nil {
291-
return err
292-
}
293-
294-
for _, field := range fields {
295-
if !field.IsExported() {
296-
continue
297-
}
298-
299-
fieldName := field.TagPriorityName()
300-
fieldValue := field.Value.Interface()
301-
302-
// Use field name directly (consistent with anonymous embedded behavior)
303-
targetMap[fieldName] = fieldValue
304-
}
305-
306-
return nil
307-
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
2+
//
3+
// This Source Code Form is subject to the terms of the MIT License.
4+
// If a copy of the MIT was not distributed with this file,
5+
// You can obtain one at https://github.com/gogf/gf.
6+
7+
package gclient_test
8+
9+
import (
10+
"context"
11+
"fmt"
12+
"testing"
13+
"time"
14+
15+
"github.com/gogf/gf/v2/frame/g"
16+
"github.com/gogf/gf/v2/net/ghttp"
17+
"github.com/gogf/gf/v2/test/gtest"
18+
"github.com/gogf/gf/v2/text/gstr"
19+
"github.com/gogf/gf/v2/util/gconv"
20+
"github.com/gogf/gf/v2/util/guid"
21+
)
22+
23+
// Test_DoRequestObj_EmbeddedStruct_Flattened tests anonymous embedded struct fields flattened to body
24+
func Test_DoRequestObj_EmbeddedStruct_Flattened(t *testing.T) {
25+
s := g.Server(guid.S())
26+
s.BindHandler("/user", func(r *ghttp.Request) {
27+
// Verify query parameter
28+
queryPage := r.URL.Query().Get("page")
29+
30+
// Verify body parameters (should be flattened)
31+
bodyMap := r.GetBodyMap()
32+
bodyAge := gconv.Int(bodyMap["age"])
33+
bodyEmail := gconv.String(bodyMap["email"])
34+
bodyName := gconv.String(bodyMap["name"])
35+
36+
r.Response.Writef("query_page=%s,body_age=%d,body_email=%s,body_name=%s",
37+
queryPage, bodyAge, bodyEmail, bodyName)
38+
})
39+
s.SetDumpRouterMap(false)
40+
s.Start()
41+
defer s.Shutdown()
42+
43+
time.Sleep(100 * time.Millisecond)
44+
45+
gtest.C(t, func(t *gtest.T) {
46+
type UserInfo struct {
47+
Age int `json:"age"`
48+
Email string `json:"email"`
49+
}
50+
51+
type Req struct {
52+
g.Meta `path:"/user" method:"post"`
53+
Page int `in:"query" json:"page"`
54+
UserInfo // Anonymous embedded, should flatten to body
55+
Name string `json:"name"` // Direct field, should go to body
56+
}
57+
58+
var res string
59+
err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())).
60+
DoRequestObj(context.Background(), &Req{
61+
Page: 1,
62+
UserInfo: UserInfo{Age: 25, Email: "test@example.com"},
63+
Name: "John",
64+
}, &res)
65+
66+
t.AssertNil(err)
67+
// Verify: page in query, age/email/name flattened in body
68+
t.Assert(res, "query_page=1,body_age=25,body_email=test@example.com,body_name=John")
69+
})
70+
}
71+
72+
// Test_DoRequestObj_NamedStruct_Nested tests named struct field kept as nested in body
73+
func Test_DoRequestObj_NamedStruct_Nested(t *testing.T) {
74+
s := g.Server(guid.S())
75+
s.BindHandler("/user", func(r *ghttp.Request) {
76+
// Verify query parameter
77+
queryPage := r.URL.Query().Get("page")
78+
79+
// Get form data (gclient sends map as form data by default)
80+
userJsonStr := r.GetForm("user").String()
81+
bodyName := r.GetForm("name").String()
82+
83+
// Parse user JSON string
84+
var userMap map[string]interface{}
85+
if userJsonStr != "" {
86+
gconv.Struct(userJsonStr, &userMap)
87+
}
88+
89+
bodyAge := gconv.Int(userMap["age"])
90+
bodyEmail := gconv.String(userMap["email"])
91+
92+
r.Response.Writef("query_page=%s,body_user_age=%d,body_user_email=%s,body_name=%s",
93+
queryPage, bodyAge, bodyEmail, bodyName)
94+
})
95+
s.SetDumpRouterMap(false)
96+
s.Start()
97+
defer s.Shutdown()
98+
99+
time.Sleep(100 * time.Millisecond)
100+
101+
gtest.C(t, func(t *gtest.T) {
102+
type UserInfo struct {
103+
Age int `json:"age"`
104+
Email string `json:"email"`
105+
}
106+
107+
type Req struct {
108+
g.Meta `path:"/user" method:"post"`
109+
Page int `in:"query" json:"page"`
110+
User UserInfo `json:"user"` // Named struct field, should keep nested
111+
Name string `json:"name"` // Direct field, should go to body
112+
}
113+
114+
var res string
115+
err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())).
116+
DoRequestObj(context.Background(), &Req{
117+
Page: 1,
118+
User: UserInfo{Age: 25, Email: "test@example.com"},
119+
Name: "John",
120+
}, &res)
121+
122+
t.AssertNil(err)
123+
// Verify: page in query, user nested in body (as JSON string in form data), name in body
124+
t.Assert(res, "query_page=1,body_user_age=25,body_user_email=test@example.com,body_name=John")
125+
})
126+
}
127+
128+
// Test_DoRequestObj_MimeTag_JSON tests mime tag for JSON content type
129+
func Test_DoRequestObj_MimeTag_JSON(t *testing.T) {
130+
s := g.Server(guid.S())
131+
s.BindHandler("/user", func(r *ghttp.Request) {
132+
// Verify Content-Type header
133+
contentType := r.Header.Get("Content-Type")
134+
135+
// Verify body is JSON
136+
bodyMap := r.GetBodyMap()
137+
bodyName := gconv.String(bodyMap["name"])
138+
bodyAge := gconv.Int(bodyMap["age"])
139+
140+
r.Response.Writef("content_type=%s,name=%s,age=%d", contentType, bodyName, bodyAge)
141+
})
142+
s.SetDumpRouterMap(false)
143+
s.Start()
144+
defer s.Shutdown()
145+
146+
time.Sleep(100 * time.Millisecond)
147+
148+
gtest.C(t, func(t *gtest.T) {
149+
type Req struct {
150+
g.Meta `path:"/user" method:"post" mime:"application/json"`
151+
Name string `json:"name"`
152+
Age int `json:"age"`
153+
}
154+
155+
var res string
156+
err := g.Client().SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())).
157+
DoRequestObj(context.Background(), &Req{
158+
Name: "John",
159+
Age: 25,
160+
}, &res)
161+
162+
t.AssertNil(err)
163+
// Verify Content-Type is set to application/json
164+
t.Assert(gstr.Contains(res, "content_type=application/json"), true)
165+
t.Assert(gstr.Contains(res, "name=John"), true)
166+
t.Assert(gstr.Contains(res, "age=25"), true)
167+
})
168+
}

0 commit comments

Comments
 (0)