Compare commits

..

No commits in common. "main" and "v1.0.4" have entirely different histories.
main ... v1.0.4

9 changed files with 191 additions and 397 deletions

8
.gitignore vendored
View File

@ -1,8 +0,0 @@
.log.meta.json
.ai/
.geminiignore
.gemini
env.json
env.yml
env.yaml
/CODE-FULL.md

View File

@ -1,34 +1,5 @@
# CHANGELOG # CHANGELOG
## v1.0.10 (2026-05-09)
- **CI/CD 对齐**:
- 修复测试用例由于默认绑定泛地址导致的 `connection refused` 错误,显式绑定本地测试服务器至 `127.0.0.1`,提高 macOS 与多网卡环境下的测试稳定性。
- 更新 `TEST.md`,同步最新的测试用例覆盖场景(包括 `Multipart``Form` 的验证)及真实 Benchmark。
## v1.0.9 (2026-05-09)
- **基础设施对齐**:
- 升级 `apigo.cc/go/log``v1.1.13`
- 确保与 `log` 包最新的“绝对索引”和“强制 Reset 契约”兼容。`http` 包不涉及自定义日志类型,故无代码变更。
## v1.0.8 (2026-05-08)
- **性能优化 (Memory Efficiency)**:
- 重构 `Multipart` 处理逻辑,采用 `io.Pipe` 实现流式上传,彻底解决大文件上传时的内存撑爆问题。
- 引入 `writeMultipartPart``writeMultipartFile` 辅助函数,优化分块写入逻辑。
- **功能增强 (Unified Type Handling)**:
- 统一 `do` 方法内部逻辑,通过类型分组消除冗余代码。
- 将 `map[string][]any` 提升为 `Multipart` 处理,确保复杂/混合数据类型的正确流式发送。
- 完善对 `url.Values`, `map[string][]string`, `Form` 的统一表单编码支持。
- 优化 `Multipart` 对多值字段的支持,允许在 `Multipart` 映射中使用 `[]string``[]any` 发送同名多参数。
- **基础设施对齐**:
- 更新 `go/encoding` 至 v1.0.6。
- 移除 `bufferPool``Multipart` 中的使用,转向更高效的流式 IO。
## v1.0.6 (2026-05-07)
- 内部版本,优化 Header 自动透传逻辑。
## v1.0.5 (2026-05-06)
- 内部版本,增强对 H2C 场景下的连接池管理。
## v1.0.4 (2026-05-05) ## v1.0.4 (2026-05-05)
- **基础设施对齐**: - **基础设施对齐**:
- 更新 `go/file` 至 v1.0.5, `go/log` 至 v1.1.1, `go/config` 至 v1.0.5。 - 更新 `go/file` 至 v1.0.5, `go/log` 至 v1.1.1, `go/config` 至 v1.0.5。

View File

@ -36,12 +36,8 @@ user, err := http.To[User](r)
if err == nil { if err == nil {
fmt.Println(user.Name) fmt.Println(user.Name)
} }
// 支持 map[string][]string 或 map[string][]any 作为表单 (application/x-www-form-urlencoded)
c.Post("https://api.example.com/update", map[string][]string{"tags": {"go", "http"}})
``` ```
### 2. Header 透传 (Discover Relay) ### 2. Header 透传 (Discover Relay)
在处理外部请求时,自动从原请求中提取并续传关键 Header。 在处理外部请求时,自动从原请求中提取并续传关键 Header。
```go ```go
@ -70,20 +66,11 @@ c.Download("local_file.zip", "https://example.com/large_file.zip", func(start, e
### 请求方法 ### 请求方法
- `func (c *Client) Get(url string, headers ...string) *Result` - `func (c *Client) Get(url string, headers ...string) *Result`
- `func (c *Client) Post(url string, data any, headers ...string) *Result`: 支持多种数据类型JSON, Form, Multipart - `func (c *Client) Post(url string, data any, headers ...string) *Result`
- `func (c *Client) Put(url string, data any, headers ...string) *Result` - `func (c *Client) Put(url string, data any, headers ...string) *Result`
- `func (c *Client) Delete(url string, data any, headers ...string) *Result` - `func (c *Client) Delete(url string, data any, headers ...string) *Result`
- `func (c *Client) Head(url string, headers ...string) *Result` - `func (c *Client) Head(url string, headers ...string) *Result`
- `func (c *Client) PostMultipart(url string, formData map[string]string, files map[string]any, headers ...string) (*Result, []error)`: 多部分表单提交(支持文件与流)。
### 特殊类型
- `type Form map[string]string`: 用于 `Post/Put` 等方法,显式指定为 `application/x-www-form-urlencoded` 格式。
- 注意:直接传入 `map[string]string` 会被默认识别为 `application/json`;传入 `map[string][]string``map[string][]any` 会被识别为 `application/x-www-form-urlencoded`
- `type Multipart map[string]any`: 用于 `Post/Put` 等方法,支持混合表单字段与文件流式上传。
- **流式上传**: 内部使用 `io.Pipe` 结合 `multipart.Writer` 实现,支持超大文件而不会占用过多内存。
- **文件识别**: 如果值为 `string` 且指向有效文件路径,则作为文件上传。
- **流/字节**: 如果值为 `[]byte``io.Reader`,则作为文件上传。
- **多值支持**: 如果值为 `[]string``[]any`,将产生多个同名的表单字段或文件。
- **其他类型**: 将作为普通表单字段(复杂类型会自动转为 JSON
### 响应处理 (Result) ### 响应处理 (Result)
- `func (rs *Result) String() string`: 返回响应体字符串。 - `func (rs *Result) String() string`: 返回响应体字符串。

10
TEST.md
View File

@ -4,10 +4,9 @@
- **基础请求**: 验证了使用 `Get` 获取 JSON 响应并通过 `To[T]` 进行泛型绑定的功能。 - **基础请求**: 验证了使用 `Get` 获取 JSON 响应并通过 `To[T]` 进行泛型绑定的功能。
- **本地服务器**: 验证了客户端与本地测试服务器的交互。 - **本地服务器**: 验证了客户端与本地测试服务器的交互。
- **H2C 支持**: 验证了 H2C (HTTP/2 Cleartext) 的兼容性。 - **H2C 支持**: 验证了 H2C (HTTP/2 Cleartext) 的兼容性。
- **手动请求**: 验证了通过 `ManualDo` 进行精细化控制的流式请求 - **手动请求**: 验证了通过 `ManualDo` 进行精细化控制的流
- **文件下载**: 验证了使用 `Download` 进行并发分段下载的功能。 - **文件下载**: 验证了使用 `Download` 进行并发分段下载的功能。
- **多部分表单 (Multipart)**: 验证了使用流式 `io.Pipe``Multipart` 发送,以及多类型、多值字段的支持。 - **多部分表单**: 验证了使用 `MPost` 上传文件和表单数据的正确性。
- **表单与映射**: 验证了使用 `Form`, `map[string]string`, `map[string][]string`, 和 `map[string][]any` 自动转为正确的 `application/x-www-form-urlencoded``application/json`
## 性能测试结果 (Benchmark) ## 性能测试结果 (Benchmark)
``` ```
@ -15,8 +14,5 @@ goos: darwin
goarch: amd64 goarch: amd64
pkg: apigo.cc/go/http pkg: apigo.cc/go/http
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkGet-16 16364 74735 ns/op BenchmarkGet-16 15944 73379 ns/op
``` ```
## 测试覆盖率
- **整体覆盖率**: 56.5% of statements

286
client.go
View File

@ -42,29 +42,12 @@ type Result struct {
data []byte data []byte
} }
type Form map[string]string type Form = map[string]string
type Multipart map[string]any
var DefaultClient = NewClient(30 * time.Second) var bufferPool = sync.Pool{
New: func() any {
func Get(url string, headers ...string) *Result { return new(bytes.Buffer)
return DefaultClient.Get(url, headers...) },
}
func Post(url string, data any, headers ...string) *Result {
return DefaultClient.Post(url, data, headers...)
}
func Put(url string, data any, headers ...string) *Result {
return DefaultClient.Put(url, data, headers...)
}
func Delete(url string, data any, headers ...string) *Result {
return DefaultClient.Delete(url, data, headers...)
}
func Do(method, url string, data any, headers ...string) *Result {
return DefaultClient.Do(method, url, data, headers...)
} }
func NewClient(timeout time.Duration) *Client { func NewClient(timeout time.Duration) *Client {
@ -179,18 +162,6 @@ func (client *Client) doByRequest(manualDo bool, request *http.Request, method,
} }
} }
// 确保 Request-ID 存在
foundID := false
for i := 1; i < len(headers); i += 2 {
if headers[i-1] == HeaderRequestID {
foundID = true
break
}
}
if !foundID {
headers = append(headers, HeaderRequestID, string(encoding.Hex(rand.Bytes(16))))
}
// 续传 X-Forwarded-For // 续传 X-Forwarded-For
xForwardFor := request.Header.Get(HeaderForwardedFor) xForwardFor := request.Header.Get(HeaderForwardedFor)
remoteIP, _, err := net.SplitHostPort(request.RemoteAddr) remoteIP, _, err := net.SplitHostPort(request.RemoteAddr)
@ -205,6 +176,18 @@ func (client *Client) doByRequest(manualDo bool, request *http.Request, method,
} }
headers = append(headers, HeaderForwardedFor, xForwardFor) headers = append(headers, HeaderForwardedFor, xForwardFor)
// 处理请求唯一编号
foundID := false
for i := 1; i < len(headers); i += 2 {
if headers[i-1] == HeaderRequestID {
foundID = true
break
}
}
if !foundID {
headers = append(headers, HeaderRequestID, string(encoding.Hex(rand.Bytes(16))))
}
headers = append(headers, settedHeaders...) headers = append(headers, settedHeaders...)
if manualDo { if manualDo {
@ -340,100 +323,97 @@ func (client *Client) Download(filename, url string, callback func(start, end in
return result, err return result, err
} }
func (client *Client) buildMultipart(writer *multipart.Writer, data map[string]any) error { func (client *Client) PostMultipart(url string, formData map[string]string, files map[string]any, headers ...string) (*Result, []error) {
for key, value := range data { errs := make([]error, 0)
if err := client.writeMultipartPart(writer, key, value); err != nil { buf := bufferPool.Get().(*bytes.Buffer)
return err buf.Reset()
} defer bufferPool.Put(buf)
}
return nil
}
func (client *Client) writeMultipartPart(writer *multipart.Writer, key string, value any) error { writer := multipart.NewWriter(buf)
if value == nil {
return nil
}
// 检查是否是文件 if formData != nil {
if filename, ok := value.(string); ok && file.Exists(filename) { for key, value := range formData {
return client.writeMultipartFile(writer, key, filename) if err := writer.WriteField(key, value); err != nil {
} errs = append(errs, err)
switch t := value.(type) {
case []string:
for _, v := range t {
if err := client.writeMultipartPart(writer, key, v); err != nil {
return err
} }
} }
return nil }
case []any:
for _, v := range t { if files != nil {
if err := client.writeMultipartPart(writer, key, v); err != nil { for key, value := range files {
return err if filename, ok := value.(string); ok && file.Exists(filename) {
var reader io.Reader
var closer io.Closer
if mf := file.ReadFileFromMemory(filename); mf != nil {
reader = bytes.NewReader(mf.GetData())
} else {
if fp, err := os.Open(filename); err == nil {
reader = fp
closer = fp
} else {
errs = append(errs, err)
continue
}
}
if part, err := writer.CreateFormFile(key, filepath.Base(filename)); err == nil {
if _, err = io.Copy(part, reader); err != nil {
errs = append(errs, err)
}
} else {
errs = append(errs, err)
}
if closer != nil {
_ = closer.Close()
}
} else {
h := make(textproto.MIMEHeader)
var dataBytes []byte
switch t := value.(type) {
case io.Reader:
dataBytes, _ = io.ReadAll(t)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
h.Set("Content-Type", "application/octet-stream")
case []byte:
dataBytes = t
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
h.Set("Content-Type", "application/octet-stream")
case string:
dataBytes = []byte(t)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s.txt"`, key, key))
h.Set("Content-Type", "text/plain")
default:
dataBytes = cast.As(cast.ToJSONBytes(value))
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s.json"`, key, key))
h.Set("Content-Type", "application/json")
}
if part, err := writer.CreatePart(h); err == nil {
if _, err = part.Write(dataBytes); err != nil {
errs = append(errs, err)
}
} else {
errs = append(errs, err)
}
} }
} }
return nil
case io.Reader:
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
h.Set("Content-Type", "application/octet-stream")
part, err := writer.CreatePart(h)
if err != nil {
return err
}
_, err = io.Copy(part, t)
return err
case []byte:
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
h.Set("Content-Type", "application/octet-stream")
part, err := writer.CreatePart(h)
if err != nil {
return err
}
_, err = part.Write(t)
return err
case string:
return writer.WriteField(key, t)
default:
// 其他复杂类型序列化为 JSON
bytesData, err := cast.ToJSONBytes(value)
if err != nil {
return err
}
part, err := writer.CreateFormField(key)
if err != nil {
return err
}
_, err = part.Write(bytesData)
return err
}
}
func (client *Client) writeMultipartFile(writer *multipart.Writer, key, filename string) error {
var r io.Reader
var closer io.Closer
if mf := file.ReadFileFromMemory(filename); mf != nil {
r = bytes.NewReader(mf.GetData())
} else {
fp, err := os.Open(filename)
if err != nil {
return err
}
r = fp
closer = fp
}
if closer != nil {
defer closer.Close()
} }
part, err := writer.CreateFormFile(key, filepath.Base(filename)) if err := writer.Close(); err != nil {
if err != nil { errs = append(errs, err)
return err
} }
_, err = io.Copy(part, r)
return err if len(errs) > 0 {
return nil, errs
}
headers = append(headers, "Content-Type", writer.FormDataContentType())
result := client.Post(url, buf.Bytes(), headers...)
if result.Error != nil {
errs = append(errs, result.Error)
}
return result, errs
} }
func (client *Client) do(fetchBody bool, method, url string, data any, headers ...string) *Result { func (client *Client) do(fetchBody bool, method, url string, data any, headers ...string) *Result {
@ -453,52 +433,29 @@ func (client *Client) do(fetchBody bool, method, url string, data any, headers .
case string: case string:
reader = strings.NewReader(t) reader = strings.NewReader(t)
contentLength = len(t) contentLength = len(t)
case url2.Values, map[string][]string, Form: case url2.Values:
var values url2.Values encoded := t.Encode()
switch v := t.(type) { reader = strings.NewReader(encoded)
case url2.Values: contentType = "application/x-www-form-urlencoded"
values = v contentLength = len(encoded)
case map[string][]string: case map[string][]string:
values = v values := url2.Values(t)
case Form: encoded := values.Encode()
values = url2.Values{} reader = strings.NewReader(encoded)
for k, v1 := range v { contentType = "application/x-www-form-urlencoded"
values.Set(k, v1) contentLength = len(encoded)
} case map[string]string:
values := url2.Values{}
for k, v := range t {
values.Set(k, v)
} }
encoded := values.Encode() encoded := values.Encode()
reader = strings.NewReader(encoded) reader = strings.NewReader(encoded)
contentType = "application/x-www-form-urlencoded" contentType = "application/x-www-form-urlencoded"
contentLength = len(encoded) contentLength = len(encoded)
case Multipart, map[string][]any:
var mData map[string]any
if m, ok := t.(Multipart); ok {
mData = m
} else {
m := t.(map[string][]any)
mData = make(map[string]any, len(m))
for k, v := range m {
mData[k] = v
}
}
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
contentType = writer.FormDataContentType()
reader = pr
go func() {
err := client.buildMultipart(writer, mData)
if err == nil {
err = writer.Close()
}
if err != nil {
_ = pw.CloseWithError(err)
} else {
_ = pw.Close()
}
}()
default: default:
bytesData, _ := cast.ToJSONBytes(data) bytesData, _ := cast.ToJSONBytes(data)
if len(bytesData) > 0 && !bytes.Equal(bytesData, []byte("null")) { if len(bytesData) > 0 && string(bytesData) != "null" {
reader = bytes.NewReader(bytesData) reader = bytes.NewReader(bytesData)
contentType = "application/json" contentType = "application/json"
contentLength = len(bytesData) contentLength = len(bytesData)
@ -601,10 +558,19 @@ func (result *Result) Slice() []any {
} }
func (result *Result) To(v any) error { func (result *Result) To(v any) error {
if len(result.data) == 0 { if result.data == nil {
return errors.New("no data") return errors.New("no data")
} }
return cast.UnmarshalJSON(result.data, v) err := cast.UnmarshalJSON(result.data, v)
if err != nil {
// 如果 cast 直接解不出来,尝试通过 convert 做深度映射(处理 struct 字段匹配等)
var tmp any
if err2 := cast.UnmarshalJSON(result.data, &tmp); err2 == nil {
cast.Convert(v, tmp)
return nil
}
}
return err
} }
// To 使用泛型获取结果 // To 使用泛型获取结果

View File

@ -44,7 +44,7 @@ func TestLocalServer(t *testing.T) {
_, _ = w.Write([]byte("world")) _, _ = w.Write([]byte("world"))
}) })
server := &http.Server{Addr: "127.0.0.1:18080", Handler: handler} server := &http.Server{Addr: ":18080", Handler: handler}
go func() { _ = server.ListenAndServe() }() go func() { _ = server.ListenAndServe() }()
defer server.Close() defer server.Close()
@ -102,7 +102,7 @@ func BenchmarkGet(b *testing.B) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
}) })
server := &http.Server{Addr: "127.0.0.1:18082", Handler: handler} server := &http.Server{Addr: ":18082", Handler: handler}
go func() { _ = server.ListenAndServe() }() go func() { _ = server.ListenAndServe() }()
defer server.Close() defer server.Close()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
@ -121,7 +121,7 @@ func TestManualDo(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("stream")) _, _ = w.Write([]byte("stream"))
}) })
server := &http.Server{Addr: "127.0.0.1:18083", Handler: handler} server := &http.Server{Addr: ":18083", Handler: handler}
go func() { _ = server.ListenAndServe() }() go func() { _ = server.ListenAndServe() }()
defer server.Close() defer server.Close()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
@ -157,7 +157,7 @@ func TestDownload(t *testing.T) {
} }
_, _ = w.Write([]byte(content)) _, _ = w.Write([]byte(content))
}) })
server := &http.Server{Addr: "127.0.0.1:18084", Handler: handler} server := &http.Server{Addr: ":18084", Handler: handler}
go func() { _ = server.ListenAndServe() }() go func() { _ = server.ListenAndServe() }()
defer server.Close() defer server.Close()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
@ -178,7 +178,7 @@ func TestDownload(t *testing.T) {
} }
} }
func TestMultipartDo(t *testing.T) { func TestMPost(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseMultipartForm(10 << 20) _ = r.ParseMultipartForm(10 << 20)
f := r.FormValue("foo") f := r.FormValue("foo")
@ -189,43 +189,17 @@ func TestMultipartDo(t *testing.T) {
} }
fmt.Fprintf(w, "foo=%s,file=%s", f, string(fileContent)) fmt.Fprintf(w, "foo=%s,file=%s", f, string(fileContent))
}) })
server := &http.Server{Addr: "127.0.0.1:18086", Handler: handler} server := &http.Server{Addr: ":18085", Handler: handler}
go func() { _ = server.ListenAndServe() }() go func() { _ = server.ListenAndServe() }()
defer server.Close() defer server.Close()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
c := ah.NewClient(time.Second) c := ah.NewClient(time.Second)
r := c.Post("http://127.0.0.1:18086/", ah.Multipart{"foo": "bar", "file": []byte("baz")}) r, errs := c.PostMultipart("http://127.0.0.1:18085/", map[string]string{"foo": "bar"}, map[string]any{"file": []byte("baz")})
if r.Error != nil { if len(errs) > 0 {
t.Fatalf("Post with Multipart failed: %v", r.Error) t.Fatalf("PostMultipart failed: %v", errs)
} }
if r.String() != "foo=bar,file=baz" { if r.String() != "foo=bar,file=baz" {
t.Errorf("expected foo=bar,file=baz, got %s", r.String()) t.Errorf("expected foo=bar,file=baz, got %s", r.String())
} }
} }
func TestFormAndMap(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
fmt.Fprintf(w, "ct=%s,body=%s", ct, string(body))
})
server := &http.Server{Addr: "127.0.0.1:18087", Handler: handler}
go func() { _ = server.ListenAndServe() }()
defer server.Close()
time.Sleep(100 * time.Millisecond)
c := ah.NewClient(time.Second)
// Test Form (urlencoded)
r1 := c.Post("http://127.0.0.1:18087/", ah.Form{"foo": "bar"})
if r1.String() != "ct=application/x-www-form-urlencoded,body=foo=bar" {
t.Errorf("Form failed, got: %s", r1.String())
}
// Test map[string]string (JSON)
r2 := c.Post("http://127.0.0.1:18087/", map[string]string{"foo": "bar"})
if r2.String() != `ct=application/json,body={"foo":"bar"}` {
t.Errorf("map[string]string failed, got: %s", r2.String())
}
}

30
go.mod
View File

@ -3,21 +3,25 @@ module apigo.cc/go/http
go 1.25.0 go 1.25.0
require ( require (
apigo.cc/go/cast v1.3.3 apigo.cc/go/cast v1.2.6
apigo.cc/go/encoding v1.3.1 apigo.cc/go/encoding v1.0.4
apigo.cc/go/file v1.3.2 apigo.cc/go/file v1.0.5
apigo.cc/go/log v1.3.4 apigo.cc/go/log v1.1.1
apigo.cc/go/rand v1.3.1 apigo.cc/go/rand v1.0.4
golang.org/x/net v0.54.0 golang.org/x/net v0.53.0
) )
require apigo.cc/go/convert v1.0.4 // indirect
require ( require (
apigo.cc/go/config v1.3.1 // indirect apigo.cc/go/config v1.0.5 // indirect
apigo.cc/go/id v1.3.1 // indirect apigo.cc/go/safe v1.0.4 // indirect
apigo.cc/go/safe v1.3.1 // indirect apigo.cc/go/shell v1.0.4 // indirect
apigo.cc/go/shell v1.3.1 // indirect github.com/kr/pretty v0.3.0 // indirect
golang.org/x/crypto v0.51.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

60
go.sum
View File

@ -1,37 +1,45 @@
apigo.cc/go/cast v1.3.3 h1:aln5eDR5DZVWVzZ/y5SJh1gQNgWv2sT82I25NaO9g34= apigo.cc/go/cast v1.2.6 h1:xnWiaQAGsRCrnu1p8fIFQfg5HFSc7CxR+3ItiDIDMaY=
apigo.cc/go/cast v1.3.3/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk= apigo.cc/go/cast v1.2.6/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
apigo.cc/go/config v1.3.1 h1:wZzUh4oL+fGD6SayVgX6prLPMsniM25etWFcEH8XzIE= apigo.cc/go/config v1.0.4 h1:WG9zrQkqfFPkrKIL7RNvvAbbkuUBt1Av11ZP/aIfldM=
apigo.cc/go/config v1.3.1/go.mod h1:7KHz/1WmtBLM762Lln/TaXh2dmlMvJTLhnlk33zbS3U= apigo.cc/go/config v1.0.4/go.mod h1:obryzJiK6j7lQex/58d5eWYOGx5O5IABguqNWxyyXJo=
apigo.cc/go/encoding v1.3.1 h1:y8O58KYAyulkThg1O2ji2BqjnFoSvk42sit9I3z+K7Y= apigo.cc/go/convert v1.0.4 h1:5+qPjC3dlPB59GnWZRlmthxcaXQtKvN+iOuiLdJ1GvQ=
apigo.cc/go/encoding v1.3.1/go.mod h1:xAJk5b83VZ31mXMTnyp0dfMoBKfT/AHDn0u+cQfojgY= apigo.cc/go/convert v1.0.4/go.mod h1:Hp+geeSyhqg/zwIKPOrDoceIREzcwM14t1I5q/dtbfU=
apigo.cc/go/file v1.3.2 h1:pu4oiDyiqgj3/eykfnJf+/6+A9v/Z0b3ClP5XK+lwG4= apigo.cc/go/encoding v1.0.4 h1:aezB0J/qFuHs6iXkbtuJP5JIHUtmjsr5SFb0NNvbObY=
apigo.cc/go/file v1.3.2/go.mod h1:vci4h0Pz94mV6dkniQkuyBYERVYeq7/LX4jJVuCg9hs= apigo.cc/go/encoding v1.0.4/go.mod h1:V5CgT7rBbCxy+uCU20q0ptcNNRSgMtpA8cNOs6r8IeI=
apigo.cc/go/id v1.3.1 h1:pkqi6VeWyQoHuIu0Zbx/RRxIAdM61Js0j6cY1M9XVCk= apigo.cc/go/file v1.0.4 h1:qCKegV7OYh7r0qc3jZjGA/aKh0vIHgmr1OEbhfEmGX8=
apigo.cc/go/id v1.3.1/go.mod h1:P2/vl3tyW3US+ayOFSMoPIOCulNLBngNYPhXJC/Z7J4= apigo.cc/go/file v1.0.4/go.mod h1:C9gNo7386iA21OiBmuWh6CznKWlVBDFkhE4f0H0Susg=
apigo.cc/go/log v1.3.4 h1:UT8Neb9r4QjjbCFbTzw+ZeTxd+DmdmR5gNExeR4Cj+g= apigo.cc/go/log v1.0.2 h1:OY6T3SC28blDNkMpdRvDK2N4sGdriAB9DBItGl/qOos=
apigo.cc/go/log v1.3.4/go.mod h1:/Q/2r51xWSsrS4QN5U9jLiTw8n6qNC8kG9nuVHweY20= apigo.cc/go/log v1.0.2/go.mod h1:tvPgFpebY9Wf/DlqMHZ0ZjxDp9AaQTywOQKvtBaNqNo=
apigo.cc/go/rand v1.3.1 h1:7FvsI6PtQ5XrWER0dTiLVo0p7GIxRidT/TBKhVy93j8= apigo.cc/go/rand v1.0.4 h1:we070eWSL0dB8NEMaWjXj43+EekXQTm/h0kKpZ/frqw=
apigo.cc/go/rand v1.3.1/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk= apigo.cc/go/rand v1.0.4/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
apigo.cc/go/safe v1.3.1 h1:irTCqPAC97gGsX/Lw5AzLelDt1xXLEZIAaVhLELWe9Q= apigo.cc/go/safe v1.0.4 h1:07pRSdEHprF/2v6SsqAjICYFoeLcqjjvHGEdh6Dzrzg=
apigo.cc/go/safe v1.3.1/go.mod h1:XdOpBhN2vkImalaykYXXmEpczqWa1y3ah6/Q72cdRqE= apigo.cc/go/safe v1.0.4/go.mod h1:o568sHS5rTRSVPmhxWod0tGdc+8l1KjidsNY1/OVZr0=
apigo.cc/go/shell v1.3.1 h1:M8oD0b2HcJuCC6frQFx11b3UTcTx3lATX8XK+YXSVm8= apigo.cc/go/shell v1.0.4 h1:EL9zjI39YBe1h+kRYQeAi/8zVGHe5W198DYYN7cENiY=
apigo.cc/go/shell v1.3.1/go.mod h1:ZMdJjpCpWdvsHKUXlelh/AxsV/nWdkH/k3lISfzMdUw= apigo.cc/go/shell v1.0.4/go.mod h1:N2gDkgK4tJ9TadD60/+gAGuWxyVAWHs5YPBmytw6ELA=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,104 +0,0 @@
package http_test
import (
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
ah "apigo.cc/go/http"
)
func TestOptimizationForms(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
_ = r.ParseMultipartForm(10 << 20)
} else {
_ = r.ParseForm()
}
fmt.Fprintf(w, "foo=%v", r.Form["foo"])
})
server := &http.Server{Addr: "127.0.0.1:18088", Handler: handler}
go func() { _ = server.ListenAndServe() }()
defer server.Close()
time.Sleep(100 * time.Millisecond)
c := ah.NewClient(time.Second)
// Test map[string][]string
r1 := c.Post("http://127.0.0.1:18088/", map[string][]string{"foo": {"bar", "baz"}})
if r1.Error != nil {
t.Fatalf("map[string][]string failed: %v", r1.Error)
}
if r1.String() != "foo=[bar baz]" {
t.Errorf("expected foo=[bar baz], got %s", r1.String())
}
// Test map[string][]any (now Multipart)
r2 := c.Post("http://127.0.0.1:18088/", map[string][]any{"foo": {"bar", 123}})
if r2.Error != nil {
t.Fatalf("map[string][]any failed: %v", r2.Error)
}
// Multipart output might look different depending on how the server parses it,
// but r.Form["foo"] should still work if it's multipart.
if r2.String() != "foo=[bar 123]" {
t.Errorf("expected foo=[bar 123], got %s", r2.String())
}
}
func TestMultipartStreaming(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20)
if err != nil {
t.Errorf("ParseMultipartForm failed: %v", err)
}
f1 := r.FormValue("foo")
file, _, err := r.FormFile("file")
var fileContent []byte
if file != nil {
fileContent, _ = io.ReadAll(file)
}
fmt.Fprintf(w, "foo=%s,file=%s", f1, string(fileContent))
})
server := &http.Server{Addr: "127.0.0.1:18089", Handler: handler}
go func() { _ = server.ListenAndServe() }()
defer server.Close()
time.Sleep(100 * time.Millisecond)
c := ah.NewClient(time.Second)
// Test streaming Multipart
r := c.Post("http://127.0.0.1:18089/", ah.Multipart{
"foo": "bar",
"file": io.NopCloser(strings.NewReader("baz")), // use Reader to force streaming
})
if r.Error != nil {
t.Fatalf("Post with Multipart streaming failed: %v", r.Error)
}
if r.String() != "foo=bar,file=baz" {
t.Errorf("expected foo=bar,file=baz, got %s", r.String())
}
}
func TestMultipartMultipleParts(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseMultipartForm(10 << 20)
fmt.Fprintf(w, "foo=%v", r.MultipartForm.Value["foo"])
})
server := &http.Server{Addr: "127.0.0.1:18090", Handler: handler}
go func() { _ = server.ListenAndServe() }()
defer server.Close()
time.Sleep(100 * time.Millisecond)
c := ah.NewClient(time.Second)
r := c.Post("http://127.0.0.1:18090/", ah.Multipart{
"foo": []string{"bar", "baz"},
})
if r.Error != nil {
t.Fatalf("Post with Multipart multiple parts failed: %v", r.Error)
}
if r.String() != "foo=[bar baz]" {
t.Errorf("expected foo=[bar baz], got %s", r.String())
}
}