├── testdata ├── testfile3.txt ├── testfile1.txt ├── testfile2.txt └── root-ca.pem ├── .codecov.yml ├── go.mod ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── codecov.yml ├── transport_go1.12.go ├── transport_go1.13.go ├── transport_go1.9-1.11.go ├── debug.go ├── dump_test.go ├── rate.go ├── debug_test.go ├── LICENSE ├── multipart_test.go ├── rate_test.go ├── backoff_test.go ├── ghttp_test.go ├── README.md ├── retry.go ├── response.go ├── dump.go ├── retry_test.go ├── ghttp.go ├── backoff.go ├── util_test.go ├── util.go ├── go.sum ├── response_test.go ├── trace.go ├── multipart.go ├── client_test.go ├── request_test.go ├── client.go ├── request.go └── example_test.go /testdata/testfile3.txt: -------------------------------------------------------------------------------- 1 | hello world -------------------------------------------------------------------------------- /testdata/testfile1.txt: -------------------------------------------------------------------------------- 1 | testfile1.txt -------------------------------------------------------------------------------- /testdata/testfile2.txt: -------------------------------------------------------------------------------- 1 | testfile2.txt -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: 80..100 4 | round: down 5 | 6 | status: 7 | project: 8 | default: 9 | target: 95% 10 | if_not_found: success 11 | if_ci_failed: error 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/winterssy/ghttp 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/stretchr/testify v1.4.0 7 | github.com/winterssy/bufferpool v0.0.0-20200229012952-527e7777fcd3 8 | github.com/winterssy/gjson v0.0.0-20200306020332-1f68efaec187 9 | golang.org/x/net v0.0.0-20191009170851-d66e71096ffb 10 | golang.org/x/text v0.3.0 11 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 12 | ) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Go template 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | .idea 20 | coverage.txt 21 | testdata.txt 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test 6 | strategy: 7 | matrix: 8 | go: [1.12.x, 1.13.x] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - name: Set up Go 13 | uses: actions/setup-go@v1 14 | with: 15 | go-version: ${{ matrix.go }} 16 | 17 | - name: Checkout code 18 | uses: actions/checkout@v1 19 | 20 | - name: Get dependencies 21 | run: go mod tidy 22 | 23 | - name: Test 24 | run: go test -v . 25 | -------------------------------------------------------------------------------- /transport_go1.12.go: -------------------------------------------------------------------------------- 1 | // +build go1.12 2 | // +build !go1.13 3 | 4 | package ghttp 5 | 6 | import ( 7 | "net" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | // DefaultTransport returns a preset HTTP transport. 13 | // It's a clone of http.DefaultTransport indeed. 14 | func DefaultTransport() *http.Transport { 15 | return &http.Transport{ 16 | Proxy: ProxyFromEnvironment, 17 | DialContext: (&net.Dialer{ 18 | Timeout: 30 * time.Second, 19 | KeepAlive: 30 * time.Second, 20 | }).DialContext, 21 | MaxIdleConns: 100, 22 | IdleConnTimeout: 90 * time.Second, 23 | TLSHandshakeTimeout: 10 * time.Second, 24 | ExpectContinueTimeout: 1 * time.Second, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /transport_go1.13.go: -------------------------------------------------------------------------------- 1 | // +build go1.13 2 | 3 | package ghttp 4 | 5 | import ( 6 | "net" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // DefaultTransport returns a preset HTTP transport. 12 | // It's a clone of http.DefaultTransport indeed. 13 | func DefaultTransport() *http.Transport { 14 | return &http.Transport{ 15 | Proxy: ProxyFromEnvironment, 16 | DialContext: (&net.Dialer{ 17 | Timeout: 30 * time.Second, 18 | KeepAlive: 30 * time.Second, 19 | }).DialContext, 20 | ForceAttemptHTTP2: true, 21 | MaxIdleConns: 100, 22 | IdleConnTimeout: 90 * time.Second, 23 | TLSHandshakeTimeout: 10 * time.Second, 24 | ExpectContinueTimeout: 1 * time.Second, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /transport_go1.9-1.11.go: -------------------------------------------------------------------------------- 1 | // +build go1.9 2 | // +build !go1.12 3 | 4 | package ghttp 5 | 6 | import ( 7 | "net" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | // DefaultTransport returns a preset HTTP transport. 13 | // It's a clone of http.DefaultTransport indeed. 14 | func DefaultTransport() *http.Transport { 15 | return &http.Transport{ 16 | Proxy: ProxyFromEnvironment, 17 | DialContext: (&net.Dialer{ 18 | Timeout: 30 * time.Second, 19 | KeepAlive: 30 * time.Second, 20 | DualStack: true, 21 | }).DialContext, 22 | MaxIdleConns: 100, 23 | IdleConnTimeout: 90 * time.Second, 24 | TLSHandshakeTimeout: 10 * time.Second, 25 | ExpectContinueTimeout: 1 * time.Second, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type ( 9 | debugger struct { 10 | out io.Writer 11 | body bool 12 | } 13 | ) 14 | 15 | // Enter implements BeforeRequestCallback interface. 16 | func (d *debugger) Enter(req *Request) error { 17 | err := dumpRequest(req, d.out, d.body) 18 | if err != nil { 19 | fmt.Fprintf(d.out, "* ghttp [ERROR] %s\r\n", err.Error()) 20 | } 21 | return err 22 | } 23 | 24 | // Exit implements AfterResponseCallback interface. 25 | func (d *debugger) Exit(resp *Response, err error) { 26 | if err == nil { 27 | err = dumpResponse(resp, d.out, d.body) 28 | } 29 | if err != nil { 30 | fmt.Fprintf(d.out, "* ghttp [ERROR] %s\r\n", err.Error()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dump_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "net/http" 5 | neturl "net/url" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDumpRequestHeaders(t *testing.T) { 13 | dummyRequest := &Request{Request: &http.Request{URL: &neturl.URL{ 14 | Scheme: "https", 15 | Host: "httpbin.org", 16 | Path: "/get", 17 | }}} 18 | dummyRequest.TransferEncoding = []string{"chunked"} 19 | dummyRequest.Close = true 20 | 21 | var sb strings.Builder 22 | dumpRequestHeaders(dummyRequest, &sb) 23 | want := "" + 24 | "> Host: httpbin.org\r\n" + 25 | "> Transfer-Encoding: chunked\r\n" + 26 | "> Connection: close\r\n" + 27 | ">\r\n" 28 | assert.Equal(t, want, sb.String()) 29 | } 30 | -------------------------------------------------------------------------------- /rate.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import "golang.org/x/time/rate" 4 | 5 | type ( 6 | rateLimiter struct { 7 | base *rate.Limiter 8 | } 9 | 10 | concurrency struct { 11 | ch chan struct{} 12 | } 13 | ) 14 | 15 | // Enter implements BeforeRequestCallback interface. 16 | func (rl *rateLimiter) Enter(req *Request) error { 17 | return rl.base.Wait(req.Context()) 18 | } 19 | 20 | // Enter implements BeforeRequestCallback interface. 21 | func (c *concurrency) Enter(req *Request) error { 22 | select { 23 | case <-req.Context().Done(): 24 | return req.Context().Err() 25 | case c.ch <- struct{}{}: 26 | return nil 27 | } 28 | } 29 | 30 | // Exit implements AfterResponseCallback interface. 31 | func (c *concurrency) Exit(*Response, error) { 32 | <-c.ch 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: CodeCov 2 | on: [push, pull_request] 3 | jobs: 4 | coverage: 5 | name: Coverage 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set up Go 9 | uses: actions/setup-go@v1 10 | with: 11 | go-version: 1.13.x 12 | 13 | - name: Checkout code 14 | uses: actions/checkout@v1 15 | 16 | - name: Get dependencies 17 | run: go mod tidy 18 | 19 | - name: Test 20 | run: go test -v . -race -coverprofile=coverage.txt -covermode=atomic 21 | 22 | - name: Upload coverage to CodeCov 23 | uses: codecov/codecov-action@v1 24 | with: 25 | token: ${{secrets.CODECOV_TOKEN}} 26 | file: ./coverage.txt 27 | yml: ./.codecov.yml 28 | -------------------------------------------------------------------------------- /debug_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | neturl "net/url" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestDebugger_Enter(t *testing.T) { 15 | dummyRequest := &Request{ 16 | Request: &http.Request{ 17 | Method: MethodPost, 18 | URL: &neturl.URL{ 19 | Scheme: "https", 20 | Host: "httpbin.org", 21 | Path: "/post", 22 | }, 23 | Proto: "HTTP/1.1", 24 | ProtoMajor: 1, 25 | ProtoMinor: 1, 26 | Header: make(http.Header), 27 | Body: &dummyBody{errFlag: errRead}, 28 | Host: "httpbin.org", 29 | }, 30 | } 31 | debugger := &debugger{out: ioutil.Discard, body: true} 32 | err := debugger.Enter(dummyRequest) 33 | assert.Equal(t, errAccessDummyBody, err) 34 | } 35 | 36 | func TestDebugger_Exit(t *testing.T) { 37 | var sb strings.Builder 38 | debugger := &debugger{out: &sb, body: true} 39 | var dummyResponse *Response 40 | debugger.Exit(dummyResponse, errAccessDummyBody) 41 | assert.Equal(t, fmt.Sprintf("* ghttp [ERROR] %s\r\n", errAccessDummyBody), sb.String()) 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 winterssy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /testdata/root-ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDMzCCAhugAwIBAgIJAIgxRFfVTNZjMA0GCSqGSIb3DQEBCwUAMDAxCzAJBgNV 3 | BAYTAkNOMRIwEAYDVQQIDAlHdWFuZ2RvbmcxDTALBgNVBAoMBHNyZXEwHhcNMTkx 4 | MTI4MDc1MzE0WhcNMjkxMTI1MDc1MzE0WjAwMQswCQYDVQQGEwJDTjESMBAGA1UE 5 | CAwJR3Vhbmdkb25nMQ0wCwYDVQQKDARzcmVxMIIBIjANBgkqhkiG9w0BAQEFAAOC 6 | AQ8AMIIBCgKCAQEAnepnVEMP+zYcaLxK+ttbSzfTCs6E3O7k62kQ46y/dvLVfEV+ 7 | wfxqozXlTg24q/yA92tVNrwh8gUSeDkv5W6BEmIegi0SQpZpShTTOMrplCAlT/XB 8 | 2L13ZYRsK5gPBnElJCKCz+Y8pVk3I0u3/n9MNxzuDpKigOwCf/i7+luuwSFK9Ir6 9 | ssL6LirtcsqXQgkCKyZAMM18tHFDnGyN4dlgitrhl43h9To3kTTwqbRmT4oxurss 10 | YfGmzB6lEYZWzBk1D4PlU2nhKruINOhi0rl3hQFq+fpJ4oAOI/M6+kdZiooiktNc 11 | ln27VMKNEHbCleoTLWHcVNsKjNOVdgYVOQPXrQIDAQABo1AwTjAdBgNVHQ4EFgQU 12 | mXMVqOdg9piqYvLeQ+V5FCBf42EwHwYDVR0jBBgwFoAUmXMVqOdg9piqYvLeQ+V5 13 | FCBf42EwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAe7/z8wCK8XCH 14 | 6/ez/lhd1VuqCb89ZrYx7M5PV/TYRQle7G6ZbA5taIeHM2PhysbzVND1hyTDz7Gg 15 | eVncBqhla547aEWQ9YvoZA6wr5cZLwrPB4lp99I7vqXl0cOqtsgznqUf+bGUYIZY 16 | yLbafPasiQ7Vk4JQXeLM7VGkXfsn0Kl1mXqE0uPW+87PXrvEj+J/Rj57bXyyL3S1 17 | rMfGylN8W2XLTPmZLvgGbGer6P4pX2MgwNkJS9+ciz7YfsMOiOlg+f/4rq5qCD9d 18 | CfxJ/PITf6GLdtDnCCYQVnf1FavPiNq8nfpzk12hcUyr5M5gCY3TdF5NyCilHakA 19 | N+tN5QcsJw== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /multipart_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestFile_Read(t *testing.T) { 13 | const ( 14 | msg = "hello world" 15 | ) 16 | 17 | file := FileFromReader(strings.NewReader(msg)).WithFilename("testfile.txt") 18 | n, err := io.Copy(ioutil.Discard, file) 19 | if assert.NoError(t, err) { 20 | assert.Equal(t, int64(len(msg)), n) 21 | } 22 | } 23 | 24 | func TestOpen(t *testing.T) { 25 | const ( 26 | fileExist = "./testdata/testfile1.txt" 27 | fileNotExist = "./testdata/file_not_exist.txt" 28 | ) 29 | 30 | f, err := Open(fileExist) 31 | if assert.NoError(t, err) { 32 | assert.NoError(t, f.Close()) 33 | } 34 | 35 | _, err = Open(fileNotExist) 36 | assert.Error(t, err) 37 | } 38 | 39 | func TestMustOpen(t *testing.T) { 40 | const ( 41 | fileExist = "./testdata/testfile1.txt" 42 | fileNotExist = "./testdata/file_not_exist.txt" 43 | mime = "text/plain; charset=utf-8" 44 | ) 45 | 46 | var f *File 47 | if assert.NotPanics(t, func() { 48 | f = MustOpen(fileExist).WithMIME(mime) 49 | }) { 50 | assert.Equal(t, "testfile1.txt", f.filename) 51 | assert.Equal(t, mime, f.mime) 52 | } 53 | 54 | assert.Panics(t, func() { 55 | f = MustOpen(fileNotExist) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /rate_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | neturl "net/url" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "golang.org/x/time/rate" 11 | ) 12 | 13 | func TestRateLimiter_Enter(t *testing.T) { 14 | dummyRequest := &Request{Request: &http.Request{ 15 | URL: &neturl.URL{ 16 | Scheme: "https", 17 | Host: "httpbin.org", 18 | Path: "/get", 19 | }, 20 | Header: make(http.Header), 21 | }} 22 | 23 | rl := &rateLimiter{base: rate.NewLimiter(1, 10)} 24 | err := rl.Enter(dummyRequest) 25 | assert.NoError(t, err) 26 | 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | dummyRequest.SetContext(ctx) 29 | cancel() 30 | err = rl.Enter(dummyRequest) 31 | assert.Equal(t, ctx.Err(), err) 32 | } 33 | 34 | func TestConcurrency_Enter(t *testing.T) { 35 | dummyRequest := &Request{Request: &http.Request{ 36 | URL: &neturl.URL{ 37 | Scheme: "https", 38 | Host: "httpbin.org", 39 | Path: "/get", 40 | }, 41 | Header: make(http.Header), 42 | }} 43 | 44 | c := &concurrency{ch: make(chan struct{}, 1)} 45 | err := c.Enter(dummyRequest) 46 | assert.NoError(t, err) 47 | 48 | ctx, cancel := context.WithCancel(context.Background()) 49 | dummyRequest.SetContext(ctx) 50 | cancel() 51 | err = c.Enter(dummyRequest) 52 | assert.Equal(t, ctx.Err(), err) 53 | } 54 | -------------------------------------------------------------------------------- /backoff_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestConstantBackoff_Wait(t *testing.T) { 11 | const ( 12 | interval = 100 * time.Millisecond 13 | ) 14 | 15 | backoff := NewConstantBackoff(interval, false) 16 | for i := 0; i < 10; i++ { 17 | assert.Equal(t, interval, backoff.Wait(i, nil, nil)) 18 | } 19 | 20 | backoff = NewConstantBackoff(interval, true) 21 | for i := 0; i < 10; i++ { 22 | assert.True(t, backoff.Wait(i, nil, nil) >= interval/2) 23 | assert.True(t, backoff.Wait(i, nil, nil) <= (interval/2+interval)) 24 | } 25 | } 26 | 27 | func TestExponentialBackoff_Wait(t *testing.T) { 28 | const ( 29 | baseInterval = 1 * time.Second 30 | maxInterval = 30 * time.Second 31 | ) 32 | 33 | backoff := NewExponentialBackoff(baseInterval, maxInterval, false) 34 | for i := 0; i < 10; i++ { 35 | assert.True(t, backoff.Wait(i, nil, nil) >= baseInterval/2) 36 | assert.True(t, backoff.Wait(i, nil, nil) <= maxInterval) 37 | } 38 | 39 | backoff = NewExponentialBackoff(baseInterval, maxInterval, true) 40 | for i := 0; i < 10; i++ { 41 | assert.True(t, backoff.Wait(i, nil, nil) >= baseInterval/2) 42 | assert.True(t, backoff.Wait(i, nil, nil) <= maxInterval) 43 | } 44 | } 45 | 46 | func TestFibonacciBackoff_Wait(t *testing.T) { 47 | nums := []time.Duration{1, 1, 2, 3, 5, 8, 13, 21, 34, 55} 48 | backoff := NewFibonacciBackoff(0, time.Second) 49 | for i, v := range nums { 50 | assert.True(t, backoff.Wait(i, nil, nil) == v*time.Second) 51 | } 52 | 53 | nums = []time.Duration{1, 1, 2, 3, 5, 8, 13, 21, 30, 30} 54 | backoff = NewFibonacciBackoff(30, time.Second) 55 | for i, v := range nums { 56 | assert.True(t, backoff.Wait(i, nil, nil) == v*time.Second) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ghttp_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/winterssy/gjson" 9 | ) 10 | 11 | type ( 12 | postmanResponse struct { 13 | Args H `json:"args,omitempty"` 14 | Authenticated bool `json:"authenticated,omitempty"` 15 | Cookies H `json:"cookies,omitempty"` 16 | Data string `json:"data,omitempty"` 17 | Files H `json:"files,omitempty"` 18 | Form H `json:"form,omitempty"` 19 | Headers H `json:"headers,omitempty"` 20 | JSON H `json:"json,omitempty"` 21 | Method string `json:"method,omitempty"` 22 | Origin string `json:"origin,omitempty"` 23 | Token string `json:"token,omitempty"` 24 | URL string `json:"url,omitempty"` 25 | User string `json:"user,omitempty"` 26 | } 27 | ) 28 | 29 | func TestKV_Decode(t *testing.T) { 30 | var ( 31 | v1 = "hello world" 32 | v2 = []string{"hello", "world"} 33 | ) 34 | v := KV{ 35 | "k1": v1, 36 | "k2": v2, 37 | "invalid": 1 + 2i, 38 | } 39 | vv := v.Decode() 40 | assert.Len(t, vv, 2) 41 | assert.Equal(t, []string{v1}, vv["k1"]) 42 | assert.Equal(t, v2, vv["k2"]) 43 | } 44 | 45 | func TestKV_EncodeToURL(t *testing.T) { 46 | var ( 47 | v1 = "hello" 48 | v2 = []string{"hello", "hi"} 49 | ) 50 | v := KV{ 51 | "expr": "1+2", 52 | "k1": v1, 53 | "k2": v2, 54 | } 55 | want := "expr=1%2B2&k1=hello&k2=hello&k2=hi" 56 | assert.Equal(t, want, v.EncodeToURL(true)) 57 | 58 | want = "expr=1+2&k1=hello&k2=hello&k2=hi" 59 | assert.Equal(t, want, v.EncodeToURL(false)) 60 | } 61 | 62 | func TestKV_EncodeToJSON(t *testing.T) { 63 | v := KV{ 64 | "text": "

Hello World

", 65 | } 66 | want := "{\"text\":\"

Hello World

\"}" 67 | assert.Equal(t, want, v.EncodeToJSON(func(enc *gjson.Encoder) { 68 | enc.SetEscapeHTML(false) 69 | })) 70 | 71 | v = KV{ 72 | "num": math.Inf(1), 73 | } 74 | assert.Equal(t, "{}", v.EncodeToJSON()) 75 | } 76 | 77 | func TestCookies_Decode(t *testing.T) { 78 | c := Cookies{ 79 | "n1": "v1", 80 | "n2": "v2", 81 | } 82 | assert.Len(t, c.Decode(), 2) 83 | } 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghttp 2 | 3 | **[ghttp](https://pkg.go.dev/github.com/winterssy/ghttp)** is a simple, user-friendly and concurrent safe HTTP request library for Go. 4 | 5 | ![Build](https://img.shields.io/github/workflow/status/winterssy/ghttp/Test/master?logo=appveyor) [![codecov](https://codecov.io/gh/winterssy/ghttp/branch/master/graph/badge.svg)](https://codecov.io/gh/winterssy/ghttp) [![Go Report Card](https://goreportcard.com/badge/github.com/winterssy/ghttp)](https://goreportcard.com/report/github.com/winterssy/ghttp) [![GoDoc](https://img.shields.io/badge/godoc-reference-5875b0)](https://pkg.go.dev/github.com/winterssy/ghttp) [![License](https://img.shields.io/github/license/winterssy/ghttp.svg)](LICENSE) 6 | 7 | ## Features 8 | 9 | `ghttp` wraps `net/http` and provides convenient APIs and advanced features to simplify your jobs. 10 | 11 | - Requests-style APIs. 12 | - GET, POST, PUT, PATCH, DELETE, etc. 13 | - Easy set query params, headers and cookies. 14 | - Easy send form, JSON or multipart payload. 15 | - Automatic cookies management. 16 | - Backoff retry mechanism. 17 | - Before request and after response callbacks. 18 | - Rate limiting for outbound requests. 19 | - Easy decode the response body to bytes, string or unmarshal the JSON-encoded data. 20 | - Friendly debugging. 21 | - Concurrent safe. 22 | 23 | ## Install 24 | 25 | ```sh 26 | go get -u github.com/winterssy/ghttp 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```go 32 | import "github.com/winterssy/ghttp" 33 | ``` 34 | 35 | ## Quick Start 36 | 37 | The usages of `ghttp` are very similar to `net/http` . 38 | 39 | - `ghttp.Client` 40 | 41 | ```go 42 | client := ghttp.New() 43 | // Now you can manipulate client like net/http 44 | client.CheckRedirect = ghttp.NoRedirect 45 | client.Timeout = 300 * time.Second 46 | ``` 47 | 48 | - `ghttp.Request` 49 | 50 | ```go 51 | req, err := ghttp.NewRequest("GET", "https://httpbin.org/get") 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | // Now you can manipulate req like net/http 56 | req.Close = true 57 | ``` 58 | 59 | - `ghttp.Response` 60 | 61 | ```go 62 | resp, err := ghttp.Get("https://www.google.com") 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | // Now you can access resp like net/http 67 | fmt.Println(resp.StatusCode) 68 | ``` 69 | 70 | Documentation is available at **[go.dev](https://pkg.go.dev/github.com/winterssy/ghttp)** . 71 | 72 | ## License 73 | 74 | **[MIT](LICENSE)** 75 | -------------------------------------------------------------------------------- /retry.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/winterssy/bufferpool" 9 | ) 10 | 11 | const ( 12 | defaultRetryMaxAttempts = 3 13 | ) 14 | 15 | var ( 16 | defaultRetryBackoff = NewExponentialBackoff(1*time.Second, 30*time.Second, true) 17 | ) 18 | 19 | type ( 20 | retrier struct { 21 | maxAttempts int 22 | backoff Backoff 23 | triggers []func(resp *Response, err error) bool 24 | } 25 | 26 | // RetryOption configures a retrier. 27 | RetryOption func(r *retrier) 28 | ) 29 | 30 | func defaultRetrier() *retrier { 31 | return &retrier{ 32 | maxAttempts: defaultRetryMaxAttempts, 33 | backoff: defaultRetryBackoff, 34 | } 35 | } 36 | 37 | func (r *retrier) modifyRequest(req *Request) (err error) { 38 | if r.maxAttempts > 0 && req.Body != nil && req.GetBody == nil { 39 | buf := bufferpool.Get() 40 | defer buf.Free() 41 | err = drainBody(req.Body, buf) 42 | if err == nil { 43 | req.SetContent(buf.Bytes()) 44 | } 45 | } 46 | return 47 | } 48 | 49 | // Report whether a request needs a retry. 50 | func (r *retrier) on(ctx context.Context, attemptNum int, resp *Response, err error) bool { 51 | if ctx.Err() != nil || attemptNum >= r.maxAttempts { 52 | return false 53 | } 54 | 55 | if len(r.triggers) == 0 { 56 | return err != nil || resp.StatusCode == http.StatusTooManyRequests 57 | } 58 | 59 | for _, trigger := range r.triggers { 60 | if trigger(resp, err) { 61 | return true 62 | } 63 | } 64 | 65 | return false 66 | } 67 | 68 | // WithRetryMaxAttempts is a retry option that specifies the max attempts to a retrier while 0 means no retries. 69 | // By default is 3. 70 | func WithRetryMaxAttempts(n int) RetryOption { 71 | return func(r *retrier) { 72 | r.maxAttempts = n 73 | } 74 | } 75 | 76 | // WithRetryBackoff is a retry option that specifies the backoff to a retrier. 77 | // By default is an exponential backoff with jitter whose baseInterval is 1s and maxInterval is 30s. 78 | func WithRetryBackoff(backoff Backoff) RetryOption { 79 | return func(r *retrier) { 80 | r.backoff = backoff 81 | } 82 | } 83 | 84 | // WithRetryTriggers is a retry option that specifies the triggers to a retrier 85 | // for determining whether a request needs a retry. 86 | // By default is the error isn't nil or the response status code is 429. 87 | func WithRetryTriggers(triggers ...func(resp *Response, err error) bool) RetryOption { 88 | return func(r *retrier) { 89 | r.triggers = triggers 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "os" 7 | 8 | "github.com/winterssy/bufferpool" 9 | "github.com/winterssy/gjson" 10 | "golang.org/x/text/encoding" 11 | ) 12 | 13 | type ( 14 | // Response is a wrapper around an http.Response. 15 | Response struct { 16 | *http.Response 17 | clientTrace *clientTrace 18 | } 19 | ) 20 | 21 | // Cookie returns the named cookie provided in resp. 22 | // If multiple cookies match the given name, only one cookie will be returned. 23 | func (resp *Response) Cookie(name string) (*http.Cookie, error) { 24 | return findCookie(name, resp.Cookies()) 25 | } 26 | 27 | // Content reads from resp's body until an error or EOF and returns the data it read. 28 | func (resp *Response) Content() ([]byte, error) { 29 | buf := bufferpool.Get() 30 | defer buf.Free() 31 | err := drainBody(resp.Body, buf) 32 | return buf.Bytes(), err 33 | } 34 | 35 | // Text is like Content, but it decodes the data it read to a string given an optional charset encoding. 36 | func (resp *Response) Text(e ...encoding.Encoding) (string, error) { 37 | b, err := resp.Content() 38 | if err != nil || len(e) == 0 { 39 | return b2s(b), err 40 | } 41 | 42 | b, err = e[0].NewDecoder().Bytes(b) 43 | return b2s(b), err 44 | } 45 | 46 | // JSON decodes resp's body and unmarshals its JSON-encoded data into v. 47 | // v must be a pointer. 48 | func (resp *Response) JSON(v interface{}, opts ...func(dec *gjson.Decoder)) error { 49 | defer resp.Body.Close() 50 | return gjson.NewDecoder(resp.Body, opts...).Decode(v) 51 | } 52 | 53 | // H is like JSON, but it unmarshals into an H instance. 54 | // It provides a convenient way to read arbitrary JSON. 55 | func (resp *Response) H(opts ...func(dec *gjson.Decoder)) (H, error) { 56 | var h H 57 | return h, resp.JSON(&h, opts...) 58 | } 59 | 60 | // SaveFile saves resp's body into a file. 61 | func (resp *Response) SaveFile(filename string, perm os.FileMode) (err error) { 62 | var file *os.File 63 | file, err = os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) 64 | if err == nil { 65 | defer file.Close() 66 | err = drainBody(resp.Body, file) 67 | } 68 | return 69 | } 70 | 71 | // TraceInfo returns the trace info for the request if client trace is enabled. 72 | func (resp *Response) TraceInfo() (traceInfo *TraceInfo) { 73 | if resp.clientTrace != nil { 74 | traceInfo = resp.clientTrace.traceInfo() 75 | } 76 | return 77 | } 78 | 79 | // Dump returns the HTTP/1.x wire representation of resp. 80 | func (resp *Response) Dump(body bool) ([]byte, error) { 81 | return httputil.DumpResponse(resp.Response, body) 82 | } 83 | -------------------------------------------------------------------------------- /dump.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "strings" 9 | 10 | "github.com/winterssy/bufferpool" 11 | ) 12 | 13 | var ( 14 | reqWriteExcludeHeaderDump = map[string]bool{ 15 | "Host": true, // not in Header map anyway 16 | "Transfer-Encoding": true, 17 | "Trailer": true, 18 | } 19 | ) 20 | 21 | func dumpRequestLine(req *Request, w io.Writer) { 22 | fmt.Fprintf(w, "> %s %s %s\r\n", req.Method, req.URL.RequestURI(), req.Proto) 23 | } 24 | 25 | func dumpRequestHeaders(req *Request, w io.Writer) { 26 | host := req.Host 27 | if req.Host == "" && req.URL != nil { 28 | host = req.URL.Host 29 | } 30 | if host != "" { 31 | fmt.Fprintf(w, "> Host: %s\r\n", host) 32 | } 33 | 34 | if len(req.TransferEncoding) > 0 { 35 | fmt.Fprintf(w, "> Transfer-Encoding: %s\r\n", strings.Join(req.TransferEncoding, ",")) 36 | } 37 | if req.Close { 38 | io.WriteString(w, "> Connection: close\r\n") 39 | } 40 | 41 | for k, vs := range req.Header { 42 | if !reqWriteExcludeHeaderDump[k] { 43 | for _, v := range vs { 44 | fmt.Fprintf(w, "> %s: %s\r\n", k, v) 45 | } 46 | } 47 | } 48 | io.WriteString(w, ">\r\n") 49 | } 50 | 51 | func dumpRequestBody(req *Request, w io.Writer) (err error) { 52 | buf := bufferpool.Get() 53 | defer buf.Free() 54 | err = drainBody(req.Body, buf) 55 | if err == nil { 56 | req.SetContent(buf.Bytes()) 57 | _, _ = io.Copy(w, buf) 58 | io.WriteString(w, "\r\n") 59 | } 60 | return 61 | } 62 | 63 | func dumpRequest(req *Request, w io.Writer, body bool) (err error) { 64 | dumpRequestLine(req, w) 65 | dumpRequestHeaders(req, w) 66 | if body && !bodyEmpty(req.Body) { 67 | err = dumpRequestBody(req, w) 68 | } 69 | return 70 | } 71 | 72 | func dumpResponseLine(resp *Response, w io.Writer) { 73 | fmt.Fprintf(w, "< %s %s\r\n", resp.Proto, resp.Status) 74 | } 75 | 76 | func dumpResponseHeaders(resp *Response, w io.Writer) { 77 | for k, vs := range resp.Header { 78 | for _, v := range vs { 79 | fmt.Fprintf(w, "< %s: %s\r\n", k, v) 80 | } 81 | } 82 | io.WriteString(w, "<\r\n") 83 | } 84 | 85 | func dumpResponseBody(resp *Response, w io.Writer) (err error) { 86 | buf := bufferpool.Get() 87 | defer buf.Free() 88 | err = drainBody(resp.Body, buf) 89 | if err == nil { 90 | resp.Body = ioutil.NopCloser(bytes.NewReader(buf.Bytes())) 91 | _, _ = io.Copy(w, buf) 92 | io.WriteString(w, "\r\n") 93 | } 94 | return 95 | } 96 | 97 | func dumpResponse(resp *Response, w io.Writer, body bool) (err error) { 98 | dumpResponseLine(resp, w) 99 | dumpResponseHeaders(resp, w) 100 | if body && !bodyEmpty(resp.Body) { 101 | err = dumpResponseBody(resp, w) 102 | } 103 | return 104 | } 105 | -------------------------------------------------------------------------------- /retry_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | neturl "net/url" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestRetrier_ModifyRequest(t *testing.T) { 15 | const dummyData = "hello world" 16 | req, _ := NewRequest(MethodPost, "https://httpbin.org/post") 17 | req.SetBody(&dummyBody{s: dummyData}) 18 | 19 | retrier := defaultRetrier() 20 | err := retrier.modifyRequest(req) 21 | if assert.NoError(t, err) && assert.NotNil(t, req.GetBody) { 22 | rc, _ := req.GetBody() 23 | b, err := ioutil.ReadAll(rc) 24 | if assert.NoError(t, err) { 25 | rc.Close() 26 | assert.Equal(t, dummyData, string(b)) 27 | } 28 | } 29 | 30 | dummyRequest := &Request{ 31 | Request: &http.Request{ 32 | Method: MethodPost, 33 | URL: &neturl.URL{ 34 | Scheme: "https", 35 | Host: "httpbin.org", 36 | Path: "/post", 37 | }, 38 | Proto: "HTTP/1.1", 39 | ProtoMajor: 1, 40 | ProtoMinor: 1, 41 | Header: make(http.Header), 42 | Body: &dummyBody{errFlag: errRead}, 43 | Host: "httpbin.org", 44 | }, 45 | } 46 | err = retrier.modifyRequest(dummyRequest) 47 | assert.Equal(t, errAccessDummyBody, err) 48 | } 49 | 50 | func TestRetrier_On(t *testing.T) { 51 | retrier := defaultRetrier() 52 | dummyResponse := &Response{ 53 | Response: &http.Response{ 54 | StatusCode: http.StatusTooManyRequests, 55 | }, 56 | } 57 | assert.True(t, retrier.on(context.Background(), 0, dummyResponse, nil)) 58 | assert.False(t, retrier.on(context.Background(), retrier.maxAttempts, dummyResponse, nil)) 59 | 60 | ctx, cancel := context.WithCancel(context.Background()) 61 | cancel() 62 | assert.False(t, retrier.on(ctx, retrier.maxAttempts, dummyResponse, nil)) 63 | 64 | opt := WithRetryTriggers(func(resp *Response, err error) bool { 65 | return err != nil || resp.StatusCode != http.StatusOK 66 | }) 67 | opt(retrier) 68 | dummyResponse = &Response{ 69 | Response: &http.Response{ 70 | StatusCode: http.StatusInternalServerError, 71 | }, 72 | } 73 | assert.True(t, retrier.on(context.Background(), 0, dummyResponse, nil)) 74 | 75 | dummyResponse = &Response{ 76 | Response: &http.Response{ 77 | StatusCode: http.StatusOK, 78 | }, 79 | } 80 | assert.False(t, retrier.on(context.Background(), 0, dummyResponse, nil)) 81 | } 82 | 83 | func TestRetryOption(t *testing.T) { 84 | const maxAttempts = 7 85 | backoff := NewConstantBackoff(time.Second, true) 86 | opts := []RetryOption{ 87 | WithRetryMaxAttempts(maxAttempts), 88 | WithRetryBackoff(backoff), 89 | WithRetryTriggers(func(resp *Response, err error) bool { 90 | return err != nil || resp.StatusCode != http.StatusOK 91 | }), 92 | } 93 | 94 | retrier := defaultRetrier() 95 | for _, opt := range opts { 96 | opt(retrier) 97 | } 98 | assert.Equal(t, maxAttempts, retrier.maxAttempts) 99 | assert.Equal(t, backoff, retrier.backoff) 100 | assert.NotEmpty(t, retrier.triggers) 101 | } 102 | -------------------------------------------------------------------------------- /ghttp.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "net/http" 5 | neturl "net/url" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/winterssy/gjson" 10 | ) 11 | 12 | type ( 13 | // KV maps a string key to an interface{} type value, 14 | // It's typically used for request query parameters, form data or headers. 15 | KV map[string]interface{} 16 | 17 | // Params is an alias of KV, used for for request query parameters. 18 | Params = KV 19 | 20 | // Form is an alias of KV, used for request form data. 21 | Form = KV 22 | 23 | // Headers is an alias of KV, used for request headers. 24 | Headers = KV 25 | 26 | // Cookies is a shortcut for map[string]string, used for request cookies. 27 | Cookies map[string]string 28 | 29 | // BeforeRequestCallback is the interface that defines the manipulations before sending a request. 30 | BeforeRequestCallback interface { 31 | // Enter is called when a request is about to begin. 32 | // If a non-nil error is returned, ghttp will cancel the request. 33 | Enter(req *Request) error 34 | } 35 | 36 | // AfterResponseCallback is the interface that defines the manipulations after receiving a response. 37 | AfterResponseCallback interface { 38 | // Exit is called when a request ends. 39 | Exit(resp *Response, err error) 40 | } 41 | ) 42 | 43 | // Decode translates kv and returns the equivalent request query parameters, form data or headers. 44 | // It ignores any unexpected key-value pair. 45 | func (kv KV) Decode() map[string][]string { 46 | vv := make(map[string][]string, len(kv)) 47 | for k, v := range kv { 48 | if vs := toStrings(v); len(vs) > 0 { 49 | vv[k] = vs 50 | } 51 | } 52 | return vv 53 | } 54 | 55 | // EncodeToURL encodes kv into URL form sorted by key if kv is considered as request query parameters or form data. 56 | func (kv KV) EncodeToURL(escape bool) string { 57 | vv := kv.Decode() 58 | keys := make([]string, 0, len(vv)) 59 | for k := range kv { 60 | keys = append(keys, k) 61 | } 62 | sort.Strings(keys) 63 | 64 | var sb strings.Builder 65 | for _, k := range keys { 66 | vs := vv[k] 67 | for _, v := range vs { 68 | if sb.Len() > 0 { 69 | sb.WriteByte('&') 70 | } 71 | 72 | if escape { 73 | k = neturl.QueryEscape(k) 74 | v = neturl.QueryEscape(v) 75 | } 76 | 77 | sb.WriteString(k) 78 | sb.WriteByte('=') 79 | sb.WriteString(v) 80 | } 81 | } 82 | return sb.String() 83 | } 84 | 85 | // EncodeToJSON returns the JSON encoding of kv. 86 | // If there is an error, it returns "{}". 87 | func (kv KV) EncodeToJSON(opts ...func(enc *gjson.Encoder)) string { 88 | s, err := gjson.EncodeToString(kv, opts...) 89 | if err != nil { 90 | return "{}" 91 | } 92 | 93 | return s 94 | } 95 | 96 | // Decode translates c and returns the equivalent request cookies. 97 | func (c Cookies) Decode() []*http.Cookie { 98 | cookies := make([]*http.Cookie, 0, len(c)) 99 | for k, v := range c { 100 | cookies = append(cookies, &http.Cookie{ 101 | Name: k, 102 | Value: v, 103 | }) 104 | } 105 | return cookies 106 | } 107 | -------------------------------------------------------------------------------- /backoff.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "math" 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | type ( 10 | // Backoff is the interface defines a backoff for a retrier. It is called 11 | // after a failing request to determine the amount of time 12 | // that should pass before trying again. 13 | Backoff interface { 14 | // Wait returns the duration to wait before retrying a request. 15 | Wait(attemptNum int, resp *Response, err error) time.Duration 16 | } 17 | 18 | constantBackoff struct { 19 | interval time.Duration 20 | jitter bool 21 | } 22 | 23 | exponentialBackoff struct { 24 | baseInterval time.Duration 25 | maxInterval time.Duration 26 | jitter bool 27 | } 28 | 29 | fibonacciBackoff struct { 30 | maxValue int 31 | interval time.Duration 32 | } 33 | ) 34 | 35 | func init() { 36 | rand.Seed(time.Now().UnixNano()) 37 | } 38 | 39 | // NewConstantBackoff provides a callback for the retry policy which 40 | // will perform constant backoff with jitter based on interval. 41 | func NewConstantBackoff(interval time.Duration, jitter bool) Backoff { 42 | return &constantBackoff{ 43 | interval: interval, 44 | jitter: jitter, 45 | } 46 | } 47 | 48 | // Wait implements Backoff interface. 49 | func (cb *constantBackoff) Wait(int, *Response, error) time.Duration { 50 | if !cb.jitter { 51 | return cb.interval 52 | } 53 | 54 | return cb.interval/2 + time.Duration(rand.Int63n(int64(cb.interval))) 55 | } 56 | 57 | // NewExponentialBackoff provides a callback for the retry policy which 58 | // will perform exponential backoff with jitter based on the attempt number and limited 59 | // by baseInterval and maxInterval. 60 | // See: https://aws.amazon.com/cn/blogs/architecture/exponential-backoff-and-jitter/ 61 | func NewExponentialBackoff(baseInterval, maxInterval time.Duration, jitter bool) Backoff { 62 | return &exponentialBackoff{ 63 | baseInterval: baseInterval, 64 | maxInterval: maxInterval, 65 | jitter: jitter, 66 | } 67 | } 68 | 69 | // Wait implements Backoff interface. 70 | func (eb *exponentialBackoff) Wait(attemptNum int, _ *Response, _ error) time.Duration { 71 | temp := math.Min(float64(eb.maxInterval), float64(eb.baseInterval)*math.Exp2(float64(attemptNum))) 72 | if !eb.jitter { 73 | return time.Duration(temp) 74 | } 75 | 76 | n := int64(temp / 2) 77 | return time.Duration(n + rand.Int63n(n)) 78 | } 79 | 80 | // NewFibonacciBackoff provides a callback for the retry policy which 81 | // will perform fibonacci backoff based on the attempt number and limited by maxValue. 82 | // If maxValue less than or equal to zero, it means no limit. 83 | func NewFibonacciBackoff(maxValue int, interval time.Duration) Backoff { 84 | return &fibonacciBackoff{ 85 | maxValue: maxValue, 86 | interval: interval, 87 | } 88 | } 89 | 90 | // Wait implements Backoff interface. 91 | func (fb *fibonacciBackoff) Wait(attemptNum int, _ *Response, _ error) time.Duration { 92 | a, b := 0, 1 93 | for ; attemptNum >= 0; attemptNum-- { 94 | if fb.maxValue > 0 && b >= fb.maxValue { 95 | a = fb.maxValue 96 | break 97 | } 98 | a, b = b, a+b 99 | } 100 | return time.Duration(a) * fb.interval 101 | } 102 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const ( 13 | errRead = 1 << iota 14 | errClose 15 | ) 16 | 17 | var ( 18 | errAccessDummyBody = errors.New("dummy body is inaccessible") 19 | ) 20 | 21 | type ( 22 | dummyBody struct { 23 | s string 24 | i int64 25 | errFlag int 26 | } 27 | ) 28 | 29 | func (db *dummyBody) Read(b []byte) (n int, err error) { 30 | if db.errFlag&errRead != 0 { 31 | return 0, errAccessDummyBody 32 | } 33 | 34 | if db.i >= int64(len(db.s)) { 35 | return 0, io.EOF 36 | } 37 | 38 | n = copy(b, db.s[db.i:]) 39 | db.i += int64(n) 40 | return 41 | } 42 | 43 | func (db *dummyBody) Close() error { 44 | if db.errFlag&errClose != 0 { 45 | return errAccessDummyBody 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func TestToString(t *testing.T) { 52 | var ( 53 | stringVal = "hi" 54 | bytesVal = []byte{'h', 'e', 'l', 'l', 'o'} 55 | bytesValStr = "hello" 56 | boolValStr = "true" 57 | intVal = -314 58 | intValStr = "-314" 59 | int8Val int8 = -128 60 | int8ValStr = "-128" 61 | int16Val int16 = -32768 62 | int16ValStr = "-32768" 63 | int32Val int32 = -314159 64 | int32ValStr = "-314159" 65 | int64Val int64 = -31415926535 66 | int64ValStr = "-31415926535" 67 | uintVal uint = 314 68 | uintValStr = "314" 69 | uint8Val uint8 = 127 70 | uint8ValStr = "127" 71 | uint16Val uint16 = 32767 72 | uint16ValStr = "32767" 73 | uint32Val uint32 = 314159 74 | uint32ValStr = "314159" 75 | uint64Val uint64 = 31415926535 76 | uint64ValStr = "31415926535" 77 | float32Val float32 = 3.14159 78 | float32ValStr = "3.14159" 79 | float64Val = 3.1415926535 80 | float64ValStr = "3.1415926535" 81 | errVal = errAccessDummyBody 82 | timeVal = time.Now() 83 | ) 84 | tests := []struct { 85 | input interface{} 86 | want string 87 | }{ 88 | {stringVal, stringVal}, 89 | {bytesVal, bytesValStr}, 90 | {true, boolValStr}, 91 | {intVal, intValStr}, 92 | {int8Val, int8ValStr}, 93 | {int16Val, int16ValStr}, 94 | {int32Val, int32ValStr}, 95 | {int64Val, int64ValStr}, 96 | {uintVal, uintValStr}, 97 | {uint8Val, uint8ValStr}, 98 | {uint16Val, uint16ValStr}, 99 | {uint32Val, uint32ValStr}, 100 | {uint64Val, uint64ValStr}, 101 | {float32Val, float32ValStr}, 102 | {float64Val, float64ValStr}, 103 | {errVal, errVal.Error()}, 104 | {timeVal, timeVal.String()}, 105 | } 106 | for _, test := range tests { 107 | assert.Equal(t, test.want, toString(test.input)) 108 | } 109 | 110 | complexVal := 1 + 2i 111 | assert.Panics(t, func() { 112 | toString(complexVal) 113 | }) 114 | } 115 | 116 | func TestToStrings(t *testing.T) { 117 | vs := []string{"1", "2", "3"} 118 | 119 | var v interface{} = []int{1, 2, 3} 120 | assert.Equal(t, vs, toStrings(v)) 121 | 122 | v = [3]int{1, 2, 3} 123 | assert.Equal(t, vs, toStrings(v)) 124 | 125 | v = []interface{}{1, 2, 1 + 2i} 126 | assert.Empty(t, toStrings(v)) 127 | } 128 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "reflect" 10 | "strconv" 11 | "unsafe" 12 | 13 | "github.com/winterssy/gjson" 14 | ) 15 | 16 | type ( 17 | // H is an alias of gjson.Object. 18 | // Visit https://github.com/winterssy/gjson for more details. 19 | H = gjson.Object 20 | ) 21 | 22 | func b2s(b []byte) string { 23 | return *(*string)(unsafe.Pointer(&b)) 24 | } 25 | 26 | func toString(v interface{}) string { 27 | switch v := v.(type) { 28 | case string: 29 | return v 30 | case []byte: 31 | return b2s(v) 32 | case bool: 33 | return strconv.FormatBool(v) 34 | case float32: 35 | return strconv.FormatFloat(float64(v), 'f', -1, 32) 36 | case float64: 37 | return strconv.FormatFloat(v, 'f', -1, 64) 38 | case int: 39 | return strconv.FormatInt(int64(v), 10) 40 | case int8: 41 | return strconv.FormatInt(int64(v), 10) 42 | case int16: 43 | return strconv.FormatInt(int64(v), 10) 44 | case int32: 45 | return strconv.FormatInt(int64(v), 10) 46 | case int64: 47 | return strconv.FormatInt(v, 10) 48 | case uint: 49 | return strconv.FormatUint(uint64(v), 10) 50 | case uint8: 51 | return strconv.FormatUint(uint64(v), 10) 52 | case uint16: 53 | return strconv.FormatUint(uint64(v), 10) 54 | case uint32: 55 | return strconv.FormatUint(uint64(v), 10) 56 | case uint64: 57 | return strconv.FormatUint(v, 10) 58 | case error: 59 | return v.Error() 60 | case interface { 61 | String() string 62 | }: 63 | return v.String() 64 | } 65 | 66 | panic(fmt.Errorf("ghttp: can't cast %#v of type %[1]T to string", v)) 67 | } 68 | 69 | func toStrings(v interface{}) (vs []string) { 70 | defer func() { 71 | if err := recover(); err != nil { 72 | log.Print(err) 73 | vs = nil // ignore this field 74 | } 75 | }() 76 | 77 | switch v := v.(type) { 78 | case []string: 79 | vs = make([]string, len(v)) 80 | copy(vs, v) 81 | case string, []byte, 82 | bool, 83 | float32, float64, 84 | int, int8, int16, int32, int64, 85 | uint, uint8, uint16, uint32, uint64, 86 | error, 87 | interface { 88 | String() string 89 | }: 90 | vs = []string{toString(v)} 91 | default: 92 | rv := reflect.ValueOf(v) 93 | switch rv.Kind() { 94 | case reflect.Slice, reflect.Array: 95 | n := rv.Len() 96 | vs = make([]string, 0, n) 97 | for i := 0; i < n; i++ { 98 | vs = append(vs, toString(rv.Index(i).Interface())) 99 | } 100 | } 101 | } 102 | 103 | return 104 | } 105 | 106 | func toReadCloser(r io.Reader) io.ReadCloser { 107 | rc, ok := r.(io.ReadCloser) 108 | if !ok && r != nil { 109 | rc = ioutil.NopCloser(r) 110 | } 111 | return rc 112 | } 113 | 114 | // Report whether an HTTP request or response body is empty. 115 | func bodyEmpty(body io.ReadCloser) bool { 116 | return body == nil || body == http.NoBody 117 | } 118 | 119 | func drainBody(body io.ReadCloser, w io.Writer) (err error) { 120 | defer body.Close() 121 | _, err = io.Copy(w, body) 122 | return 123 | } 124 | 125 | func findCookie(name string, cookies []*http.Cookie) (*http.Cookie, error) { 126 | for _, cookie := range cookies { 127 | if cookie.Name == name { 128 | return cookie, nil 129 | } 130 | } 131 | 132 | return nil, http.ErrNoCookie 133 | } 134 | 135 | // Return value if nonempty, def otherwise. 136 | func valueOrDefault(value, def string) string { 137 | if value != "" { 138 | return value 139 | } 140 | return def 141 | } 142 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 6 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 7 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 8 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 9 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 10 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 11 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 12 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 13 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 17 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 18 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 19 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 20 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 21 | github.com/winterssy/bufferpool v0.0.0-20200229012952-527e7777fcd3 h1:hNn8ALenX7lFAh2ZSlseyv+xzYOyaWpaTLMoU7xhtM4= 22 | github.com/winterssy/bufferpool v0.0.0-20200229012952-527e7777fcd3/go.mod h1:Ys+2xIWb2KNMf2jI1xTlebh5Fblja7LyxlhU2xWfbPU= 23 | github.com/winterssy/gjson v0.0.0-20200303165818-ba395c113d1f h1:MT4aq94hz9I3VgZfh2wDNknKN5HkNuJ84dtvPUDPXYQ= 24 | github.com/winterssy/gjson v0.0.0-20200303165818-ba395c113d1f/go.mod h1:Hq+tE5rN41nrDvD925cOO3YVjwFixLcs/CDUcULGpKQ= 25 | github.com/winterssy/gjson v0.0.0-20200306020332-1f68efaec187 h1:NuUeupnBUlIDdH4du0pa+XvbQFiUZ9Vk/UvkUBPxAJI= 26 | github.com/winterssy/gjson v0.0.0-20200306020332-1f68efaec187/go.mod h1:Hq+tE5rN41nrDvD925cOO3YVjwFixLcs/CDUcULGpKQ= 27 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 28 | golang.org/x/net v0.0.0-20191009170851-d66e71096ffb h1:TR699M2v0qoKTOHxeLgp6zPqaQNs74f01a/ob9W0qko= 29 | golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 32 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 33 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= 34 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 38 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 39 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "golang.org/x/text/encoding/simplifiedchinese" 12 | "golang.org/x/text/transform" 13 | ) 14 | 15 | func TestResponse_Cookie(t *testing.T) { 16 | var _cookie = &http.Cookie{ 17 | Name: "uid", 18 | Value: "10086", 19 | } 20 | 21 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | http.SetCookie(w, _cookie) 23 | })) 24 | defer ts.Close() 25 | 26 | client := New() 27 | resp, err := client. 28 | Get(ts.URL) 29 | require.NoError(t, err) 30 | 31 | cookie, err := resp.Cookie(_cookie.Name) 32 | if assert.NoError(t, err) { 33 | assert.Equal(t, _cookie.Value, cookie.Value) 34 | } 35 | 36 | cookie, err = resp.Cookie("uuid") 37 | if assert.Equal(t, http.ErrNoCookie, err) { 38 | assert.Nil(t, cookie) 39 | } 40 | } 41 | 42 | func TestResponse_Text(t *testing.T) { 43 | const dummyData = "你好世界" 44 | 45 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | var _w io.Writer = w 47 | if r.Method == MethodGet { 48 | _w = transform.NewWriter(w, simplifiedchinese.GBK.NewEncoder()) 49 | } 50 | _w.Write([]byte(dummyData)) 51 | })) 52 | defer ts.Close() 53 | 54 | client := New() 55 | resp, err := client.Post(ts.URL) 56 | require.NoError(t, err) 57 | 58 | data, err := resp.Text() 59 | if assert.NoError(t, err) { 60 | assert.Equal(t, dummyData, data) 61 | } 62 | 63 | resp, err = client.Get(ts.URL) 64 | require.NoError(t, err) 65 | 66 | data, err = resp.Text(simplifiedchinese.GBK) 67 | if assert.NoError(t, err) { 68 | assert.Equal(t, dummyData, data) 69 | } 70 | } 71 | 72 | func TestResponse_H(t *testing.T) { 73 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | w.Header().Set("Content-Type", "application/json") 75 | w.Write([]byte(`{"msg":"hello world"}`)) 76 | })) 77 | defer ts.Close() 78 | 79 | client := New() 80 | resp, err := client.Get(ts.URL) 81 | require.NoError(t, err) 82 | 83 | data, err := resp.H() 84 | if assert.NoError(t, err) { 85 | assert.Equal(t, "hello world", data.GetString("msg")) 86 | } 87 | } 88 | 89 | func TestResponse_SaveFile(t *testing.T) { 90 | const testFile = "testdata.txt" 91 | 92 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | w.Write([]byte("hello world")) 94 | })) 95 | defer ts.Close() 96 | 97 | client := New() 98 | resp, err := client.Get(ts.URL) 99 | require.NoError(t, err) 100 | 101 | assert.NoError(t, resp.SaveFile(testFile, 0644)) 102 | } 103 | 104 | func TestResponse_TraceInfo(t *testing.T) { 105 | client := New() 106 | resp, _ := client. 107 | Get("https://httpbin.org/get", 108 | WithClientTrace(), 109 | ) 110 | 111 | traceInfo := resp.TraceInfo() 112 | if assert.NotNil(t, traceInfo) { 113 | assert.True(t, traceInfo.DNSLookupTime >= 0) 114 | assert.True(t, traceInfo.TCPConnTime >= 0) 115 | assert.True(t, traceInfo.TLSHandshakeTime >= 0) 116 | assert.True(t, traceInfo.ConnTime >= 0) 117 | assert.True(t, traceInfo.ServerTime >= 0) 118 | assert.True(t, traceInfo.ResponseTime >= 0) 119 | assert.True(t, traceInfo.TotalTime >= 0) 120 | } 121 | } 122 | 123 | func TestResponse_Dump(t *testing.T) { 124 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 125 | w.Write([]byte("hello world")) 126 | })) 127 | defer ts.Close() 128 | 129 | client := New() 130 | resp, err := client.Get(ts.URL) 131 | require.NoError(t, err) 132 | 133 | _, err = resp.Dump(true) 134 | assert.NoError(t, err) 135 | } 136 | -------------------------------------------------------------------------------- /trace.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http/httptrace" 6 | "time" 7 | ) 8 | 9 | type ( 10 | clientTrace struct { 11 | start time.Time 12 | dnsStart time.Time 13 | dnsDone time.Time 14 | connStart time.Time 15 | connDone time.Time 16 | tlsHandshakeStart time.Time 17 | tlsHandshakeDone time.Time 18 | gotFirstResponseByte time.Time 19 | wroteRequest time.Time 20 | end time.Time 21 | getConn time.Time 22 | gotConn time.Time 23 | gotConnInfo httptrace.GotConnInfo 24 | } 25 | 26 | // TraceInfo is used to provide request trace info such as DNS lookup 27 | // duration, Connection obtain duration, Server processing duration, etc. 28 | TraceInfo struct { 29 | // DNSLookupTime is a duration that transport took to perform 30 | // DNS lookup. 31 | DNSLookupTime time.Duration `json:"dns_lookup_time"` 32 | 33 | // TCPConnTime is a duration that TCP connection took place. 34 | TCPConnTime time.Duration `json:"tcp_conn_time"` 35 | 36 | // TLSHandshakeTime is a duration that TLS handshake took place. 37 | TLSHandshakeTime time.Duration `json:"tls_handshake_time,omitempty"` 38 | 39 | // ConnTime is a duration that took to obtain a successful connection. 40 | ConnTime time.Duration `json:"conn_time"` 41 | 42 | // ServerTime is a duration that server took to respond first byte. 43 | ServerTime time.Duration `json:"server_time"` 44 | 45 | // ResponseTime is a duration since first response byte from server to 46 | // request completion. 47 | ResponseTime time.Duration `json:"response_time"` 48 | 49 | // TotalTime is a duration that total request took end-to-end. 50 | TotalTime time.Duration `json:"total_time"` 51 | 52 | // ConnReused reports whether this connection has been previously 53 | // used for another HTTP request. 54 | ConnReused bool `json:"conn_reused"` 55 | 56 | // ConnWasIdle reports whether this connection was obtained from an 57 | // idle pool. 58 | ConnWasIdle bool `json:"conn_was_idle"` 59 | 60 | // ConnIdleTime is a duration how long the connection was previously 61 | // idle, if ConnWasIdle is true. 62 | ConnIdleTime time.Duration `json:"conn_idle_time"` 63 | } 64 | ) 65 | 66 | func (ct *clientTrace) modifyRequest(req *Request) { 67 | ctx := httptrace.WithClientTrace( 68 | req.Context(), 69 | &httptrace.ClientTrace{ 70 | GetConn: func(_ string) { 71 | ct.getConn = time.Now() 72 | }, 73 | GotConn: func(gotConnInfo httptrace.GotConnInfo) { 74 | ct.gotConn = time.Now() 75 | ct.gotConnInfo = gotConnInfo 76 | }, 77 | GotFirstResponseByte: func() { 78 | ct.gotFirstResponseByte = time.Now() 79 | }, 80 | DNSStart: func(_ httptrace.DNSStartInfo) { 81 | ct.dnsStart = time.Now() 82 | }, 83 | DNSDone: func(_ httptrace.DNSDoneInfo) { 84 | ct.dnsDone = time.Now() 85 | }, 86 | ConnectStart: func(network, addr string) { 87 | ct.connStart = time.Now() 88 | }, 89 | ConnectDone: func(network, addr string, err error) { 90 | ct.connDone = time.Now() 91 | }, 92 | TLSHandshakeStart: func() { 93 | ct.tlsHandshakeStart = time.Now() 94 | }, 95 | TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { 96 | ct.tlsHandshakeDone = time.Now() 97 | }, 98 | WroteRequest: func(_ httptrace.WroteRequestInfo) { 99 | ct.wroteRequest = time.Now() 100 | }, 101 | }, 102 | ) 103 | req.Request = req.WithContext(ctx) 104 | } 105 | 106 | func (ct *clientTrace) done() { 107 | ct.end = time.Now() 108 | } 109 | 110 | func (ct *clientTrace) traceInfo() *TraceInfo { 111 | return &TraceInfo{ 112 | DNSLookupTime: ct.dnsDone.Sub(ct.dnsStart), 113 | TCPConnTime: ct.connDone.Sub(ct.connStart), 114 | TLSHandshakeTime: ct.tlsHandshakeDone.Sub(ct.tlsHandshakeStart), 115 | ConnTime: ct.gotConn.Sub(ct.getConn), 116 | ServerTime: ct.gotFirstResponseByte.Sub(ct.wroteRequest), 117 | ResponseTime: ct.end.Sub(ct.gotFirstResponseByte), 118 | TotalTime: ct.end.Sub(ct.start), 119 | ConnReused: ct.gotConnInfo.Reused, 120 | ConnWasIdle: ct.gotConnInfo.WasIdle, 121 | ConnIdleTime: ct.gotConnInfo.IdleTime, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /multipart.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "log" 8 | "mime/multipart" 9 | "net/http" 10 | "net/textproto" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "sync" 15 | ) 16 | 17 | type ( 18 | // FormData is a multipart container that uses io.Pipe to reduce memory used 19 | // while uploading files. 20 | FormData struct { 21 | files Files 22 | form Form 23 | pr *io.PipeReader 24 | pw *io.PipeWriter 25 | mw *multipart.Writer 26 | once sync.Once 27 | } 28 | 29 | // Files maps a string key to a *File type value, used for files of multipart payload. 30 | Files map[string]*File 31 | 32 | // File is a struct defines a file of a multipart section to upload. 33 | File struct { 34 | body io.ReadCloser 35 | filename string 36 | mime string 37 | } 38 | ) 39 | 40 | // NewMultipart returns a new multipart container. 41 | func NewMultipart(files Files) *FormData { 42 | pr, pw := io.Pipe() 43 | mw := multipart.NewWriter(pw) 44 | return &FormData{ 45 | mw: mw, 46 | pr: pr, 47 | pw: pw, 48 | files: files, 49 | } 50 | } 51 | 52 | // WithForm specifies form for fd. 53 | // If you only want to send form payload, use Request.SetForm or ghttp.WithForm instead. 54 | func (fd *FormData) WithForm(form Form) *FormData { 55 | fd.form = form 56 | return fd 57 | } 58 | 59 | var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") 60 | 61 | func escapeQuotes(s string) string { 62 | return quoteEscaper.Replace(s) 63 | } 64 | 65 | func (fd *FormData) writeFiles() { 66 | const ( 67 | fileFormat = `form-data; name="%s"; filename="%s"` 68 | unknownFilename = "???" 69 | ) 70 | var ( 71 | part io.Writer 72 | err error 73 | ) 74 | for k, v := range fd.files { 75 | filename := valueOrDefault(v.filename, unknownFilename) 76 | 77 | r := bufio.NewReader(v) 78 | mime := v.mime 79 | if mime == "" { 80 | data, _ := r.Peek(512) 81 | mime = http.DetectContentType(data) 82 | } 83 | 84 | h := make(textproto.MIMEHeader) 85 | h.Set("Content-Disposition", 86 | fmt.Sprintf(fileFormat, escapeQuotes(k), escapeQuotes(filename))) 87 | h.Set("Content-Type", mime) 88 | part, _ = fd.mw.CreatePart(h) 89 | _, err = io.Copy(part, r) 90 | if err != nil { 91 | log.Printf("ghttp: can't bind multipart section (%s=@%s): %s", k, filename, err.Error()) 92 | } 93 | v.Close() 94 | } 95 | } 96 | 97 | func (fd *FormData) writeForm() { 98 | for k, vs := range fd.form.Decode() { 99 | for _, v := range vs { 100 | fd.mw.WriteField(k, v) 101 | } 102 | } 103 | } 104 | 105 | // Read implements io.Reader interface. 106 | func (fd *FormData) Read(b []byte) (int, error) { 107 | fd.once.Do(func() { 108 | go func() { 109 | defer fd.pw.Close() 110 | defer fd.mw.Close() // must close the multipart writer first! 111 | fd.writeFiles() 112 | if len(fd.form) > 0 { 113 | fd.writeForm() 114 | } 115 | }() 116 | }) 117 | return fd.pr.Read(b) 118 | } 119 | 120 | // ContentType returns the Content-Type for an HTTP 121 | // multipart/form-data with this multipart Container's Boundary. 122 | func (fd *FormData) ContentType() string { 123 | return fd.mw.FormDataContentType() 124 | } 125 | 126 | // WithFilename specifies f's filename. 127 | func (f *File) WithFilename(filename string) *File { 128 | f.filename = filename 129 | return f 130 | } 131 | 132 | // WithMIME specifies f's Content-Type. 133 | // By default ghttp detects automatically using http.DetectContentType. 134 | func (f *File) WithMIME(mime string) *File { 135 | f.mime = mime 136 | return f 137 | } 138 | 139 | // Read implements io.Reader interface. 140 | func (f *File) Read(b []byte) (int, error) { 141 | return f.body.Read(b) 142 | } 143 | 144 | // Close implements io.Closer interface. 145 | func (f *File) Close() error { 146 | return f.body.Close() 147 | } 148 | 149 | // FileFromReader constructors a new File from a reader. 150 | func FileFromReader(body io.Reader) *File { 151 | return &File{body: toReadCloser(body)} 152 | } 153 | 154 | // Open opens the named file and returns a File with filename specified. 155 | func Open(filename string) (*File, error) { 156 | body, err := os.Open(filename) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | return FileFromReader(body).WithFilename(filepath.Base(filename)), nil 162 | } 163 | 164 | // MustOpen is like Open, but if there is an error, it will panic. 165 | func MustOpen(filename string) *File { 166 | file, err := Open(filename) 167 | if err != nil { 168 | panic(err) 169 | } 170 | 171 | return file 172 | } 173 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "compress/gzip" 5 | "crypto/tls" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | neturl "net/url" 10 | "sync" 11 | "sync/atomic" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | "golang.org/x/time/rate" 18 | ) 19 | 20 | func TestClient_SetProxy(t *testing.T) { 21 | const ( 22 | proxyURL = "http://127.0.0.1:1081" 23 | ) 24 | 25 | client := New() 26 | client.SetProxy(ProxyURL(proxyURL)) 27 | transport := client.Transport.(*http.Transport) 28 | require.NotNil(t, transport.Proxy) 29 | 30 | req, _ := http.NewRequest("GET", "https://www.google.com", nil) 31 | fixedURL, err := transport.Proxy(req) 32 | if assert.NoError(t, err) { 33 | assert.Equal(t, proxyURL, fixedURL.String()) 34 | } 35 | 36 | client.SetProxy(nil) 37 | assert.Nil(t, transport.Proxy) 38 | } 39 | 40 | func TestClient_SetTLSClientConfig(t *testing.T) { 41 | config := &tls.Config{} 42 | client := New() 43 | client.SetTLSClientConfig(config) 44 | transport := client.Transport.(*http.Transport) 45 | assert.NotNil(t, transport.TLSClientConfig) 46 | } 47 | 48 | func TestClient_AddClientCerts(t *testing.T) { 49 | cert := tls.Certificate{} 50 | client := New() 51 | client.AddClientCerts(cert) 52 | transport := client.Transport.(*http.Transport) 53 | if assert.NotNil(t, transport.TLSClientConfig) { 54 | assert.Len(t, transport.TLSClientConfig.Certificates, 1) 55 | } 56 | } 57 | 58 | func TestClient_AddRootCerts(t *testing.T) { 59 | const ( 60 | pemFile = "./testdata/root-ca.pem" 61 | ) 62 | 63 | pemCerts, err := ioutil.ReadFile(pemFile) 64 | require.NoError(t, err) 65 | 66 | client := New() 67 | assert.True(t, client.AddRootCerts(pemCerts)) 68 | } 69 | 70 | func TestClient_DisableTLSVerify(t *testing.T) { 71 | client := New() 72 | client.DisableTLSVerify() 73 | transport := client.Transport.(*http.Transport) 74 | if assert.NotNil(t, transport.TLSClientConfig) { 75 | assert.True(t, transport.TLSClientConfig.InsecureSkipVerify) 76 | } 77 | } 78 | 79 | func TestClient_AddCookies(t *testing.T) { 80 | const ( 81 | validURL = "https://api.example.com" 82 | invalidURL = "https://api.example.com^" 83 | ) 84 | 85 | var ( 86 | _cookie = &http.Cookie{ 87 | Name: "uid", 88 | Value: "10086", 89 | } 90 | ) 91 | 92 | client := New() 93 | client.AddCookies(invalidURL, _cookie) 94 | assert.Empty(t, client.Cookies(invalidURL)) 95 | 96 | client.AddCookies(validURL, _cookie) 97 | cookie, err := client.Cookie(validURL, _cookie.Name) 98 | if assert.NoError(t, err) { 99 | assert.Equal(t, _cookie.Value, cookie.Value) 100 | } 101 | 102 | cookie, err = client.Cookie(validURL, "uuid") 103 | if assert.Equal(t, http.ErrNoCookie, err) { 104 | assert.Nil(t, cookie) 105 | } 106 | } 107 | 108 | func TestClient_EnableRateLimiting(t *testing.T) { 109 | const ( 110 | r rate.Limit = 1 111 | bursts = 5 112 | concurrency = 10 113 | ) 114 | 115 | var counter uint64 116 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 | atomic.AddUint64(&counter, 1) 118 | })) 119 | defer ts.Close() 120 | 121 | client := New() 122 | client.EnableRateLimiting(rate.NewLimiter(r, bursts)) 123 | 124 | wg := new(sync.WaitGroup) 125 | start := time.Now() 126 | for i := 0; i < concurrency; i++ { 127 | wg.Add(1) 128 | go func() { 129 | _, _ = client.Get(ts.URL) 130 | wg.Done() 131 | }() 132 | } 133 | wg.Wait() 134 | end := time.Since(start) 135 | 136 | if assert.Equal(t, uint64(concurrency), atomic.LoadUint64(&counter)) { 137 | assert.True(t, end >= ((concurrency-bursts)*time.Second)) 138 | } 139 | } 140 | 141 | func TestClient_SetMaxConcurrency(t *testing.T) { 142 | const ( 143 | n = 4 144 | concurrency = 20 145 | sleep = time.Second 146 | ) 147 | 148 | var counter uint64 149 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 | time.Sleep(sleep) 151 | atomic.AddUint64(&counter, 1) 152 | })) 153 | defer ts.Close() 154 | 155 | client := New() 156 | client.SetMaxConcurrency(n) 157 | 158 | wg := new(sync.WaitGroup) 159 | start := time.Now() 160 | for i := 0; i < concurrency; i++ { 161 | wg.Add(1) 162 | go func() { 163 | _, _ = client.Get(ts.URL) 164 | wg.Done() 165 | }() 166 | } 167 | wg.Wait() 168 | end := time.Since(start) 169 | 170 | if assert.Equal(t, uint64(concurrency), atomic.LoadUint64(&counter)) { 171 | assert.True(t, end >= (concurrency/n)*sleep) 172 | assert.True(t, end <= concurrency*sleep) 173 | } 174 | } 175 | 176 | func TestClient_EnableDebugging(t *testing.T) { 177 | client := New() 178 | client.EnableDebugging(ioutil.Discard, true) 179 | 180 | resp, err := client.Post("https://httpbin.org/post", 181 | WithForm(Form{ 182 | "k1": "v1", 183 | "k2": "v2", 184 | }), 185 | ) 186 | require.NoError(t, err) 187 | 188 | result := new(postmanResponse) 189 | err = resp.JSON(result) 190 | if assert.NoError(t, err) { 191 | assert.Equal(t, "v1", result.Form.GetString("k1")) 192 | assert.Equal(t, "v2", result.Form.GetString("k2")) 193 | } 194 | } 195 | 196 | func TestClient_AutoGzip(t *testing.T) { 197 | const dummyData = "hello world" 198 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 199 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 200 | w.Header().Set("Content-Encoding", "gzip") 201 | zw := gzip.NewWriter(w) 202 | _, _ = zw.Write([]byte(dummyData)) 203 | zw.Close() 204 | })) 205 | defer ts.Close() 206 | 207 | client := New() 208 | resp, err := client. 209 | Get(ts.URL, 210 | WithHeaders(Headers{ 211 | "Accept-Encoding": "gzip", 212 | }), 213 | ) 214 | if assert.NoError(t, err) { 215 | data, err := resp.Text() 216 | if assert.NoError(t, err) { 217 | assert.Equal(t, dummyData, data) 218 | } 219 | } 220 | } 221 | 222 | func TestClient_Get(t *testing.T) { 223 | client := New() 224 | resp, err := client.Get("https://httpbin.org/get") 225 | if assert.NoError(t, err) { 226 | assert.Equal(t, http.StatusOK, resp.StatusCode) 227 | } 228 | } 229 | 230 | func TestClient_Head(t *testing.T) { 231 | client := New() 232 | resp, err := client.Head("https://httpbin.org") 233 | if assert.NoError(t, err) { 234 | assert.Equal(t, http.StatusOK, resp.StatusCode) 235 | } 236 | } 237 | 238 | func TestClient_Post(t *testing.T) { 239 | client := New() 240 | resp, err := client.Post("https://httpbin.org/post") 241 | if assert.NoError(t, err) { 242 | assert.Equal(t, http.StatusOK, resp.StatusCode) 243 | } 244 | } 245 | 246 | func TestClient_Put(t *testing.T) { 247 | client := New() 248 | resp, err := client.Put("https://httpbin.org/put") 249 | if assert.NoError(t, err) { 250 | assert.Equal(t, http.StatusOK, resp.StatusCode) 251 | } 252 | } 253 | 254 | func TestClient_Patch(t *testing.T) { 255 | client := New() 256 | resp, err := client.Patch("https://httpbin.org/patch") 257 | if assert.NoError(t, err) { 258 | assert.Equal(t, http.StatusOK, resp.StatusCode) 259 | } 260 | } 261 | 262 | func TestClient_Delete(t *testing.T) { 263 | client := New() 264 | resp, err := client.Delete("https://httpbin.org/delete") 265 | if assert.NoError(t, err) { 266 | assert.Equal(t, http.StatusOK, resp.StatusCode) 267 | } 268 | } 269 | 270 | func TestClient_Options(t *testing.T) { 271 | client := New() 272 | resp, err := client.Options("https://httpbin.org") 273 | if assert.NoError(t, err) { 274 | assert.Equal(t, http.StatusOK, resp.StatusCode) 275 | } 276 | } 277 | 278 | func TestClient_Send(t *testing.T) { 279 | client := New() 280 | _, err := client.Send(MethodPost, "https://httpbin.org/post", func(req *Request) error { 281 | return errAccessDummyBody 282 | }) 283 | assert.Equal(t, errAccessDummyBody, err) 284 | } 285 | 286 | func TestClient_Do(t *testing.T) { 287 | client := New() 288 | dummyRequest := &Request{ 289 | Request: &http.Request{ 290 | Method: MethodPost, 291 | URL: &neturl.URL{ 292 | Scheme: "https", 293 | Host: "httpbin.org", 294 | Path: "/post", 295 | }, 296 | Proto: "HTTP/1.1", 297 | ProtoMajor: 1, 298 | ProtoMinor: 1, 299 | Header: make(http.Header), 300 | Body: &dummyBody{errFlag: errRead}, 301 | Host: "httpbin.org", 302 | }, 303 | } 304 | dummyRequest.EnableRetrier() 305 | _, err := client.Do(dummyRequest) 306 | assert.Equal(t, errAccessDummyBody, err) 307 | 308 | var dummyRequestHook RequestHook = func(req *Request) error { 309 | return errAccessDummyBody 310 | } 311 | client.RegisterBeforeRequestCallbacks(dummyRequestHook) 312 | _, err = client.Do(dummyRequest) 313 | assert.Equal(t, errAccessDummyBody, err) 314 | } 315 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io/ioutil" 7 | "math" 8 | "net/http" 9 | "net/http/httptest" 10 | neturl "net/url" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | func TestNewRequest(t *testing.T) { 20 | const ( 21 | validURL = "https://www.example.com" 22 | invalidURL = "https://www.example.com^" 23 | ) 24 | 25 | _, err := NewRequest(MethodGet, validURL) 26 | assert.NoError(t, err) 27 | 28 | _, err = NewRequest(MethodGet, invalidURL) 29 | assert.Error(t, err) 30 | } 31 | 32 | func TestRequest_SetQuery(t *testing.T) { 33 | params := Params{ 34 | "k1": "hello", 35 | "k2": []string{"hello", "hi"}, 36 | } 37 | 38 | dummyRequest := &Request{Request: &http.Request{ 39 | URL: &neturl.URL{}, 40 | }} 41 | dummyRequest.SetQuery(params) 42 | assert.Equal(t, params.EncodeToURL(true), dummyRequest.URL.RawQuery) 43 | } 44 | 45 | func TestRequest_SetHeaders(t *testing.T) { 46 | headers := Headers{ 47 | "host": "google.com", 48 | "k1": "v1", 49 | "k2": "v2", 50 | } 51 | dummyRequest := &Request{Request: &http.Request{ 52 | Header: make(http.Header), 53 | }} 54 | dummyRequest.SetHeaders(headers) 55 | assert.Equal(t, headers["host"], dummyRequest.Host) 56 | assert.Equal(t, headers["k1"], dummyRequest.Header.Get("K1")) 57 | assert.Equal(t, headers["k2"], dummyRequest.Header.Get("K2")) 58 | } 59 | 60 | func TestRequest_SetUserAgent(t *testing.T) { 61 | const userAgent = "Go-http-client" 62 | 63 | dummyRequest := &Request{Request: &http.Request{ 64 | Header: make(http.Header), 65 | }} 66 | dummyRequest.SetUserAgent(userAgent) 67 | assert.Equal(t, userAgent, dummyRequest.Header.Get("User-Agent")) 68 | } 69 | 70 | func TestRequest_SetOrigin(t *testing.T) { 71 | const origin = "https://www.google.com" 72 | 73 | dummyRequest := &Request{Request: &http.Request{ 74 | Header: make(http.Header), 75 | }} 76 | dummyRequest.SetOrigin(origin) 77 | assert.Equal(t, origin, dummyRequest.Header.Get("Origin")) 78 | } 79 | 80 | func TestRequest_SetReferer(t *testing.T) { 81 | const referer = "https://www.google.com" 82 | 83 | dummyRequest := &Request{Request: &http.Request{ 84 | Header: make(http.Header), 85 | }} 86 | dummyRequest.SetReferer(referer) 87 | assert.Equal(t, referer, dummyRequest.Header.Get("Referer")) 88 | } 89 | 90 | func TestRequest_SetBearerToken(t *testing.T) { 91 | const token = "ghttp" 92 | 93 | dummyRequest := &Request{Request: &http.Request{ 94 | Header: make(http.Header), 95 | }} 96 | dummyRequest.SetBearerToken(token) 97 | assert.Equal(t, "Bearer "+token, dummyRequest.Header.Get("Authorization")) 98 | } 99 | 100 | func TestRequest_AddCookies(t *testing.T) { 101 | dummyRequest := &Request{Request: &http.Request{ 102 | Header: make(http.Header), 103 | }} 104 | cookies := Cookies{ 105 | "n1": "v1", 106 | "n2": "v2", 107 | } 108 | dummyRequest.AddCookies(cookies) 109 | assert.Len(t, dummyRequest.Cookies(), len(cookies)) 110 | } 111 | 112 | func TestRequest_SetBody(t *testing.T) { 113 | const dummyData = "hello world" 114 | 115 | dummyRequest := &Request{Request: &http.Request{}} 116 | dummyRequest.SetBody(bytes.NewBuffer([]byte(dummyData))) 117 | assert.True(t, int64(len(dummyData)) == dummyRequest.ContentLength) 118 | if assert.NotNil(t, dummyRequest.GetBody) { 119 | rc, err := dummyRequest.GetBody() 120 | if assert.NoError(t, err) { 121 | assert.NotNil(t, rc) 122 | } 123 | } 124 | 125 | dummyRequest.SetBody(bytes.NewReader([]byte(dummyData))) 126 | assert.True(t, int64(len(dummyData)) == dummyRequest.ContentLength) 127 | if assert.NotNil(t, dummyRequest.GetBody) { 128 | rc, err := dummyRequest.GetBody() 129 | if assert.NoError(t, err) { 130 | assert.NotNil(t, rc) 131 | } 132 | } 133 | 134 | dummyRequest.SetBody(strings.NewReader(dummyData)) 135 | assert.True(t, int64(len(dummyData)) == dummyRequest.ContentLength) 136 | if assert.NotNil(t, dummyRequest.GetBody) { 137 | rc, err := dummyRequest.GetBody() 138 | if assert.NoError(t, err) { 139 | assert.NotNil(t, rc) 140 | } 141 | } 142 | 143 | dummyRequest.SetBody(bytes.NewBuffer(nil)) 144 | assert.Zero(t, dummyRequest.ContentLength) 145 | if assert.NotNil(t, dummyRequest.GetBody) { 146 | rc, err := dummyRequest.GetBody() 147 | if assert.NoError(t, err) { 148 | assert.True(t, rc == http.NoBody) 149 | } 150 | } 151 | } 152 | 153 | func TestRequest_SetContent(t *testing.T) { 154 | const dummyData = "hello world" 155 | 156 | dummyRequest := &Request{Request: &http.Request{}} 157 | dummyRequest.SetContent([]byte(dummyData)) 158 | if assert.NotNil(t, dummyRequest.Body) { 159 | defer dummyRequest.Body.Close() 160 | b, err := ioutil.ReadAll(dummyRequest.Body) 161 | if assert.NoError(t, err) { 162 | assert.Equal(t, dummyData, string(b)) 163 | } 164 | } 165 | } 166 | 167 | func TestRequest_SetText(t *testing.T) { 168 | const dummyData = "hello world" 169 | 170 | dummyRequest := &Request{Request: &http.Request{ 171 | Header: make(http.Header), 172 | }} 173 | dummyRequest.SetText(dummyData) 174 | assert.Equal(t, "text/plain; charset=utf-8", dummyRequest.Header.Get("Content-Type")) 175 | if assert.NotNil(t, dummyRequest.Body) { 176 | defer dummyRequest.Body.Close() 177 | b, err := ioutil.ReadAll(dummyRequest.Body) 178 | if assert.NoError(t, err) { 179 | assert.Equal(t, dummyData, string(b)) 180 | } 181 | } 182 | } 183 | 184 | func TestRequest_SetForm(t *testing.T) { 185 | form := Form{ 186 | "k1": "hello", 187 | "k2": []string{"hello", "hi"}, 188 | } 189 | dummyRequest := &Request{Request: &http.Request{ 190 | Header: make(http.Header), 191 | }} 192 | dummyRequest.SetForm(form) 193 | assert.Equal(t, "application/x-www-form-urlencoded", dummyRequest.Header.Get("Content-Type")) 194 | if assert.NotNil(t, dummyRequest.Body) { 195 | defer dummyRequest.Body.Close() 196 | b, err := ioutil.ReadAll(dummyRequest.Body) 197 | if assert.NoError(t, err) { 198 | assert.Equal(t, form.EncodeToURL(true), string(b)) 199 | } 200 | } 201 | } 202 | 203 | func TestRequest_SetJSON(t *testing.T) { 204 | dummyRequest := &Request{Request: &http.Request{ 205 | Header: make(http.Header), 206 | }} 207 | err := dummyRequest.SetJSON(map[string]interface{}{ 208 | "num": math.Inf(1), 209 | }) 210 | assert.Error(t, err) 211 | 212 | dummyRequest = &Request{Request: &http.Request{ 213 | Header: make(http.Header), 214 | }} 215 | err = dummyRequest.SetJSON(map[string]interface{}{ 216 | "msg": "hello world", 217 | }) 218 | if assert.NoError(t, err) { 219 | assert.Equal(t, "application/json", dummyRequest.Header.Get("Content-Type")) 220 | if assert.NotNil(t, dummyRequest.Body) { 221 | defer dummyRequest.Body.Close() 222 | b, err := ioutil.ReadAll(dummyRequest.Body) 223 | if assert.NoError(t, err) { 224 | assert.Equal(t, `{"msg":"hello world"}`, string(b)) 225 | } 226 | } 227 | } 228 | } 229 | 230 | func TestRequest_SetFiles(t *testing.T) { 231 | client := New() 232 | 233 | result := new(postmanResponse) 234 | resp, err := client. 235 | Post("https://httpbin.org/post", 236 | WithFiles(Files{ 237 | "file0": FileFromReader(&dummyBody{s: "hello world", errFlag: errRead}).WithFilename("dummyFile.txt"), 238 | "file1": MustOpen("./testdata/testfile1.txt"), 239 | "file2": FileFromReader(strings.NewReader("

This is a text file from memory

")), 240 | }), 241 | ) 242 | require.NoError(t, err) 243 | 244 | err = resp.JSON(result) 245 | if assert.NoError(t, err) { 246 | assert.Equal(t, "testfile1.txt", result.Files.GetString("file1")) 247 | assert.Equal(t, "

This is a text file from memory

", result.Files.GetString("file2")) 248 | } 249 | } 250 | 251 | func TestRequest_SetContext(t *testing.T) { 252 | dummyRequest := &Request{Request: &http.Request{ 253 | Header: make(http.Header), 254 | }} 255 | assert.Equal(t, context.Background(), dummyRequest.Context()) 256 | 257 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 258 | defer cancel() 259 | dummyRequest.SetContext(ctx) 260 | assert.Equal(t, ctx, dummyRequest.Context()) 261 | } 262 | 263 | func TestRequest_EnableRetrier(t *testing.T) { 264 | const ( 265 | dummyData = "hello world" 266 | n = 5 267 | ) 268 | 269 | attempts := 0 270 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 271 | attempts++ 272 | if attempts%n == 0 { 273 | w.WriteHeader(http.StatusOK) 274 | } else { 275 | w.WriteHeader(http.StatusTooManyRequests) 276 | } 277 | })) 278 | defer ts.Close() 279 | 280 | client := New() 281 | resp, err := client. 282 | Post(ts.URL, 283 | WithText(dummyData), 284 | WithRetrier(), 285 | ) 286 | if assert.NoError(t, err) { 287 | assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) 288 | assert.Equal(t, defaultRetryMaxAttempts, attempts-1) 289 | } 290 | 291 | resp, err = client. 292 | Get(ts.URL, 293 | WithRetrier( 294 | WithRetryMaxAttempts(n), 295 | ), 296 | ) 297 | if assert.NoError(t, err) { 298 | assert.Equal(t, http.StatusOK, resp.StatusCode) 299 | assert.Equal(t, n, attempts) 300 | } 301 | 302 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 303 | defer cancel() 304 | _, err = client. 305 | Get(ts.URL, 306 | WithContext(ctx), 307 | WithRetrier(), 308 | ) 309 | assert.Error(t, err) 310 | } 311 | 312 | func TestRequest_EnableClientTrace(t *testing.T) { 313 | dummyRequest := &Request{} 314 | assert.False(t, dummyRequest.clientTrace) 315 | 316 | dummyRequest.EnableClientTrace() 317 | assert.True(t, dummyRequest.clientTrace) 318 | } 319 | 320 | func TestRequest_Dump(t *testing.T) { 321 | req, err := NewRequest(MethodGet, "https://httpbin.org/get") 322 | if assert.NoError(t, err) { 323 | _, err = req.Dump(false) 324 | assert.NoError(t, err) 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "compress/gzip" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "net/http/cookiejar" 11 | neturl "net/url" 12 | "strings" 13 | "time" 14 | 15 | "golang.org/x/net/publicsuffix" 16 | "golang.org/x/time/rate" 17 | ) 18 | 19 | var ( 20 | // DefaultClient is a global Client used by the global methods such as Get, Post, etc. 21 | DefaultClient = New() 22 | 23 | // ProxyFromEnvironment is an alias of http.ProxyFromEnvironment. 24 | ProxyFromEnvironment = http.ProxyFromEnvironment 25 | ) 26 | 27 | // HTTP verbs via the global Client. 28 | var ( 29 | Get = DefaultClient.Get 30 | Head = DefaultClient.Head 31 | Post = DefaultClient.Post 32 | Put = DefaultClient.Put 33 | Patch = DefaultClient.Patch 34 | Delete = DefaultClient.Delete 35 | Options = DefaultClient.Options 36 | Send = DefaultClient.Send 37 | ) 38 | 39 | type ( 40 | // Client is a wrapper around an http.Client. 41 | Client struct { 42 | *http.Client 43 | beforeRequestCallbacks []BeforeRequestCallback 44 | afterResponseCallbacks []AfterResponseCallback 45 | } 46 | ) 47 | 48 | // New returns a new Client. 49 | func New() *Client { 50 | jar, _ := cookiejar.New(&cookiejar.Options{ 51 | PublicSuffixList: publicsuffix.List, 52 | }) 53 | client := &http.Client{ 54 | Transport: DefaultTransport(), 55 | Jar: jar, 56 | } 57 | return &Client{ 58 | Client: client, 59 | } 60 | } 61 | 62 | // NoRedirect is a redirect policy that makes the Client not follow redirects. 63 | func NoRedirect(*http.Request, []*http.Request) error { 64 | return http.ErrUseLastResponse 65 | } 66 | 67 | // MaxRedirects returns a redirect policy for limiting maximum redirects followed up by n. 68 | func MaxRedirects(n int) func(req *http.Request, via []*http.Request) error { 69 | return func(req *http.Request, via []*http.Request) error { 70 | if len(via) >= n { 71 | return http.ErrUseLastResponse 72 | } 73 | 74 | return nil 75 | } 76 | } 77 | 78 | // ProxyURL returns a proxy function (for use in an http.Transport) given a URL. 79 | func ProxyURL(url string) func(*http.Request) (*neturl.URL, error) { 80 | return func(*http.Request) (*neturl.URL, error) { 81 | return neturl.Parse(url) 82 | } 83 | } 84 | 85 | // SetProxy specifies a function to return a proxy for a given Request while nil indicates no proxy. 86 | // By default is ProxyFromEnvironment. 87 | func (c *Client) SetProxy(proxy func(*http.Request) (*neturl.URL, error)) { 88 | c.Transport.(*http.Transport).Proxy = proxy 89 | } 90 | 91 | // SetTLSClientConfig specifies the TLS configuration to use with tls.Client. 92 | func (c *Client) SetTLSClientConfig(config *tls.Config) { 93 | c.Transport.(*http.Transport).TLSClientConfig = config 94 | } 95 | 96 | // AddClientCerts adds client certificates to c. 97 | func (c *Client) AddClientCerts(certs ...tls.Certificate) { 98 | t := c.Transport.(*http.Transport) 99 | if t.TLSClientConfig == nil { 100 | t.TLSClientConfig = &tls.Config{} 101 | } 102 | t.TLSClientConfig.Certificates = append(t.TLSClientConfig.Certificates, certs...) 103 | } 104 | 105 | // AddRootCerts attempts to add root certificates to c from a series of PEM encoded certificates 106 | // and reports whether any certificates were successfully added. 107 | func (c *Client) AddRootCerts(pemCerts []byte) bool { 108 | t := c.Transport.(*http.Transport) 109 | if t.TLSClientConfig == nil { 110 | t.TLSClientConfig = &tls.Config{} 111 | } 112 | if t.TLSClientConfig.RootCAs == nil { 113 | t.TLSClientConfig.RootCAs = x509.NewCertPool() 114 | } 115 | return t.TLSClientConfig.RootCAs.AppendCertsFromPEM(pemCerts) 116 | } 117 | 118 | // DisableTLSVerify makes c not verify the server's TLS certificate. 119 | func (c *Client) DisableTLSVerify() { 120 | t := c.Transport.(*http.Transport) 121 | if t.TLSClientConfig == nil { 122 | t.TLSClientConfig = &tls.Config{} 123 | } 124 | t.TLSClientConfig.InsecureSkipVerify = true 125 | } 126 | 127 | // AddCookies adds cookies to send in a request for the given URL to cookie jar. 128 | func (c *Client) AddCookies(url string, cookies ...*http.Cookie) { 129 | u, err := neturl.Parse(url) 130 | if err == nil { 131 | c.Jar.SetCookies(u, cookies) 132 | } 133 | } 134 | 135 | // Cookies returns the cookies to send in a request for the given URL from cookie jar. 136 | func (c *Client) Cookies(url string) (cookies []*http.Cookie) { 137 | u, err := neturl.Parse(url) 138 | if err == nil { 139 | cookies = c.Jar.Cookies(u) 140 | } 141 | return 142 | } 143 | 144 | // Cookie returns the named cookie to send in a request for the given URL from cookie jar. 145 | // If multiple cookies match the given name, only one cookie will be returned. 146 | func (c *Client) Cookie(url string, name string) (*http.Cookie, error) { 147 | return findCookie(name, c.Cookies(url)) 148 | } 149 | 150 | // RegisterBeforeRequestCallbacks appends c's before request callbacks. 151 | func (c *Client) RegisterBeforeRequestCallbacks(callbacks ...BeforeRequestCallback) { 152 | c.beforeRequestCallbacks = append(c.beforeRequestCallbacks, callbacks...) 153 | } 154 | 155 | // RegisterAfterResponseCallbacks appends c's after response callbacks. 156 | func (c *Client) RegisterAfterResponseCallbacks(callbacks ...AfterResponseCallback) { 157 | c.afterResponseCallbacks = append(c.afterResponseCallbacks, callbacks...) 158 | } 159 | 160 | // EnableRateLimiting adds a callback to c for limiting outbound requests 161 | // given a rate.Limiter (provided by golang.org/x/time/rate package). 162 | func (c *Client) EnableRateLimiting(limiter *rate.Limiter) { 163 | c.RegisterBeforeRequestCallbacks(&rateLimiter{base: limiter}) 164 | } 165 | 166 | // SetMaxConcurrency adds a callback to c for limiting the concurrent outbound requests up by n. 167 | func (c *Client) SetMaxConcurrency(n int) { 168 | callback := &concurrency{ch: make(chan struct{}, n)} 169 | c.RegisterBeforeRequestCallbacks(callback) 170 | c.RegisterAfterResponseCallbacks(callback) 171 | } 172 | 173 | // EnableDebugging adds a callback to c for debugging. 174 | // ghttp will dump the request and response details to w, like "curl -v". 175 | func (c *Client) EnableDebugging(w io.Writer, body bool) { 176 | callback := &debugger{out: w, body: body} 177 | c.RegisterBeforeRequestCallbacks(callback) 178 | c.RegisterAfterResponseCallbacks(callback) 179 | } 180 | 181 | // Get makes a GET HTTP request. 182 | func (c *Client) Get(url string, hooks ...RequestHook) (*Response, error) { 183 | return c.Send(MethodGet, url, hooks...) 184 | } 185 | 186 | // Head makes a HEAD HTTP request. 187 | func (c *Client) Head(url string, hooks ...RequestHook) (*Response, error) { 188 | return c.Send(MethodHead, url, hooks...) 189 | } 190 | 191 | // Post makes a POST HTTP request. 192 | func (c *Client) Post(url string, hooks ...RequestHook) (*Response, error) { 193 | return c.Send(MethodPost, url, hooks...) 194 | } 195 | 196 | // Put makes a PUT HTTP request. 197 | func (c *Client) Put(url string, hooks ...RequestHook) (*Response, error) { 198 | return c.Send(MethodPut, url, hooks...) 199 | } 200 | 201 | // Patch makes a PATCH HTTP request. 202 | func (c *Client) Patch(url string, hooks ...RequestHook) (*Response, error) { 203 | return c.Send(MethodPatch, url, hooks...) 204 | } 205 | 206 | // Delete makes a DELETE HTTP request. 207 | func (c *Client) Delete(url string, hooks ...RequestHook) (*Response, error) { 208 | return c.Send(MethodDelete, url, hooks...) 209 | } 210 | 211 | // Options makes a OPTIONS HTTP request. 212 | func (c *Client) Options(url string, hooks ...RequestHook) (*Response, error) { 213 | return c.Send(MethodOptions, url, hooks...) 214 | } 215 | 216 | // Send makes an HTTP request using a particular method. 217 | func (c *Client) Send(method string, url string, hooks ...RequestHook) (*Response, error) { 218 | req, err := NewRequest(method, url) 219 | if err == nil { 220 | for _, hook := range hooks { 221 | if err = hook(req); err != nil { 222 | break 223 | } 224 | } 225 | } 226 | if err != nil { 227 | return nil, err 228 | } 229 | 230 | return c.Do(req) 231 | } 232 | 233 | // Do sends a request and returns its response. 234 | func (c *Client) Do(req *Request) (resp *Response, err error) { 235 | if err = c.onBeforeRequest(req); err != nil { 236 | return 237 | } 238 | 239 | if req.retrier != nil { 240 | if err = req.retrier.modifyRequest(req); err != nil { 241 | return 242 | } 243 | } 244 | 245 | resp, err = c.doWithRetry(req) 246 | c.onAfterResponse(resp, err) 247 | return 248 | } 249 | 250 | func (c *Client) onBeforeRequest(req *Request) (err error) { 251 | for _, callback := range c.beforeRequestCallbacks { 252 | if err = callback.Enter(req); err != nil { 253 | break 254 | } 255 | } 256 | return 257 | } 258 | 259 | func (c *Client) doWithRetry(req *Request) (*Response, error) { 260 | var err error 261 | var sleep time.Duration 262 | resp := new(Response) 263 | for attemptNum := 0; ; attemptNum++ { 264 | if req.clientTrace { 265 | ct := &clientTrace{start: time.Now()} 266 | ct.modifyRequest(req) 267 | resp.clientTrace = ct 268 | } 269 | resp.Response, err = c.do(req.Request) 270 | if req.clientTrace { 271 | resp.clientTrace.done() 272 | } 273 | 274 | if req.retrier == nil || !req.retrier.on(req.Context(), attemptNum, resp, err) { 275 | return resp, err 276 | } 277 | 278 | sleep = req.retrier.backoff.Wait(attemptNum, resp, err) 279 | // Drain Response.Body to enable TCP/TLS connection reuse 280 | if err == nil && drainBody(resp.Body, ioutil.Discard) != http.ErrBodyReadAfterClose { 281 | resp.Body.Close() 282 | } 283 | 284 | if req.GetBody != nil { 285 | req.Body, _ = req.GetBody() 286 | } 287 | 288 | select { 289 | case <-time.After(sleep): 290 | case <-req.Context().Done(): 291 | return resp, req.Context().Err() 292 | } 293 | } 294 | } 295 | 296 | func (c *Client) do(req *http.Request) (*http.Response, error) { 297 | resp, err := c.Client.Do(req) 298 | if err != nil { 299 | return resp, err 300 | } 301 | 302 | if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") && 303 | !bodyEmpty(resp.Body) { 304 | if _, ok := resp.Body.(*gzip.Reader); !ok { 305 | body, err := gzip.NewReader(resp.Body) 306 | resp.Body.Close() 307 | resp.Body = body 308 | return resp, err 309 | } 310 | } 311 | 312 | return resp, nil 313 | } 314 | 315 | func (c *Client) onAfterResponse(resp *Response, err error) { 316 | for _, callback := range c.afterResponseCallbacks { 317 | callback.Exit(resp, err) 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package ghttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httputil" 10 | "strings" 11 | 12 | "github.com/winterssy/gjson" 13 | ) 14 | 15 | // Common HTTP methods. 16 | const ( 17 | MethodGet = "GET" 18 | MethodHead = "HEAD" 19 | MethodPost = "POST" 20 | MethodPut = "PUT" 21 | MethodPatch = "PATCH" 22 | MethodDelete = "DELETE" 23 | MethodOptions = "OPTIONS" 24 | MethodConnect = "CONNECT" 25 | MethodTrace = "TRACE" 26 | ) 27 | 28 | type ( 29 | // Request is a wrapper around an http.Request. 30 | Request struct { 31 | *http.Request 32 | retrier *retrier 33 | clientTrace bool 34 | } 35 | 36 | // RequestHook is a function that implements BeforeRequestCallback interface. 37 | // It provides a elegant way to configure a Request. 38 | RequestHook func(req *Request) error 39 | ) 40 | 41 | // Enter implements BeforeRequestCallback interface. 42 | func (rh RequestHook) Enter(req *Request) error { 43 | return rh(req) 44 | } 45 | 46 | // NewRequest returns a new Request given a method, URL. 47 | func NewRequest(method string, url string) (*Request, error) { 48 | rawRequest, err := http.NewRequest(method, url, nil) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return &Request{ 54 | Request: rawRequest, 55 | }, nil 56 | } 57 | 58 | // SetQuery sets query parameters for req. 59 | // It replaces any existing values. 60 | func (req *Request) SetQuery(params Params) { 61 | query := req.URL.Query() 62 | for k, vs := range params.Decode() { 63 | query[k] = vs 64 | } 65 | req.URL.RawQuery = query.Encode() 66 | } 67 | 68 | // SetHeaders sets headers for req. 69 | // It replaces any existing values. 70 | func (req *Request) SetHeaders(headers Headers) { 71 | for k, vs := range headers.Decode() { 72 | k = http.CanonicalHeaderKey(k) 73 | if k == "Host" && len(vs) > 0 { 74 | req.Host = vs[0] 75 | } else { 76 | req.Header[k] = vs 77 | } 78 | } 79 | } 80 | 81 | // SetContentType sets Content-Type header value for req. 82 | func (req *Request) SetContentType(contentType string) { 83 | req.Header.Set("Content-Type", contentType) 84 | } 85 | 86 | // SetUserAgent sets User-Agent header value for req. 87 | func (req *Request) SetUserAgent(userAgent string) { 88 | req.Header.Set("User-Agent", userAgent) 89 | } 90 | 91 | // SetOrigin sets Origin header value for req. 92 | func (req *Request) SetOrigin(origin string) { 93 | req.Header.Set("Origin", origin) 94 | } 95 | 96 | // SetReferer sets Referer header value for req. 97 | func (req *Request) SetReferer(referer string) { 98 | req.Header.Set("Referer", referer) 99 | } 100 | 101 | // SetBearerToken sets bearer token for req. 102 | func (req *Request) SetBearerToken(token string) { 103 | req.Header.Set("Authorization", "Bearer "+token) 104 | } 105 | 106 | // AddCookies adds cookies to req. 107 | func (req *Request) AddCookies(cookies Cookies) { 108 | for _, c := range cookies.Decode() { 109 | req.AddCookie(c) 110 | } 111 | } 112 | 113 | // SetBody sets body for req. 114 | func (req *Request) SetBody(body io.Reader) { 115 | req.Body = toReadCloser(body) 116 | if body != nil { 117 | switch v := body.(type) { 118 | case *bytes.Buffer: 119 | req.ContentLength = int64(v.Len()) 120 | buf := v.Bytes() 121 | req.GetBody = func() (io.ReadCloser, error) { 122 | r := bytes.NewReader(buf) 123 | return ioutil.NopCloser(r), nil 124 | } 125 | case *bytes.Reader: 126 | req.ContentLength = int64(v.Len()) 127 | snapshot := *v 128 | req.GetBody = func() (io.ReadCloser, error) { 129 | r := snapshot 130 | return ioutil.NopCloser(&r), nil 131 | } 132 | case *strings.Reader: 133 | req.ContentLength = int64(v.Len()) 134 | snapshot := *v 135 | req.GetBody = func() (io.ReadCloser, error) { 136 | r := snapshot 137 | return ioutil.NopCloser(&r), nil 138 | } 139 | default: 140 | // This is where we'd set it to -1 (at least 141 | // if body != NoBody) to mean unknown, but 142 | // that broke people during the Go 1.8 testing 143 | // period. People depend on it being 0 I 144 | // guess. Maybe retry later. See Issue 18117. 145 | } 146 | // For client requests, Request.ContentLength of 0 147 | // means either actually 0, or unknown. The only way 148 | // to explicitly say that the ContentLength is zero is 149 | // to set the Body to nil. But turns out too much code 150 | // depends on NewRequest returning a non-nil Body, 151 | // so we use a well-known ReadCloser variable instead 152 | // and have the http package also treat that sentinel 153 | // variable to mean explicitly zero. 154 | if req.GetBody != nil && req.ContentLength == 0 { 155 | req.Body = http.NoBody 156 | req.GetBody = func() (io.ReadCloser, error) { return http.NoBody, nil } 157 | } 158 | } 159 | } 160 | 161 | // SetContent sets bytes payload for req. 162 | func (req *Request) SetContent(content []byte) { 163 | req.SetBody(bytes.NewReader(content)) 164 | } 165 | 166 | // SetText sets plain text payload for req. 167 | func (req *Request) SetText(text string) { 168 | req.SetBody(strings.NewReader(text)) 169 | req.SetContentType("text/plain; charset=utf-8") 170 | } 171 | 172 | // SetForm sets form payload for req. 173 | func (req *Request) SetForm(form Form) { 174 | req.SetBody(strings.NewReader(form.EncodeToURL(true))) 175 | req.SetContentType("application/x-www-form-urlencoded") 176 | } 177 | 178 | // SetJSON sets JSON payload for req. 179 | func (req *Request) SetJSON(data interface{}, opts ...func(enc *gjson.Encoder)) error { 180 | b, err := gjson.Encode(data, opts...) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | req.SetBody(bytes.NewReader(b)) 186 | req.SetContentType("application/json") 187 | return nil 188 | } 189 | 190 | // SetFiles sets files payload for req. 191 | func (req *Request) SetFiles(files Files) { 192 | formData := NewMultipart(files) 193 | req.SetBody(formData) 194 | req.SetContentType(formData.ContentType()) 195 | } 196 | 197 | // SetContext sets context for req. 198 | func (req *Request) SetContext(ctx context.Context) { 199 | req.Request = req.WithContext(ctx) 200 | } 201 | 202 | // EnableRetrier enables retrier for req. 203 | func (req *Request) EnableRetrier(opts ...RetryOption) { 204 | retrier := defaultRetrier() 205 | for _, opt := range opts { 206 | opt(retrier) 207 | } 208 | req.retrier = retrier 209 | } 210 | 211 | // WithClientTrace enables client trace for req using httptrace.ClientTrace. 212 | func (req *Request) EnableClientTrace() { 213 | req.clientTrace = true 214 | } 215 | 216 | // Dump returns the HTTP/1.x wire representation of req. 217 | func (req *Request) Dump(withBody bool) ([]byte, error) { 218 | return httputil.DumpRequestOut(req.Request, withBody) 219 | } 220 | 221 | // WithQuery is a request hook to set query parameters. 222 | // It replaces any existing values. 223 | func WithQuery(params Params) RequestHook { 224 | return func(req *Request) error { 225 | req.SetQuery(params) 226 | return nil 227 | } 228 | } 229 | 230 | // WithHeaders is a request hook to set headers. 231 | // It replaces any existing values. 232 | func WithHeaders(headers Headers) RequestHook { 233 | return func(req *Request) error { 234 | req.SetHeaders(headers) 235 | return nil 236 | } 237 | } 238 | 239 | // WithContentType is a request hook to set Content-Type header value. 240 | func WithContentType(contentType string) RequestHook { 241 | return func(req *Request) error { 242 | req.SetContentType(contentType) 243 | return nil 244 | } 245 | } 246 | 247 | // WithUserAgent is a request hook to set User-Agent header value. 248 | func WithUserAgent(userAgent string) RequestHook { 249 | return func(req *Request) error { 250 | req.SetUserAgent(userAgent) 251 | return nil 252 | } 253 | } 254 | 255 | // WithOrigin is a request hook to set Origin header value. 256 | func WithOrigin(origin string) RequestHook { 257 | return func(req *Request) error { 258 | req.SetOrigin(origin) 259 | return nil 260 | } 261 | } 262 | 263 | // WithReferer is a request hook to set Referer header value. 264 | func WithReferer(referer string) RequestHook { 265 | return func(req *Request) error { 266 | req.SetReferer(referer) 267 | return nil 268 | } 269 | } 270 | 271 | // WithBasicAuth is a request hook to set basic authentication. 272 | func WithBasicAuth(username string, password string) RequestHook { 273 | return func(req *Request) error { 274 | req.SetBasicAuth(username, password) 275 | return nil 276 | } 277 | } 278 | 279 | // WithBearerToken is a request hook to set bearer token. 280 | func WithBearerToken(token string) RequestHook { 281 | return func(req *Request) error { 282 | req.SetBearerToken(token) 283 | return nil 284 | } 285 | } 286 | 287 | // WithCookies is a request hook to add cookies. 288 | func WithCookies(cookies Cookies) RequestHook { 289 | return func(req *Request) error { 290 | req.AddCookies(cookies) 291 | return nil 292 | } 293 | } 294 | 295 | // WithBody is a request hook to set body. 296 | func WithBody(body io.Reader) RequestHook { 297 | return func(req *Request) error { 298 | req.SetBody(body) 299 | return nil 300 | } 301 | } 302 | 303 | // WithContent is a request hook to set bytes payload. 304 | func WithContent(content []byte) RequestHook { 305 | return func(req *Request) error { 306 | req.SetContent(content) 307 | return nil 308 | } 309 | } 310 | 311 | // WithText is a request hook to set plain text payload. 312 | func WithText(text string) RequestHook { 313 | return func(req *Request) error { 314 | req.SetText(text) 315 | return nil 316 | } 317 | } 318 | 319 | // WithForm is a request hook to set form payload. 320 | func WithForm(form Form) RequestHook { 321 | return func(req *Request) error { 322 | req.SetForm(form) 323 | return nil 324 | } 325 | } 326 | 327 | // WithJSON is a request hook to set JSON payload. 328 | func WithJSON(data interface{}, opts ...func(enc *gjson.Encoder)) RequestHook { 329 | return func(req *Request) error { 330 | return req.SetJSON(data, opts...) 331 | } 332 | } 333 | 334 | // WithFiles is a request hook to set files payload. 335 | func WithFiles(files Files) RequestHook { 336 | return func(req *Request) error { 337 | req.SetFiles(files) 338 | return nil 339 | } 340 | } 341 | 342 | // WithContext is a request hook to set context. 343 | func WithContext(ctx context.Context) RequestHook { 344 | return func(req *Request) error { 345 | req.SetContext(ctx) 346 | return nil 347 | } 348 | } 349 | 350 | // WithRetrier is a request hook to enable retrier. 351 | func WithRetrier(opts ...RetryOption) RequestHook { 352 | return func(req *Request) error { 353 | req.EnableRetrier(opts...) 354 | return nil 355 | } 356 | } 357 | 358 | // WithClientTrace is a request hook to enable client trace. 359 | func WithClientTrace() RequestHook { 360 | return func(req *Request) error { 361 | req.EnableClientTrace() 362 | return nil 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package ghttp_test 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | neturl "net/url" 12 | "os" 13 | "strings" 14 | "sync" 15 | 16 | "github.com/winterssy/ghttp" 17 | "golang.org/x/text/encoding/simplifiedchinese" 18 | "golang.org/x/time/rate" 19 | ) 20 | 21 | func Example_goStyleAPI() { 22 | client := ghttp.New() 23 | 24 | req, err := ghttp.NewRequest(ghttp.MethodPost, "https://httpbin.org/post") 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | req.SetQuery(ghttp.Params{ 30 | "k1": "v1", 31 | "k2": "v2", 32 | }) 33 | req.SetHeaders(ghttp.Headers{ 34 | "k3": "v3", 35 | "k4": "v4", 36 | }) 37 | req.SetForm(ghttp.Form{ 38 | "k5": "v5", 39 | "k6": "v6", 40 | }) 41 | 42 | resp, err := client.Do(req) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | 47 | fmt.Println(resp.StatusCode) 48 | } 49 | 50 | func Example_requestsStyleAPI() { 51 | client := ghttp.New() 52 | 53 | resp, err := client.Post("https://httpbin.org/post", 54 | ghttp.WithQuery(ghttp.Params{ 55 | "k1": "v1", 56 | "k2": "v2", 57 | }), 58 | ghttp.WithHeaders(ghttp.Headers{ 59 | "k3": "v3", 60 | "k4": "v4", 61 | }), 62 | ghttp.WithForm(ghttp.Form{ 63 | "k5": "v5", 64 | "k6": "v6", 65 | }), 66 | ) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | fmt.Println(resp.StatusCode) 72 | } 73 | 74 | func ExampleNoRedirect() { 75 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 76 | http.Redirect(w, r, "https://www.google.com", http.StatusFound) 77 | })) 78 | defer ts.Close() 79 | 80 | client := ghttp.New() 81 | client.CheckRedirect = ghttp.NoRedirect 82 | 83 | resp, err := client. 84 | Get(ts.URL) 85 | if err != nil { 86 | log.Print(err) 87 | return 88 | } 89 | 90 | fmt.Println(resp.StatusCode) 91 | // Output: 92 | // 302 93 | } 94 | 95 | func ExampleMaxRedirects() { 96 | var counter int 97 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | http.Redirect(w, r, r.URL.String(), http.StatusFound) 99 | counter++ 100 | })) 101 | defer ts.Close() 102 | 103 | client := ghttp.New() 104 | client.CheckRedirect = ghttp.MaxRedirects(3) 105 | 106 | resp, err := client.Get(ts.URL) 107 | if err != nil { 108 | log.Print(err) 109 | return 110 | } 111 | 112 | fmt.Println(counter) 113 | fmt.Println(resp.StatusCode) 114 | // Output: 115 | // 3 116 | // 302 117 | } 118 | 119 | func ExampleClient_SetProxy() { 120 | client := ghttp.New() 121 | client.SetProxy(ghttp.ProxyURL("socks5://127.0.0.1:1080")) 122 | 123 | resp, err := client.Get("https://www.google.com") 124 | if err != nil { 125 | log.Fatal(err) 126 | } 127 | 128 | fmt.Println(resp.StatusCode) 129 | } 130 | 131 | func ExampleClient_AddCookies() { 132 | client := ghttp.New() 133 | 134 | cookies := ghttp.Cookies{ 135 | "n1": "v1", 136 | "n2": "v2", 137 | } 138 | client.AddCookies("https://httpbin.org", cookies.Decode()...) 139 | 140 | resp, err := client.Get("https://httpbin.org/cookies") 141 | if err != nil { 142 | log.Print(err) 143 | return 144 | } 145 | 146 | result, err := resp.H() 147 | if err != nil { 148 | log.Print(err) 149 | return 150 | } 151 | 152 | fmt.Println(result.GetString("cookies", "n1")) 153 | fmt.Println(result.GetString("cookies", "n2")) 154 | // Output: 155 | // v1 156 | // v2 157 | } 158 | 159 | func ExampleClient_Cookie() { 160 | client := ghttp.New() 161 | 162 | _, err := client.Get("https://httpbin.org/cookies/set/uid/10086") 163 | if err != nil { 164 | log.Print(err) 165 | return 166 | } 167 | 168 | c, err := client.Cookie("https://httpbin.org", "uid") 169 | if err != nil { 170 | log.Print(err) 171 | return 172 | } 173 | 174 | fmt.Println(c.Name) 175 | fmt.Println(c.Value) 176 | // Output: 177 | // uid 178 | // 10086 179 | } 180 | 181 | func ExampleClient_DisableTLSVerify() { 182 | client := ghttp.New() 183 | client.DisableTLSVerify() 184 | 185 | resp, err := client.Get("https://self-signed.badssl.com") 186 | if err != nil { 187 | log.Print(err) 188 | return 189 | } 190 | 191 | fmt.Println(resp.StatusCode) 192 | // Output: 193 | // 200 194 | } 195 | 196 | func ExampleClient_AddClientCerts() { 197 | client := ghttp.New() 198 | 199 | cert, err := tls.LoadX509KeyPair("/path/client.cert", "/path/client.key") 200 | if err != nil { 201 | log.Fatal(err) 202 | } 203 | 204 | client.AddClientCerts(cert) 205 | resp, err := client.Get("https://self-signed.badssl.com") 206 | if err != nil { 207 | log.Fatal(err) 208 | } 209 | 210 | fmt.Println(resp.StatusCode) 211 | } 212 | 213 | func ExampleClient_AddRootCerts() { 214 | pemCerts, err := ioutil.ReadFile("/path/root-ca.pem") 215 | if err != nil { 216 | log.Fatal(err) 217 | } 218 | 219 | client := ghttp.New() 220 | client.AddRootCerts(pemCerts) 221 | 222 | resp, err := client.Get("https://self-signed.badssl.com") 223 | if err != nil { 224 | log.Fatal(err) 225 | } 226 | 227 | fmt.Println(resp.StatusCode) 228 | } 229 | 230 | func ExampleClient_RegisterBeforeRequestCallbacks() { 231 | withReverseProxy := func(target string) ghttp.RequestHook { 232 | return func(req *ghttp.Request) error { 233 | u, err := neturl.Parse(target) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | req.URL.Scheme = u.Scheme 239 | req.URL.Host = u.Host 240 | req.Host = u.Host 241 | req.SetOrigin(u.Host) 242 | return nil 243 | } 244 | } 245 | 246 | client := ghttp.New() 247 | client.RegisterBeforeRequestCallbacks(withReverseProxy("https://httpbin.org")) 248 | 249 | resp, err := client.Get("/get") 250 | if err == nil { 251 | fmt.Println(resp.StatusCode) 252 | } 253 | resp, err = client.Post("/post") 254 | if err == nil { 255 | fmt.Println(resp.StatusCode) 256 | } 257 | // Output: 258 | // 200 259 | // 200 260 | } 261 | 262 | func ExampleClient_EnableRateLimiting() { 263 | client := ghttp.New() 264 | client.EnableRateLimiting(rate.NewLimiter(1, 10)) 265 | 266 | var wg sync.WaitGroup 267 | for i := 0; i < 100; i++ { 268 | wg.Add(1) 269 | go func() { 270 | defer wg.Done() 271 | _, _ = client.Get("https://www.example.com") 272 | }() 273 | } 274 | wg.Wait() 275 | } 276 | 277 | func ExampleClient_SetMaxConcurrency() { 278 | client := ghttp.New() 279 | client.SetMaxConcurrency(32) 280 | 281 | var wg sync.WaitGroup 282 | for i := 0; i < 100; i++ { 283 | wg.Add(1) 284 | go func() { 285 | defer wg.Done() 286 | _, _ = client.Get("https://www.example.com") 287 | }() 288 | } 289 | wg.Wait() 290 | } 291 | 292 | func ExampleClient_EnableDebugging() { 293 | client := ghttp.New() 294 | client.EnableDebugging(os.Stdout, true) 295 | 296 | _, _ = client.Post("https://httpbin.org/post", 297 | ghttp.WithForm(ghttp.Form{ 298 | "k1": "v1", 299 | "k2": "v2", 300 | }), 301 | ) 302 | } 303 | 304 | func ExampleWithQuery() { 305 | client := ghttp.New() 306 | 307 | resp, err := client. 308 | Post("https://httpbin.org/post", 309 | ghttp.WithQuery(ghttp.Params{ 310 | "k1": "v1", 311 | "k2": "v2", 312 | }), 313 | ) 314 | if err != nil { 315 | log.Print(err) 316 | return 317 | } 318 | 319 | result, err := resp.H() 320 | if err != nil { 321 | log.Print(err) 322 | return 323 | } 324 | 325 | fmt.Println(result.GetString("args", "k1")) 326 | fmt.Println(result.GetString("args", "k2")) 327 | // Output: 328 | // v1 329 | // v2 330 | } 331 | 332 | func ExampleWithHeaders() { 333 | client := ghttp.New() 334 | 335 | resp, err := client. 336 | Post("https://httpbin.org/post", 337 | ghttp.WithHeaders(ghttp.Headers{ 338 | "k1": "v1", 339 | "k2": "v2", 340 | }), 341 | ) 342 | if err != nil { 343 | log.Print(err) 344 | return 345 | } 346 | 347 | result, err := resp.H() 348 | if err != nil { 349 | log.Print(err) 350 | return 351 | } 352 | 353 | fmt.Println(result.GetString("headers", "K1")) 354 | fmt.Println(result.GetString("headers", "K2")) 355 | // Output: 356 | // v1 357 | // v2 358 | } 359 | 360 | func ExampleWithUserAgent() { 361 | client := ghttp.New() 362 | 363 | resp, err := client. 364 | Get("https://httpbin.org/get", 365 | ghttp.WithUserAgent("ghttp"), 366 | ) 367 | if err != nil { 368 | log.Print(err) 369 | return 370 | } 371 | 372 | result, err := resp.H() 373 | if err != nil { 374 | log.Print(err) 375 | return 376 | } 377 | 378 | fmt.Println(result.GetString("headers", "User-Agent")) 379 | // Output: 380 | // ghttp 381 | } 382 | 383 | func ExampleWithReferer() { 384 | client := ghttp.New() 385 | 386 | resp, err := client. 387 | Get("https://httpbin.org/get", 388 | ghttp.WithReferer("https://www.google.com"), 389 | ) 390 | if err != nil { 391 | log.Print(err) 392 | return 393 | } 394 | 395 | result, err := resp.H() 396 | if err != nil { 397 | log.Print(err) 398 | return 399 | } 400 | 401 | fmt.Println(result.GetString("headers", "Referer")) 402 | // Output: 403 | // https://www.google.com 404 | } 405 | 406 | func ExampleWithOrigin() { 407 | client := ghttp.New() 408 | 409 | resp, err := client. 410 | Get("https://httpbin.org/get", 411 | ghttp.WithOrigin("https://www.google.com"), 412 | ) 413 | if err != nil { 414 | log.Print(err) 415 | return 416 | } 417 | 418 | result, err := resp.H() 419 | if err != nil { 420 | log.Print(err) 421 | return 422 | } 423 | 424 | fmt.Println(result.GetString("headers", "Origin")) 425 | // Output: 426 | // https://www.google.com 427 | } 428 | 429 | func ExampleWithBasicAuth() { 430 | client := ghttp.New() 431 | 432 | resp, err := client. 433 | Get("https://httpbin.org/basic-auth/admin/pass", 434 | ghttp.WithBasicAuth("admin", "pass"), 435 | ) 436 | if err != nil { 437 | log.Print(err) 438 | return 439 | } 440 | 441 | result, err := resp.H() 442 | if err != nil { 443 | log.Print(err) 444 | return 445 | } 446 | 447 | fmt.Println(result.GetBoolean("authenticated")) 448 | fmt.Println(result.GetString("user")) 449 | // Output: 450 | // true 451 | // admin 452 | } 453 | 454 | func ExampleWithBearerToken() { 455 | client := ghttp.New() 456 | 457 | resp, err := client. 458 | Get("https://httpbin.org/bearer", 459 | ghttp.WithBearerToken("ghttp"), 460 | ) 461 | if err != nil { 462 | log.Print(err) 463 | return 464 | } 465 | 466 | result, err := resp.H() 467 | if err != nil { 468 | log.Print(err) 469 | return 470 | } 471 | 472 | fmt.Println(result.GetBoolean("authenticated")) 473 | fmt.Println(result.GetString("token")) 474 | // Output: 475 | // true 476 | // ghttp 477 | } 478 | 479 | func ExampleWithCookies() { 480 | client := ghttp.New() 481 | 482 | resp, err := client. 483 | Get("https://httpbin.org/cookies", 484 | ghttp.WithCookies(ghttp.Cookies{ 485 | "n1": "v1", 486 | "n2": "v2", 487 | }), 488 | ) 489 | if err != nil { 490 | log.Print(err) 491 | return 492 | } 493 | 494 | result, err := resp.H() 495 | if err != nil { 496 | log.Print(err) 497 | return 498 | } 499 | 500 | fmt.Println(result.GetString("cookies", "n1")) 501 | fmt.Println(result.GetString("cookies", "n2")) 502 | // Output: 503 | // v1 504 | // v2 505 | } 506 | 507 | func ExampleWithBody() { 508 | client := ghttp.New() 509 | 510 | formData := ghttp. 511 | NewMultipart(ghttp.Files{ 512 | "file1": ghttp.MustOpen("./testdata/testfile1.txt"), 513 | "file2": ghttp.MustOpen("./testdata/testfile2.txt"), 514 | }). 515 | WithForm(ghttp.Form{ 516 | "k1": "v1", 517 | "k2": "v2", 518 | }) 519 | 520 | resp, err := client. 521 | Post("https://httpbin.org/post", 522 | ghttp.WithBody(formData), 523 | ghttp.WithContentType(formData.ContentType()), 524 | ) 525 | if err != nil { 526 | log.Print(err) 527 | return 528 | } 529 | 530 | result, err := resp.H() 531 | if err != nil { 532 | log.Print(err) 533 | return 534 | } 535 | 536 | fmt.Println(result.GetString("files", "file1")) 537 | fmt.Println(result.GetString("files", "file2")) 538 | fmt.Println(result.GetString("form", "k1")) 539 | fmt.Println(result.GetString("form", "k2")) 540 | // Output: 541 | // testfile1.txt 542 | // testfile2.txt 543 | // v1 544 | // v2 545 | } 546 | 547 | func ExampleWithContent() { 548 | client := ghttp.New() 549 | 550 | resp, err := client. 551 | Post("https://httpbin.org/post", 552 | ghttp.WithContent([]byte("hello world")), 553 | ghttp.WithContentType("text/plain; charset=utf-8"), 554 | ) 555 | if err != nil { 556 | log.Print(err) 557 | return 558 | } 559 | 560 | result, err := resp.H() 561 | if err != nil { 562 | log.Print(err) 563 | return 564 | } 565 | 566 | fmt.Println(result.GetString("data")) 567 | // Output: 568 | // hello world 569 | } 570 | 571 | func ExampleWithText() { 572 | client := ghttp.New() 573 | 574 | resp, err := client. 575 | Post("https://httpbin.org/post", 576 | ghttp.WithText("hello world"), 577 | ) 578 | if err != nil { 579 | log.Print(err) 580 | return 581 | } 582 | 583 | result, err := resp.H() 584 | if err != nil { 585 | log.Print(err) 586 | return 587 | } 588 | 589 | fmt.Println(result.GetString("data")) 590 | // Output: 591 | // hello world 592 | } 593 | 594 | func ExampleWithForm() { 595 | client := ghttp.New() 596 | 597 | resp, err := client. 598 | Post("https://httpbin.org/post", 599 | ghttp.WithForm(ghttp.Form{ 600 | "k1": "v1", 601 | "k2": "v2", 602 | }), 603 | ) 604 | if err != nil { 605 | log.Print(err) 606 | return 607 | } 608 | 609 | result, err := resp.H() 610 | if err != nil { 611 | log.Print(err) 612 | return 613 | } 614 | 615 | fmt.Println(result.GetString("form", "k1")) 616 | fmt.Println(result.GetString("form", "k2")) 617 | // Output: 618 | // v1 619 | // v2 620 | } 621 | 622 | func ExampleWithJSON() { 623 | client := ghttp.New() 624 | 625 | resp, err := client. 626 | Post("https://httpbin.org/post", 627 | ghttp.WithJSON(map[string]interface{}{ 628 | "msg": "hello world", 629 | "num": 2019, 630 | }), 631 | ) 632 | if err != nil { 633 | log.Print(err) 634 | return 635 | } 636 | 637 | result, err := resp.H() 638 | if err != nil { 639 | log.Print(err) 640 | return 641 | } 642 | 643 | fmt.Println(result.GetString("json", "msg")) 644 | fmt.Println(result.GetNumber("json", "num")) 645 | // Output: 646 | // hello world 647 | // 2019 648 | } 649 | 650 | func ExampleWithFiles() { 651 | client := ghttp.New() 652 | 653 | resp, err := client. 654 | Post("https://httpbin.org/post", 655 | ghttp.WithFiles(ghttp.Files{ 656 | "file1": ghttp.MustOpen("./testdata/testfile1.txt"), 657 | "file2": ghttp.MustOpen("./testdata/testfile2.txt"), 658 | }), 659 | ) 660 | if err != nil { 661 | log.Print(err) 662 | return 663 | } 664 | 665 | result, err := resp.H() 666 | if err != nil { 667 | log.Print(err) 668 | return 669 | } 670 | 671 | fmt.Println(result.GetString("files", "file1")) 672 | fmt.Println(result.GetString("files", "file2")) 673 | // Output: 674 | // testfile1.txt 675 | // testfile2.txt 676 | } 677 | 678 | func ExampleWithRetrier() { 679 | client := ghttp.New() 680 | 681 | resp, err := client.Post("https://api.example.com/login", 682 | ghttp.WithBasicAuth("user", "p@ssw$"), 683 | ghttp.WithRetrier( 684 | ghttp.WithRetryMaxAttempts(5), 685 | ), 686 | ) 687 | if err != nil { 688 | log.Fatal(err) 689 | } 690 | 691 | fmt.Println(resp.StatusCode) 692 | } 693 | 694 | func ExampleWithClientTrace() { 695 | client := ghttp.New() 696 | 697 | resp, _ := client. 698 | Get("https://httpbin.org/get", 699 | ghttp.WithClientTrace(), 700 | ) 701 | fmt.Printf("%+v\n", resp.TraceInfo()) 702 | } 703 | 704 | func ExampleResponse_Text() { 705 | client := ghttp.New() 706 | 707 | resp, err := client.Get("https://www.example.com") 708 | if err != nil { 709 | log.Fatal(err) 710 | } 711 | s, err := resp.Text() 712 | if err != nil { 713 | log.Fatal(err) 714 | } 715 | 716 | fmt.Println(s) 717 | 718 | resp, err = client.Get("https://www.example.cn") 719 | if err != nil { 720 | log.Fatal(err) 721 | } 722 | s, err = resp.Text(simplifiedchinese.GBK) 723 | if err != nil { 724 | log.Fatal(err) 725 | } 726 | 727 | fmt.Println(s) 728 | } 729 | 730 | func ExampleResponse_H() { 731 | client := ghttp.New() 732 | 733 | resp, err := client. 734 | Post("https://httpbin.org/post", 735 | ghttp.WithQuery(ghttp.Params{ 736 | "k1": "v1", 737 | "k2": "v2", 738 | }), 739 | ghttp.WithHeaders(ghttp.Headers{ 740 | "k3": "v3", 741 | "k4": "v4", 742 | }), 743 | ghttp.WithForm(ghttp.Form{ 744 | "k5": "v5", 745 | "k6": "v6", 746 | }), 747 | ) 748 | if err != nil { 749 | log.Print(err) 750 | return 751 | } 752 | 753 | result, err := resp.H() 754 | if err != nil { 755 | log.Print(err) 756 | return 757 | } 758 | 759 | fmt.Println(result.GetString("args", "k1")) 760 | fmt.Println(result.GetString("args", "k2")) 761 | fmt.Println(result.GetString("headers", "K3")) 762 | fmt.Println(result.GetString("headers", "K4")) 763 | fmt.Println(result.GetString("form", "k5")) 764 | fmt.Println(result.GetString("form", "k6")) 765 | // Output: 766 | // v1 767 | // v2 768 | // v3 769 | // v4 770 | // v5 771 | // v6 772 | } 773 | 774 | const picData = ` 775 | iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 776 | JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACfFBMVEUAAABVVVU3TlI4T1Q4T1Q4TlQ4 777 | TlQ4TlQ4TlQ3TlQ2T1QAgIA5T1Q4TlU4TlUAAAA5TVM4TlM4TlM4TVMAAAA4TlQ4TlQ4TlQ4TVQ5TFQ4 778 | TlQ4T1Q5TlU4TlQ4T1U3TlU4TVM4T1Q4TlQ4TlU4TlU5UFc5TlQ5Ulc4TlQ5T1Q3T1U4TlQ4TlQ4TlM4 779 | TlQ4TlQ7V19PhJJbobRnvdRtzedx1vF03vpHcn5boLJNfow/X2hfrMB03Pl24f503Pg/X2c7VV1VkqNX 780 | mao+XWVAYmt13flis8lOg5J52/W07/7d9//l+f/S9f+g6v534f6j6/7H8//M9P+z7v6A4/5z2fVUkaFu 781 | z+lz2/dw0u1pw9tJdoJz2PNTjZ1Sjp1z2vaE4/3q+v/////L9P+B4/7g+P/2/f+a6f524P5nvtZRiZhZ 782 | n7Fmu9J23/x24P3Z9//4/f+G5f5UkaJOgZBsyeKX6P719fVfXFsqJiV5d3bx+/6T5/7S0tFAPTsxLiyy 783 | sLDC8v9owNhFbXhjtcqYlpYXExHBwMCH5f6x7v5TUE8kIB7c9Ptv0OuamJfDwsFUUVAlIR/d9fxv0OqV 784 | 6P729vZjYV8uKih/fXvv+/6R5/7V1NREQUA2MjG1tLPB8f/e+P92l6A0R0w1S1GarbP3/f+F5P6E5P7o 785 | +v////69yswjIB8qKCfX3t30/f+Y6P574v7j+f/P9f+1qaDDjHSFYlNJOTNMPDWQalmytLDD8v/J8/+v 786 | 7v5/4/553PjAjXa3j3x13vtgmql8k5S3n5TBpprCp5uwnJN6k5Zfn7Bz3PhrzObq8PH//PvX5elqyuTN 787 | 4ea82uJlwdpcsMZoxt8AAAA/l2hnAAAAL3RSTlMAA0GSvN7u8eOPPQK5ymkCWd/cVgFbw8BJQ/zj3P5X 788 | wdbg9bvQ/lX0+GevsdrZ88Z1oBsAAAABYktHRACIBR1IAAAAB3RJTUUH5AIcDgA5dJXtcQAAAZ9JREFU 789 | OMtjYCAWMDIxs7Cysenrs7NxsHBycSNkuHhYefn4BQT1UYCQsIiomKC4BAODpBSIL62vb2BoZGxiamZm 790 | amJsZGigry8DEpeVY5DXN7ew1Ne3sraxRQJ21vb6Do5OzvoKDIouZq62bu4enl7ePr5gST//gMCg4JDQ 791 | sPCISH0lBuWo6JjYuPiERBBI8rO1TU4BsVLT0jMys0L0VRgkVbMjcnLBgkAQZGubB2HlF9jahhfqyzEw 792 | qKkXFZeAhErLyisq/apAzOqa2rp624ZGDU2QP7X0mzxBos0tLS2tbe0gZkdLS2eXrZu+NjggdPS7g0Ci 793 | PUAFvQVgBX0tLf0TbCfq64IV6OnbTQKJTp4yddp0vxkg5sxZs+fMtbXT1wMr0Ne3tZ0HdeT8BQsXQViL 794 | l9ja6uvDFSxdBhJcvmLlypWrVoOYa9baoiiwXdeet37Dxk2bt2zdtmn7jp27dtuiKQCBPXs3gcE+WICj 795 | K9h/4OChw0eOHjuOS4HtiZOnTp8+c/YETgW2tudOnz6P4GFRcOHixUt4FaCCUQV0VQAAe4oDXnKi80AA 796 | AAAldEVYdGRhdGU6Y3JlYXRlADIwMjAtMDItMjhUMTQ6MDA6NTcrMDA6MDAP9tSBAAAAJXRFWHRkYXRl 797 | Om1vZGlmeQAyMDIwLTAyLTI4VDE0OjAwOjU3KzAwOjAwfqtsPQAAAABJRU5ErkJggg== 798 | ` 799 | 800 | func ExampleFile_WithFilename() { 801 | file := ghttp.FileFromReader(base64.NewDecoder(base64.StdEncoding, strings.NewReader(picData))).WithFilename("image.jpg") 802 | _ = ghttp.Files{ 803 | "field": file, 804 | } 805 | // Content-Disposition: form-data; name="field"; filename="image.jpg" 806 | } 807 | 808 | func ExampleFile_WithMIME() { 809 | _ = ghttp.MustOpen("/path/image.png").WithMIME("image/png") 810 | // Content-Type: image/png 811 | } 812 | --------------------------------------------------------------------------------