diff --git a/examples/httpx/main.go b/examples/httpx/main.go new file mode 100644 index 0000000..b751163 --- /dev/null +++ b/examples/httpx/main.go @@ -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) + } +} diff --git a/httpx/README.md b/httpx/README.md index 63a9426..fd55099 100644 --- a/httpx/README.md +++ b/httpx/README.md @@ -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 移植并扩充)。 diff --git a/httpx/httpx_test.go b/httpx/httpx_test.go new file mode 100644 index 0000000..5124b10 --- /dev/null +++ b/httpx/httpx_test.go @@ -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(`admin`)) + + 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()) + } +} diff --git a/httpx/parse.go b/httpx/parse.go index 507f726..3adbcdd 100644 --- a/httpx/parse.go +++ b/httpx/parse.go @@ -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