feat(httpx): 并入 go-hua/httpx 的 ParseXML 并补齐单元测试

- ParseXML(XML 请求体绑定, 空请求体返回 nil)为 zogo 版本唯一缺口,
  从 go-hua/httpx 并入; 顶层 README 同步补充说明
- 移植 go-hua 全套 httpx 测试(CodeMsg/OkJson/ErrorJson/四种绑定/Validate/
  ParseXML)并新增 Handle/HandleResult/空 body 用例; 修正 README 中
  wsc.New 笔误为 New
This commit is contained in:
2026-09-20 12:37:11 +08:00
parent 96c63fb153
commit e1c0cc1136
4 changed files with 309 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
// httpx 包示例:gin 统一响应(OkJson/ErrorJson)、参数绑定(Parse)与业务校验(Validator)
package main
import (
"fmt"
"net/http"
"git.zeroonesoft.cn/golib/zogo/httpx"
"github.com/gin-gonic/gin"
)
// LoginReq 登录请求:Parse 会依次绑定 URI / Query / Header / JSON Body
type LoginReq struct {
Username string `json:"username" form:"username" uri:"username" header:"X-Username"`
Password string `json:"password" form:"password" uri:"password"`
}
// Validate 实现 httpx.Validator 接口,Parse 绑定完成后自动调用
func (r *LoginReq) Validate() error {
if r.Username == "" {
return httpx.New(1001, "用户名不能为空")
}
if r.Password == "" {
return httpx.New(1002, "密码不能为空")
}
return nil
}
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// POST /login/:username?password=xxx 或 POST /login/:username + JSON Body
r.POST("/login/:username", func(c *gin.Context) {
req := &LoginReq{}
if err := httpx.Parse(c, req); err != nil {
httpx.ErrorJson(c, err)
return
}
httpx.OkJson(c, gin.H{"welcome": req.Username})
})
r.GET("/ping", func(c *gin.Context) {
httpx.OkJson(c, "pong")
})
fmt.Println("服务已启动: http://127.0.0.1:8080")
fmt.Println("测试: curl -X POST \"http://127.0.0.1:8080/login/admin?password=123456\"")
fmt.Println("测试: curl -X POST http://127.0.0.1:8080/login/guest -d '{\"username\":\"\",\"password\":\"\"}'")
if err := http.ListenAndServe("127.0.0.1:8080", r); err != nil {
fmt.Println("服务退出:", err)
}
}
+12 -1
View File
@@ -8,7 +8,7 @@ Gin 服务的统一出入参收口。
- `OkJson(c, data)` —— 成功出参
- `ErrorJson(c, err)` —— 错误出参;`*CodeMsg` 携带的错误码**透出**(如 401),其余错误折叠为 code 1
- `wsc.New(401, "未登录")` 构造带码错误
- `New(401, "未登录")` 构造带码错误
## Handler 收口
@@ -29,3 +29,14 @@ binding tag 校验(`bindingValidator`,与 gin 同源 tag 名)。
json 字段的 required)。**引入本包即全局生效**,服务内不要绕过 Parse 自行 ShouldBind。
- 请求结构体可实现 `Validate() error` 接口做自定义校验,在 binding 校验之前调用。
- body 解析用 `json.NewDecoder` 直解,不触发 gin 内部校验。
## ParseXML(自 go-hua/httpx 并入)
XML 报文(如第三方回调)不在 Parse 主流程内,按需显式调用:
```go
if err := httpx.ParseXML(c, &req); err != nil { ... } // 空请求体返回 nil
```
单元测试覆盖 OkJson/ErrorJson/四种绑定/Validate 失败/ParseXML/Handle/HandleResult
([httpx_test.go](httpx_test.go),自 go-hua/httpx 移植并扩充)。
+233
View File
@@ -0,0 +1,233 @@
// 自 go-hua/httpx 整体移植的单元测试(覆盖 OkJson/ErrorJson/Parse 绑定/ParseXML),
// 并补充 Handle/HandleResult 用例。
package httpx
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type bindReq struct {
Username string `form:"username" json:"username" uri:"username" header:"X-Username"`
Password string `form:"password" json:"password" uri:"password"`
}
func (r *bindReq) Validate() error {
if r.Username == "" {
return New(1001, "用户名不能为空")
}
return nil
}
func newTestContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
if body != "" {
c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
}
return c, w
}
func TestNewCodeMsg(t *testing.T) {
err := New(1001, "业务错误")
cm, ok := err.(*CodeMsg)
if !ok {
t.Fatalf("New 应返回 *CodeMsg, got %T", err)
}
if cm.Code != 1001 {
t.Errorf("Code = %d, want 1001", cm.Code)
}
if cm.Msg != "业务错误" {
t.Errorf("Msg = %q", cm.Msg)
}
if cm.Error() != "code: 1001, msg: 业务错误" {
t.Errorf("Error() = %q", cm.Error())
}
}
func TestOkJson(t *testing.T) {
c, w := newTestContext("")
OkJson(c, map[string]string{"k": "v"})
if !strings.Contains(w.Body.String(), `"code":0`) {
t.Errorf("OkJson 输出缺少 code:0: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"data"`) {
t.Errorf("OkJson 输出缺少 data: %s", w.Body.String())
}
}
func TestErrorJson(t *testing.T) {
c, w := newTestContext("")
ErrorJson(c, New(1001, "业务错误"))
// *CodeMsg 应使用其业务错误码与消息
if !strings.Contains(w.Body.String(), `"code":1001`) {
t.Errorf("ErrorJson 输出缺少 code:1001: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"msg":"业务错误"`) {
t.Errorf("ErrorJson msg 不符: %s", w.Body.String())
}
}
func TestErrorJsonPlainError(t *testing.T) {
c, w := newTestContext("")
ErrorJson(c, errors.New("数据库连接失败"))
// 非 *CodeMsg 的 error 统一 code=1,msg 为 err.Error()
if !strings.Contains(w.Body.String(), `"code":1`) {
t.Errorf("普通错误 code 应为 1: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"msg":"数据库连接失败"`) {
t.Errorf("普通错误 msg 不符: %s", w.Body.String())
}
}
func TestParseQueryBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/?username=admin&password=123", nil)
req := &bindReq{}
if err := Parse(c, req); err != nil {
t.Fatalf("Parse: %v", err)
}
if req.Username != "admin" || req.Password != "123" {
t.Errorf("query 绑定结果 = %+v", req)
}
}
func TestParseJsonBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"username":"admin","password":"123"}`))
c.Request.Header.Set("Content-Type", "application/json")
req := &bindReq{}
if err := Parse(c, req); err != nil {
t.Fatalf("Parse: %v", err)
}
if req.Username != "admin" || req.Password != "123" {
t.Errorf("json 绑定结果 = %+v", req)
}
}
func TestParseHeaderBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.Header.Set("X-Username", "admin")
req := &bindReq{}
if err := Parse(c, req); err != nil {
t.Fatalf("Parse: %v", err)
}
if req.Username != "admin" {
t.Errorf("header 绑定结果 = %+v", req)
}
}
func TestParseUriBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/1/admin", nil)
// Parse 仅当存在 id 参数时才执行 uri 绑定
c.Params = gin.Params{{Key: "id", Value: "1"}, {Key: "username", Value: "admin"}, {Key: "password", Value: "123"}}
req := &bindReq{}
if err := Parse(c, req); err != nil {
t.Fatalf("Parse: %v", err)
}
if req.Username != "admin" || req.Password != "123" {
t.Errorf("uri 绑定结果 = %+v", req)
}
}
func TestParseValidateFail(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
req := &bindReq{}
err := Parse(c, req)
if err == nil {
t.Fatal("Validate 失败应返回错误")
}
if cm, ok := err.(*CodeMsg); !ok || cm.Code != 1001 {
t.Errorf("错误类型应为 *CodeMsg(1001), got %T %v", err, err)
}
}
func TestParseXML(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`<bindReq><Username>admin</Username></bindReq>`))
req := &bindReq{}
if err := ParseXML(c, req); err != nil {
t.Fatalf("ParseXML: %v", err)
}
if req.Username != "admin" {
t.Errorf("xml 绑定结果 = %+v", req)
}
}
func TestParseXMLEmptyBody(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
req := &bindReq{}
if err := ParseXML(c, req); err != nil {
t.Fatalf("空请求体 ParseXML 应返回 nil: %v", err)
}
if req.Username != "" {
t.Errorf("空请求体不应绑定出值: %+v", req)
}
}
func TestHandle(t *testing.T) {
c, w := newTestContext(`{"username":"admin","password":"123"}`)
c.Request.Header.Set("Content-Type", "application/json")
Handle(c, func(req *bindReq) (*map[string]string, error) {
return &map[string]string{"user": req.Username}, nil
})
if !strings.Contains(w.Body.String(), `"user":"admin"`) {
t.Errorf("Handle 输出缺 data: %s", w.Body.String())
}
}
func TestHandleBizError(t *testing.T) {
c, w := newTestContext(`{"username":"admin","password":"123"}`)
c.Request.Header.Set("Content-Type", "application/json")
Handle(c, func(req *bindReq) (*map[string]string, error) {
return nil, New(1002, "被拦了")
})
if !strings.Contains(w.Body.String(), `"code":1002`) {
t.Errorf("Handle 业务错误应透传 CodeMsg: %s", w.Body.String())
}
}
func TestHandleResult(t *testing.T) {
c, w := newTestContext("")
HandleResult(c, map[string]string{"k": "v"}, nil)
if !strings.Contains(w.Body.String(), `"k":"v"`) {
t.Errorf("HandleResult 成功输出不符: %s", w.Body.String())
}
c, w = newTestContext("")
HandleResult(c, nil, New(1003, "失败了"))
if !strings.Contains(w.Body.String(), `"code":1003`) {
t.Errorf("HandleResult 错误输出不符: %s", w.Body.String())
}
}
+10
View File
@@ -60,6 +60,16 @@ func parseJSON(c *gin.Context, v any) error {
return json.NewDecoder(c.Request.Body).Decode(v)
}
// ParseXML 显式绑定 XML 请求体到 v(空请求体直接返回 nil)。
// 自 go-hua/httpx 并入:Parse 主流程收口 uri/query/header/json 四种来源,
// XML 报文(如第三方回调)按需显式调用本函数。
func ParseXML(c *gin.Context, v any) error {
if c.Request.ContentLength == 0 {
return nil
}
return c.ShouldBindXML(v)
}
// Validator 请求结构体可实现此接口做自定义校验, 在 binding 校验之前调用
type Validator interface {
Validate() error