feat(httpc): go-hua/httpurl 迁入并更名 httpc(HTTP 客户端)

- 原包名 httpurl 看不出是客户端, 更名 httpc; GET/POST/PUT/DELETE/代理/
  Digest 认证, 函数签名不变, 附测试、例程与 README
This commit is contained in:
2026-09-20 12:25:36 +08:00
parent caa65209ac
commit a8577b7b3b
5 changed files with 547 additions and 0 deletions
+41
View File
@@ -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` 之外的客户端封装自行扩展。
+126
View File
@@ -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]
}
+196
View File
@@ -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
}
+137
View File
@@ -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)
}
}