diff --git a/examples/httpc/main.go b/examples/httpc/main.go new file mode 100644 index 0000000..c6a61f8 --- /dev/null +++ b/examples/httpc/main.go @@ -0,0 +1,47 @@ +// httpc 包示例:HTTP 客户端封装(Get/Post/Put/Delete/GetProxy/DigestAuthRequest) +package main + +import ( + "fmt" + "io" + "net" + "net/http" + + "git.zeroonesoft.cn/golib/zogo/httpc" +) + +func main() { + // 启动一个本地测试服务 + mux := http.NewServeMux() + mux.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"method":"%s","query":"%s"}`, r.Method, r.URL.RawQuery) + }) + mux.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + fmt.Fprintf(w, `{"method":"%s","body":%s}`, r.Method, string(body)) + }) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) + } + go http.Serve(l, mux) + base := "http://" + l.Addr().String() + + // GET + body, err := httpc.Get(base+"/get?name=go-hua", nil) + fmt.Println("GET:", string(body), "err:", err) + + // POST(默认 Content-Type: application/json) + body, err = httpc.Post(base+"/post", []byte(`{"id":1}`), nil) + fmt.Println("POST:", string(body), "err:", err) + + // 带自定义请求头 + body, err = httpc.Get(base+"/get", map[string]string{"X-Token": "abc123"}) + fmt.Println("GET(带Header):", string(body), "err:", err) + + // 其他方法用法相同: + // httpc.Put(url, param, header) + // httpc.Delete(url, param, header) + // httpc.GetProxy(url, header, "http://127.0.0.1:7890") + // httpc.DigestAuthRequest(&httpc.RequestArg{...}) +} diff --git a/httpc/README.md b/httpc/README.md new file mode 100644 index 0000000..77b80e0 --- /dev/null +++ b/httpc/README.md @@ -0,0 +1,41 @@ +# httpc + +轻量 HTTP 客户端:GET/POST/PUT/DELETE 快捷方法(自定义 Header、可选代理)、 +Digest 认证请求。 + +> 迁移自 go-hua/httpurl 并更名:原包名 `httpurl` 看不出是客户端,更名 `httpc` +> (http client)。函数签名不变。 + +## 用法 + +```go +import "git.zeroonesoft.cn/golib/zogo/httpc" + +body, err := httpc.Get("https://api.example.com/v1/info", nil) +body, err := httpc.Post("https://api.example.com/v1/add", []byte(`{"id":1}`), + map[string]string{"Content-Type": "application/json"}) +body, err := httpc.GetProxy(url, nil, "http://127.0.0.1:8080") // 走代理 +body, err := httpc.Put(url, payload, nil) +body, err := httpc.Delete(url, nil, nil) + +// Digest 认证 +resp, err := httpc.DigestAuthRequest(&httpc.RequestArg{ + Url: url, + Method: "GET", + Username: "admin", + Password: "pass", +}) +``` + +完整可运行例程:[examples/httpc/main.go](../examples/httpc/main.go) + +## 返回与错误 + +- 2xx 返回响应体字节集;非 2xx 返回包含状态码的错误。 +- `Get/Post/Put/Delete` 的 `header` 参数传 `nil` 表示不附加自定义头。 + +## 注意 + +- `DigestAuthRequest` 只处理 RFC 2617 Digest 流程(qop=auth);Basic/Bearer + 请直接在 header 里带 Authorization。 +- 需要 JSON 自动绑定/泛型封装时配合服务端包 `zogo/httpx` 之外的客户端封装自行扩展。 diff --git a/httpc/digestAuth.go b/httpc/digestAuth.go new file mode 100644 index 0000000..d9af6c0 --- /dev/null +++ b/httpc/digestAuth.go @@ -0,0 +1,126 @@ +package httpc + +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" +) + +// RequestArg Digest 认证请求参数 +type RequestArg struct { + Host string //http://localhost + Uri string + Method string + Header map[string]string //请求头 + PostBody []byte + Username string //用户名 + Password string //密码 +} + +// DigestAuthRequest digestAuth 请求 +func DigestAuthRequest(arg *RequestArg) ([]byte, error) { + url := arg.Host + arg.Uri + req, err := http.NewRequest(arg.Method, url, nil) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + resStr, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return resStr, nil + } + parts := digestParts(resp) + parts["uri"] = arg.Uri + parts["method"] = arg.Method + parts["username"] = arg.Username + parts["password"] = arg.Password + parts["algorithm"] = "MD5" + req, err = http.NewRequest(arg.Method, url, bytes.NewBuffer(arg.PostBody)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", getDigestAuthorization(parts)) + for k, v := range arg.Header { + req.Header.Set(k, v) + } + resp, err = client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, err + } + all, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return all, nil +} + +// 第一次请求的响应header 获取第二次请求所需要的信息 Www-Authenticate +func digestParts(resp *http.Response) map[string]string { + result := map[string]string{} + if len(resp.Header["Www-Authenticate"]) > 0 { + wantedHeaders := []string{"nonce", "realm", "qop", "opaque"} + responseHeaders := strings.Split(resp.Header["Www-Authenticate"][0], ",") + for _, r := range responseHeaders { + for _, w := range wantedHeaders { + if strings.Contains(r, w) { + result[w] = strings.Split(r, `"`)[1] + } + } + } + } + return result +} + +// 第二次请求的请求header Authorization 值 +func getDigestAuthorization(digestParts map[string]string) string { + d := digestParts + //ha1=md5(username:realm:password) + ha1 := getMD5(d["username"] + ":" + d["realm"] + ":" + d["password"]) + //ha2=md5(method:uri) + ha2 := getMD5(d["method"] + ":" + d["uri"]) + nonceCount := "00000001" + cnonce := getCnonce() + + //response=md5(ha1:nonce:nc:cnonce:qop:ha2) + response := getMD5(fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, d["nonce"], nonceCount, cnonce, d["qop"], ha2)) + + //qop和nc的值不能加引号 + authorization := fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s", opaque="%s", algorithm=MD5", qop=%s, nc=%s, cnonce="%s"`, + d["username"], d["realm"], d["nonce"], d["uri"], response, d["opaque"], d["qop"], nonceCount, cnonce) + + return authorization +} + +// 字符串MD5加密 +func getMD5(text string) string { + hash := md5.New() + hash.Write([]byte(text)) + return hex.EncodeToString(hash.Sum(nil)) +} + +// 获取 cnonce +// 客户端提供的不透明带引号的字符串值,客户端和服务器都使用它来避免选定的明文攻击、提供相互身份验证以及提供一些消息完整性保护 +func getCnonce() string { + b := make([]byte, 8) + io.ReadFull(rand.Reader, b) + return fmt.Sprintf("%x", b)[:16] +} diff --git a/httpc/httpc.go b/httpc/httpc.go new file mode 100644 index 0000000..93a97ea --- /dev/null +++ b/httpc/httpc.go @@ -0,0 +1,196 @@ +// Package httpc 封装常用 HTTP 客户端请求(Get/Post/Put/Delete/代理/Digest 认证)。 +package httpc + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// 复用的 http.Transport 实例 +var transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, +} + +// 复用的 http.Client 实例 +var client = &http.Client{ + Transport: transport, + Timeout: time.Second * 15, +} + +// Post 发送 POST 请求并返回响应体 +// header 为空或未指定 Content-Type 时默认使用 application/json +func Post(url string, param []byte, header map[string]string) ([]byte, error) { + payload := bytes.NewReader(param) + // 创建新的 HTTP POST 请求 + req, err := http.NewRequest("POST", url, payload) + if err != nil { + return nil, err + } + // 添加请求头 + for k, v := range header { + req.Header.Add(k, v) + } + if header == nil || header["Content-Type"] == "" { + req.Header.Add("Content-Type", "application/json") + } + // 发送请求 + res, err := client.Do(req) + if err != nil { + return nil, err + } + // 确保响应体在函数结束时关闭 + defer res.Body.Close() + // 读取响应体 + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + // 检查响应状态码 + if res.StatusCode != 200 { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + return body, nil +} + +// Get 发送 GET 请求并返回响应体 +func Get(httpUrl string, header map[string]string) ([]byte, error) { + // 创建新的 HTTP GET 请求 + req, err := http.NewRequest("GET", httpUrl, nil) + if err != nil { + return nil, err + } + // 添加请求头 + for k, v := range header { + req.Header.Add(k, v) + } + req.Header.Add("Content-Type", "application/json") + // 发送请求 + res, err := client.Do(req) + if err != nil { + return nil, err + } + // 确保响应体在函数结束时关闭 + defer res.Body.Close() + // 读取响应体 + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + // 检查响应状态码 + if res.StatusCode != 200 { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + return body, nil +} + +// GetProxy 通过指定代理发送 GET 请求并返回响应体 +// proxyAddr 如 "http://127.0.0.1:7890",为空则直连 +func GetProxy(httpUrl string, header map[string]string, proxyAddr string) ([]byte, error) { + + req, err := http.NewRequest("GET", httpUrl, nil) + if err != nil { + return nil, err + } + for k, v := range header { + req.Header.Add(k, v) + } + // req.Header.Add("Content-Type", "application/json") + /*req.Header.Add("Authorization", "BNbmgAAGI155F6MJ3N2Tk9ruL_6XQpx-uxkkg:tGCY3xCsgybHd5IjcDMi9yZXBvcy93aF9mbG93RGF0YVNvdXJjZTEiLCJleHBpcmVzIjoxNTM2NzU4NjQ3LCJjb250ZW5VudFR5cGUiOiIiLCJoZWFkZXJzIjoiIiwibWV0aG9kIjoiR0VUIn0=")*/ + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + if proxyAddr != "" { + proxyUrl, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + tr.Proxy = http.ProxyURL(proxyUrl) + } + + client := &http.Client{Transport: tr} + client.Timeout = time.Second * 15 + defer client.CloseIdleConnections() + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + //fmt.Println(res) + //fmt.Println(string(body)) + if res.StatusCode != 200 { + return body, fmt.Errorf("status code: %d", res.StatusCode) + } + return body, nil + +} + +// Put 发送 PUT 请求并返回响应体 +func Put(url string, param []byte, header map[string]string) ([]byte, error) { + payload := bytes.NewReader(param) + // 创建新的 HTTP GET 请求 + req, err := http.NewRequest("PUT", url, payload) + if err != nil { + return nil, err + } + // 添加请求头 + for k, v := range header { + req.Header.Add(k, v) + } + // 发送请求 + res, err := client.Do(req) + if err != nil { + return nil, err + } + // 确保响应体在函数结束时关闭 + defer res.Body.Close() + // 读取响应体 + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + // 检查响应状态码 + if res.StatusCode != 200 { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + return body, nil +} + +// Delete 发送 DELETE 请求并返回响应体 +func Delete(url string, param []byte, header map[string]string) ([]byte, error) { + payload := bytes.NewReader(param) + // 创建新的 HTTP DELETE 请求 + req, err := http.NewRequest("DELETE", url, payload) + if err != nil { + return nil, err + } + // 添加请求头 + for k, v := range header { + req.Header.Add(k, v) + } + // 发送请求 + res, err := client.Do(req) + if err != nil { + return nil, err + } + // 确保响应体在函数结束时关闭 + defer res.Body.Close() + // 读取响应体 + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + // 检查响应状态码 + if res.StatusCode != 200 { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + return body, nil +} diff --git a/httpc/httpc_test.go b/httpc/httpc_test.go new file mode 100644 index 0000000..dda3e13 --- /dev/null +++ b/httpc/httpc_test.go @@ -0,0 +1,137 @@ +package httpc + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGet(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Method = %s, want GET", r.Method) + } + if r.URL.Query().Get("name") != "zogo" { + t.Errorf("query name = %q", r.URL.Query().Get("name")) + } + w.Write([]byte(`{"ok":true}`)) + })) + defer ts.Close() + + body, err := Get(ts.URL+"/get?name=zogo", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + if string(body) != `{"ok":true}` { + t.Errorf("body = %q", string(body)) + } +} + +func TestGetWithHeader(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Token") != "abc" { + t.Errorf("X-Token = %q", r.Header.Get("X-Token")) + } + w.Write([]byte("ok")) + })) + defer ts.Close() + + if _, err := Get(ts.URL, map[string]string{"X-Token": "abc"}); err != nil { + t.Fatalf("Get: %v", err) + } +} + +func TestPost(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Method = %s, want POST", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) + } + b, _ := io.ReadAll(r.Body) + if string(b) != `{"id":1}` { + t.Errorf("body = %q", string(b)) + } + w.Write([]byte("posted")) + })) + defer ts.Close() + + body, err := Post(ts.URL, []byte(`{"id":1}`), nil) + if err != nil { + t.Fatalf("Post: %v", err) + } + if string(body) != "posted" { + t.Errorf("body = %q", string(body)) + } +} + +func TestPostCustomContentType(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/plain") { + t.Errorf("Content-Type = %q, want text/plain", r.Header.Get("Content-Type")) + } + })) + defer ts.Close() + + if _, err := Post(ts.URL, []byte("text"), map[string]string{"Content-Type": "text/plain"}); err != nil { + t.Fatalf("Post: %v", err) + } +} + +func TestPut(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("Method = %s, want PUT", r.Method) + } + w.Write([]byte("putted")) + })) + defer ts.Close() + + body, err := Put(ts.URL, []byte(`{}`), nil) + if err != nil || string(body) != "putted" { + t.Errorf("Put = %q, %v", string(body), err) + } +} + +func TestDelete(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("Method = %s, want DELETE", r.Method) + } + w.Write([]byte("deleted")) + })) + defer ts.Close() + + body, err := Delete(ts.URL, []byte(`{}`), nil) + if err != nil || string(body) != "deleted" { + t.Errorf("Delete = %q, %v", string(body), err) + } +} + +func TestGetProxyEmptyProxy(t *testing.T) { + // 代理地址为空时应直连 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("direct")) + })) + defer ts.Close() + + body, err := GetProxy(ts.URL, nil, "") + if err != nil || string(body) != "direct" { + t.Errorf("GetProxy 空代理 = %q, %v", string(body), err) + } +} + +func TestErrorStatusCode(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + _, err := Get(ts.URL, nil) + if err == nil || !strings.Contains(err.Error(), "500") { + t.Errorf("非 2xx 响应应返回错误,got %v", err) + } +}