├── .github └── workflows │ ├── main.yml │ └── review.yml ├── .golangci.yml ├── LICENSE ├── README.md ├── benchmark ├── .gitignore ├── README.md ├── bench_test.go ├── go.mod └── go.sum ├── body_test.go ├── client.go ├── client_test.go ├── codecov.yml ├── config.go ├── config_go1.12.go ├── config_go1.13.go ├── example_test.go ├── go.mod ├── helper.go ├── helper_test.go ├── hxutil ├── drain.go ├── drain_test.go ├── round_tripper.go ├── round_tripper_test.go ├── transport.go └── transport_test.go ├── interceptor.go ├── interceptor_test.go ├── json.go ├── option.go ├── option_test.go ├── plugins ├── hxlog │ ├── export_test.go │ ├── go.mod │ ├── go.sum │ ├── transport.go │ └── transport_test.go ├── hxzap │ ├── export_test.go │ ├── go.mod │ ├── go.sum │ ├── transport.go │ └── transport_test.go ├── pb │ ├── README.md │ ├── doc.go │ ├── go.mod │ ├── go.sum │ ├── json.go │ ├── json_test.go │ ├── proto.go │ └── proto_test.go └── retry │ ├── README.md │ ├── go.mod │ ├── go.sum │ ├── options.go │ ├── retry_test.go │ └── transport.go ├── request_handler.go ├── response_handler.go ├── response_handler_test.go └── version.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | go-version: ['1.12.x', '1.13.x'] 15 | fail-fast: false 16 | 17 | steps: 18 | 19 | - uses: actions/setup-go@v1 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | 23 | - uses: actions/checkout@v1 24 | 25 | - run: go test -v -race -coverprofile coverage.txt -covermode atomic ./... 26 | 27 | - uses: codecov/codecov-action@v1.0.4 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | flags: core 31 | 32 | test-plugins: 33 | runs-on: ubuntu-latest 34 | 35 | strategy: 36 | matrix: 37 | go-version: ['1.13.x'] 38 | module: ['pb', 'retry', 'hxlog', 'hxzap'] 39 | fail-fast: false 40 | 41 | steps: 42 | 43 | - uses: actions/setup-go@v1 44 | with: 45 | go-version: ${{ matrix.go-version }} 46 | 47 | - uses: actions/checkout@v1 48 | 49 | - run: go test -v -race -coverprofile coverage.txt -covermode atomic ./... 50 | working-directory: ./plugins/${{ matrix.module }} 51 | 52 | - uses: codecov/codecov-action@v1.0.4 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | flags: ${{ matrix.module }} 56 | file: ./plugins/${{ matrix.module }}/coverage.txt 57 | -------------------------------------------------------------------------------- /.github/workflows/review.yml: -------------------------------------------------------------------------------- 1 | name: Review 2 | on: [pull_request] 3 | jobs: 4 | 5 | golangci-lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v1 9 | - uses: reviewdog/action-golangci-lint@v1 10 | with: 11 | github_token: ${{ secrets.GITHUB_TOKEN }} 12 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | lll: 3 | line-length: 140 4 | 5 | linters: 6 | enable-all: true 7 | disable: 8 | - bodyclose 9 | - funlen 10 | - gochecknoglobals 11 | - gochecknoinits 12 | - gocognit 13 | - wsl 14 | 15 | issues: 16 | exclude-rules: 17 | - path: _test\.go 18 | linters: 19 | - gocyclo 20 | - errcheck 21 | - dupl 22 | - gosec 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Masayuki Izumi 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hx 2 | [![CI](https://github.com/izumin5210/hx/workflows/CI/badge.svg)](https://github.com/izumin5210/hx/actions?workflow=CI) 3 | [![GoDoc](https://godoc.org/github.com/izumin5210/hx?status.svg)](https://godoc.org/github.com/izumin5210/hx) 4 | [![codecov](https://codecov.io/gh/izumin5210/hx/branch/master/graph/badge.svg)](https://codecov.io/gh/izumin5210/hx) 5 | [![License](https://img.shields.io/github/license/izumin5210/hx)](./LICENSE) 6 | 7 | Developer-friendly, Production-ready and extensible HTTP client for Go 8 | 9 | ## Features 10 | 11 | ... 12 | 13 | 14 | ### Plugins 15 | 16 | - [hxlog](./plugins/hxlog) - Logging requests and responses with standard logger 17 | - [hxlog](./plugins/hxzap) - Logging requests and responses with [zap](https://github.com/uber-go/zap) 18 | - [pb](./plugins/pb) - Marshaling and Unmarshaling protocol buffers 19 | - [retry](./plugins/retry) - Retrying HTTP requests 20 | 21 | ## Examples 22 | ### Simple GET 23 | 24 | ```go 25 | type Content struct { 26 | Body string `json:"body"` 27 | } 28 | 29 | var cont Content 30 | 31 | ctx := context.Background() 32 | err := hx.Get(ctx, "https://api.example.com/contents/1", 33 | hx.WhenSuccess(hx.AsJSON(&cont)), 34 | hx.WhenFailure(hx.AsError()), 35 | ) 36 | ``` 37 | 38 | ### Real-world 39 | 40 | ```go 41 | func init() { 42 | defaultTransport := hxutil.CloneTransport(http.DefaultTransport.(*http.Transport)) 43 | 44 | // Tweak keep-alive configuration 45 | defaultTransport.MaxIdleConns = 500 46 | defaultTransport.MaxIdleConnsPerHost = 100 47 | 48 | // Set global options 49 | hx.DefaultOptions = append( 50 | hx.DefaultOptions, 51 | hx.UserAgent(fmt.Sprintf("yourapp (%s)", hx.DefaultUserAgent)), 52 | hx.Transport(defaultTransport), 53 | hx.TransportFrom(func(rt http.RoundTripper) http.RoundTripper { 54 | return &ochttp.Transport{Base: rt} 55 | }), 56 | ) 57 | } 58 | 59 | func NewContentAPI() *hx.Client { 60 | // Set common options for API ciient 61 | return &ContentAPI{ 62 | client: hx.NewClient( 63 | hx.BaseURL("https://api.example.com"), 64 | ), 65 | } 66 | } 67 | 68 | type ContentAPI struct { 69 | client *hx.Client 70 | } 71 | 72 | func (a *ContentAPI) GetContent(ctx context.Context, id int) (*Content, error) { 73 | var cont Content 74 | 75 | err := a.client.Get(ctx, hx.Path("api", "contents", id), 76 | hx.WhenSuccess(hx.AsJSON(&cont)), 77 | hx.WhenFailure(hx.AsError()), 78 | ) 79 | 80 | if err != nil { 81 | // ... 82 | } 83 | 84 | return &cont, nil 85 | } 86 | 87 | func (a *ContentAPI) CreateContent(ctx context.Context, in *Content) (*Content, error) { 88 | var out Content 89 | 90 | err := a.client.Post(ctx, "/api/contents", 91 | hx.JSON(in), 92 | hx.WhenSuccess(hx.AsJSON(&out)), 93 | hx.WhenStatus(hx.AsJSONError(&InvalidArgument{}), http.StatusBadRequest), 94 | hx.WhenFailure(hx.AsError()), 95 | ) 96 | 97 | if err != nil { 98 | var ( 99 | invalidArgErr *InvalidArgument 100 | respErr *hx.ResponseError 101 | ) 102 | if errors.As(err, &invalidArgErr) { 103 | // handle known error 104 | } else if errors.As(err, &respErr) { 105 | // handle unknown response error 106 | } else { 107 | err := errors.Unwrap(err) 108 | // handle unknown error 109 | } 110 | } 111 | 112 | return &out, nil 113 | } 114 | ``` 115 | -------------------------------------------------------------------------------- /benchmark/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # Benchmark 2 | 3 | - [Resty](https://github.com/go-resty/resty) 4 | - [Sling](https://github.com/dghubble/sling) 5 | - [gentleman](https://github.com/h2non/gentleman) 6 | - [GoRequest](https://github.com/parnurzeal/gorequest) 7 | - [GRequests](https://github.com/levigross/grequests) 8 | 9 | ``` 10 | go test -bench . -benchmem -count 30 -timeout 30m > bench.log 11 | benchstat bench.log 12 | ``` 13 | 14 | ## POST with JSON 15 | 16 | ``` 17 | name time/op 18 | Resty-8 1.37ms ±97% 19 | Sling-8 94.2µs ±31% 20 | Gentleman-8 104µs ± 9% 21 | Gorequest-8 1.41ms ±81% 22 | Grequests-8 84.6µs ±12% 23 | Hx-8 81.8µs ± 9% 24 | NetHTTP-8 76.2µs ± 8% 25 | 26 | name alloc/op 27 | Resty-8 32.0kB ± 5% 28 | Sling-8 7.92kB ± 0% 29 | Gentleman-8 16.4kB ± 1% 30 | Gorequest-8 23.4kB ± 2% 31 | Grequests-8 7.29kB ± 1% 32 | Hx-8 7.89kB ± 1% 33 | NetHTTP-8 6.71kB ± 1% 34 | 35 | name allocs/op 36 | Resty-8 185 ± 0% 37 | Sling-8 100 ± 0% 38 | Gentleman-8 245 ± 0% 39 | Gorequest-8 199 ± 0% 40 | Grequests-8 87.0 ± 0% 41 | Hx-8 110 ± 0% 42 | NetHTTP-8 83.0 ± 0% 43 | ``` 44 | 45 | ## GET with Query 46 | 47 | ``` 48 | name time/op 49 | Resty-8 1.41ms ±81% 50 | Sling-8 85.3µs ±18% 51 | Gentleman-8 102µs ± 6% 52 | Gorequest-8 1.21ms ±87% 53 | Grequests-8 82.8µs ± 5% 54 | Hx-8 84.0µs ± 8% 55 | NetHTTP-8 78.0µs ± 7% 56 | 57 | name alloc/op 58 | Resty-8 32.4kB ± 4% 59 | Sling-8 8.25kB ± 0% 60 | Gentleman-8 17.9kB ± 0% 61 | Gorequest-8 20.4kB ± 1% 62 | Grequests-8 8.88kB ± 0% 63 | Hx-8 9.15kB ± 0% 64 | NetHTTP-8 7.03kB ± 0% 65 | 66 | name allocs/op 67 | Resty-8 202 ± 1% 68 | Sling-8 119 ± 0% 69 | Gentleman-8 271 ± 0% 70 | Gorequest-8 162 ± 0% 71 | Grequests-8 115 ± 0% 72 | Hx-8 128 ± 0% 73 | NetHTTP-8 96.0 ± 0% 74 | ``` 75 | -------------------------------------------------------------------------------- /benchmark/bench_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "strconv" 13 | "testing" 14 | 15 | "github.com/dghubble/sling" 16 | "github.com/go-resty/resty/v2" 17 | "github.com/izumin5210/hx" 18 | "github.com/levigross/grequests" 19 | "github.com/parnurzeal/gorequest" 20 | "gopkg.in/h2non/gentleman.v2" 21 | "gopkg.in/h2non/gentleman.v2/plugins/body" 22 | ) 23 | 24 | func setupServer() (string, func()) { 25 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | defer r.Body.Close() 27 | switch { 28 | case r.Method == http.MethodGet && r.URL.Path == "/messages": 29 | w.WriteHeader(http.StatusOK) 30 | q := r.URL.Query() 31 | userID, _ := strconv.Atoi(q.Get("user_id")) 32 | msg := q.Get("message") 33 | json.NewEncoder(w).Encode(map[string]interface{}{"user_id": userID, "message": msg}) 34 | case r.Method == http.MethodPost && r.URL.Path == "/messages": 35 | w.WriteHeader(http.StatusCreated) 36 | io.Copy(w, r.Body) 37 | default: 38 | w.WriteHeader(http.StatusNotFound) 39 | json.NewEncoder(w).Encode(&Error{Message: "not found"}) 40 | } 41 | })) 42 | 43 | return ts.URL + "/messages", ts.Close 44 | } 45 | 46 | type Message struct { 47 | UserID int `json:"user_id"` 48 | Message string `json:"message"` 49 | } 50 | 51 | type Error struct { 52 | Message string `json:"message"` 53 | } 54 | 55 | func (e *Error) Error() string { return e.Message } 56 | 57 | func BenchmarkPOSTWithJSON(b *testing.B) { 58 | b.Run("Resty", benchmarkResty_POSTWithJSON) 59 | b.Run("Sling", benchmarkSling_POSTWithJSON) 60 | b.Run("Gentleman", benchmarkGentleman_POSTWithJSON) 61 | b.Run("Gorequest", benchmarkGorequest_POSTWithJSON) 62 | b.Run("Grequests", benchmarkGrequests_POSTWithJSON) 63 | b.Run("Hx", benchmarkHx_POSTWithJSON) 64 | b.Run("NetHTTP", benchmarkNetHTTP_POSTWithJSON) 65 | } 66 | 67 | func BenchmarkGETWithQuery(b *testing.B) { 68 | b.Run("Resty", benchmarkResty_GETWithQuery) 69 | b.Run("Sling", benchmarkSling_GETWithQuery) 70 | b.Run("Gentleman", benchmarkGentleman_GETWithQuery) 71 | b.Run("Gorequest", benchmarkGorequest_GETWithQuery) 72 | b.Run("Grequests", benchmarkGrequests_GETWithQuery) 73 | b.Run("Hx", benchmarkHx_GETWithQuery) 74 | b.Run("NetHTTP", benchmarkNetHTTP_GETWithQuery) 75 | } 76 | 77 | func benchmarkResty_GETWithQuery(b *testing.B) { 78 | url, closeServer := setupServer() 79 | defer closeServer() 80 | b.ResetTimer() 81 | 82 | for i := 0; i < b.N; i++ { 83 | var msg Message 84 | client := resty.New() 85 | _, err := client.R(). 86 | SetQueryParams(map[string]string{"user_id": fmt.Sprint(i), "message": "It works!"}). 87 | SetResult(&msg). 88 | SetError(&Error{}). 89 | Get(url) 90 | if err != nil { 91 | b.Errorf("returned %v, want nil", err) 92 | } 93 | } 94 | } 95 | 96 | func benchmarkResty_POSTWithJSON(b *testing.B) { 97 | url, closeServer := setupServer() 98 | defer closeServer() 99 | b.ResetTimer() 100 | 101 | for i := 0; i < b.N; i++ { 102 | var msg Message 103 | client := resty.New() 104 | _, err := client.R(). 105 | SetBody(&Message{UserID: i, Message: "It works!"}). 106 | SetResult(&msg). 107 | SetError(&Error{}). 108 | Post(url) 109 | if err != nil { 110 | b.Errorf("returned %v, want nil", err) 111 | } 112 | } 113 | } 114 | 115 | func benchmarkSling_GETWithQuery(b *testing.B) { 116 | url, closeServer := setupServer() 117 | defer closeServer() 118 | b.ResetTimer() 119 | 120 | for i := 0; i < b.N; i++ { 121 | var msg Message 122 | client := sling.New() 123 | _, err := client.Get(url). 124 | QueryStruct(&Message{UserID: i, Message: "It works!"}). 125 | ReceiveSuccess(&msg) // sling closes a response body automatically 126 | if err != nil { 127 | b.Errorf("returned %v, want nil", err) 128 | } 129 | } 130 | } 131 | 132 | func benchmarkSling_POSTWithJSON(b *testing.B) { 133 | url, closeServer := setupServer() 134 | defer closeServer() 135 | b.ResetTimer() 136 | 137 | for i := 0; i < b.N; i++ { 138 | var msg Message 139 | client := sling.New() 140 | _, err := client.Post(url). 141 | BodyJSON(&Message{UserID: i, Message: "It works!"}). 142 | ReceiveSuccess(&msg) // sling closes a response body automatically 143 | if err != nil { 144 | b.Errorf("returned %v, want nil", err) 145 | } 146 | } 147 | } 148 | 149 | func benchmarkGentleman_GETWithQuery(b *testing.B) { 150 | url, closeServer := setupServer() 151 | defer closeServer() 152 | b.ResetTimer() 153 | 154 | for i := 0; i < b.N; i++ { 155 | var msg Message 156 | client := gentleman.New() 157 | resp, err := client.Request(). 158 | URL(url). 159 | SetQueryParams(map[string]string{"user_id": fmt.Sprint(i), "message": "It works!"}). 160 | Method(http.MethodGet). 161 | Send() 162 | if err != nil { 163 | b.Errorf("returned %v, want nil", err) 164 | } 165 | err = resp.JSON(&msg) // closes a response body 166 | if err != nil { 167 | b.Errorf("returned %v, want nil", err) 168 | } 169 | } 170 | } 171 | 172 | func benchmarkGentleman_POSTWithJSON(b *testing.B) { 173 | url, closeServer := setupServer() 174 | defer closeServer() 175 | b.ResetTimer() 176 | 177 | for i := 0; i < b.N; i++ { 178 | var msg Message 179 | client := gentleman.New() 180 | resp, err := client.Request(). 181 | URL(url). 182 | Use(body.JSON(&Message{UserID: i, Message: "It works!"})). 183 | Method(http.MethodPost). 184 | Send() 185 | if err != nil { 186 | b.Errorf("returned %v, want nil", err) 187 | } 188 | err = resp.JSON(&msg) // closes a response body 189 | if err != nil { 190 | b.Errorf("returned %v, want nil", err) 191 | } 192 | } 193 | } 194 | 195 | func benchmarkGorequest_GETWithQuery(b *testing.B) { 196 | url, closeServer := setupServer() 197 | defer closeServer() 198 | b.ResetTimer() 199 | 200 | for i := 0; i < b.N; i++ { 201 | var msg Message 202 | client := gorequest.New() 203 | _, _, err := client.Get(url). 204 | Query(&Message{UserID: i, Message: "It works!"}). 205 | EndStruct(&msg) 206 | if err != nil { 207 | b.Errorf("returned %v, want nil", err) 208 | } 209 | } 210 | } 211 | 212 | func benchmarkGorequest_POSTWithJSON(b *testing.B) { 213 | url, closeServer := setupServer() 214 | defer closeServer() 215 | b.ResetTimer() 216 | 217 | for i := 0; i < b.N; i++ { 218 | var msg Message 219 | client := gorequest.New() 220 | _, _, err := client.Post(url). 221 | Send(&Message{UserID: i, Message: "It works!"}). 222 | EndStruct(&msg) 223 | if err != nil { 224 | b.Errorf("returned %v, want nil", err) 225 | } 226 | } 227 | } 228 | 229 | func benchmarkGrequests_GETWithQuery(b *testing.B) { 230 | url, closeServer := setupServer() 231 | defer closeServer() 232 | b.ResetTimer() 233 | 234 | for i := 0; i < b.N; i++ { 235 | var msg Message 236 | resp, err := grequests.Get(url, &grequests.RequestOptions{ 237 | QueryStruct: &Message{UserID: i, Message: "It works!"}, 238 | }) 239 | if err != nil { 240 | b.Errorf("returned %v, want nil", err) 241 | } 242 | err = resp.JSON(&msg) // closes a response body 243 | if err != nil { 244 | b.Errorf("returned %v, want nil", err) 245 | } 246 | } 247 | } 248 | 249 | func benchmarkGrequests_POSTWithJSON(b *testing.B) { 250 | url, closeServer := setupServer() 251 | defer closeServer() 252 | b.ResetTimer() 253 | 254 | for i := 0; i < b.N; i++ { 255 | var msg Message 256 | resp, err := grequests.Post(url, &grequests.RequestOptions{ 257 | JSON: &Message{UserID: i, Message: "It works!"}, 258 | }) 259 | if err != nil { 260 | b.Errorf("returned %v, want nil", err) 261 | } 262 | err = resp.JSON(&msg) // closes a response body 263 | if err != nil { 264 | b.Errorf("returned %v, want nil", err) 265 | } 266 | } 267 | } 268 | 269 | func benchmarkHx_GETWithQuery(b *testing.B) { 270 | url, closeServer := setupServer() 271 | defer closeServer() 272 | b.ResetTimer() 273 | 274 | for i := 0; i < b.N; i++ { 275 | var msg Message 276 | client := hx.NewClient() 277 | err := client.Get(context.Background(), url, 278 | hx.Query("user_id", fmt.Sprint(i)), 279 | hx.Query("message", "It works!"), 280 | hx.WhenSuccess(hx.AsJSON(&msg)), 281 | hx.WhenFailure(hx.AsJSONError(&Error{})), 282 | ) 283 | if err != nil { 284 | b.Errorf("returned %v, want nil", err) 285 | } 286 | } 287 | } 288 | 289 | func benchmarkHx_POSTWithJSON(b *testing.B) { 290 | url, closeServer := setupServer() 291 | defer closeServer() 292 | b.ResetTimer() 293 | 294 | for i := 0; i < b.N; i++ { 295 | var msg Message 296 | client := hx.NewClient() 297 | err := client.Post(context.Background(), url, 298 | hx.JSON(&Message{UserID: i, Message: "It works!"}), 299 | hx.WhenSuccess(hx.AsJSON(&msg)), 300 | hx.WhenFailure(hx.AsJSONError(&Error{})), 301 | ) 302 | if err != nil { 303 | b.Errorf("returned %v, want nil", err) 304 | } 305 | } 306 | } 307 | 308 | func benchmarkNetHTTP_GETWithQuery(b *testing.B) { 309 | u, closeServer := setupServer() 310 | defer closeServer() 311 | b.ResetTimer() 312 | 313 | for i := 0; i < b.N; i++ { 314 | var msg Message 315 | 316 | q := url.Values{} 317 | q.Add("user_id", fmt.Sprint(i)) 318 | q.Add("message", "It works!") 319 | 320 | resp, err := http.Get(u + "?" + q.Encode()) 321 | if err != nil { 322 | b.Errorf("returned %v, want nil", err) 323 | } 324 | defer resp.Body.Close() 325 | err = json.NewDecoder(resp.Body).Decode(&msg) 326 | if err != nil { 327 | b.Errorf("returned %v, want nil", err) 328 | } 329 | } 330 | } 331 | 332 | func benchmarkNetHTTP_POSTWithJSON(b *testing.B) { 333 | url, closeServer := setupServer() 334 | defer closeServer() 335 | b.ResetTimer() 336 | 337 | for i := 0; i < b.N; i++ { 338 | var msg Message 339 | var reqBuf bytes.Buffer 340 | err := json.NewEncoder(&reqBuf).Encode(&Message{UserID: i, Message: "It works!"}) 341 | if err != nil { 342 | b.Errorf("returned %v, want nil", err) 343 | } 344 | resp, err := http.Post(url, "application/json", &reqBuf) 345 | if err != nil { 346 | b.Errorf("returned %v, want nil", err) 347 | } 348 | defer resp.Body.Close() 349 | err = json.NewDecoder(resp.Body).Decode(&msg) 350 | if err != nil { 351 | b.Errorf("returned %v, want nil", err) 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /benchmark/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izumin5210/hx/benchmark 2 | 3 | go 1.13 4 | 5 | replace github.com/izumin5210/hx => ../ 6 | 7 | require ( 8 | github.com/dghubble/sling v1.3.0 9 | github.com/go-resty/resty/v2 v2.1.0 10 | github.com/izumin5210/hx v0.2.0 11 | github.com/levigross/grequests v0.0.0-20190908174114-253788527a1a 12 | github.com/parnurzeal/gorequest v0.2.16 13 | github.com/pkg/errors v0.8.1 // indirect 14 | github.com/smartystreets/goconvey v1.6.4 // indirect 15 | gopkg.in/h2non/gentleman.v2 v2.0.3 16 | moul.io/http2curl v1.0.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /benchmark/go.sum: -------------------------------------------------------------------------------- 1 | github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= 2 | github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= 3 | github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484 h1:pEtiCjIXx3RvGjlUJuCNxNOw0MNblyR9Wi+vJGBFh+8= 4 | github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 5 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= 6 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= 7 | github.com/go-resty/resty/v2 v2.1.0 h1:Z6IefCpUMfnvItVJaJXWv/pMiiD11So35QgwEELsldE= 8 | github.com/go-resty/resty/v2 v2.1.0/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= 9 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 10 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 11 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 12 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 13 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 14 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 15 | github.com/levigross/grequests v0.0.0-20190908174114-253788527a1a h1:DGFy/362j92vQRE3ThU1yqg9TuJS8YJOSbQuB7BP9cA= 16 | github.com/levigross/grequests v0.0.0-20190908174114-253788527a1a/go.mod h1:jVntzcUU+2BtVohZBQmSHWUmh8B55LCNfPhcNCIvvIg= 17 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 18 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 19 | github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ= 20 | github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE= 21 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 22 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 23 | github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= 24 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 25 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 26 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 27 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 28 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 29 | golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 30 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 31 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU= 32 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 35 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 36 | gopkg.in/h2non/gentleman.v2 v2.0.3 h1:exsUPKJDFwNjJykboVj8+BKPWMNOxR/AmPL3f7Hutwo= 37 | gopkg.in/h2non/gentleman.v2 v2.0.3/go.mod h1:A1c7zwrTgAyyf6AbpvVksYtBayTB4STBUGmdkEtlHeA= 38 | moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8= 39 | moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE= 40 | -------------------------------------------------------------------------------- /body_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "testing" 13 | 14 | "github.com/izumin5210/hx" 15 | ) 16 | 17 | type fakeStringer string 18 | 19 | func (s fakeStringer) String() string { return string(s) } 20 | 21 | type fakeTextMarshaler string 22 | 23 | func (tm fakeTextMarshaler) MarshalText() ([]byte, error) { return []byte(tm), nil } 24 | 25 | type fakeJSONMarshaler string 26 | 27 | func (jm fakeJSONMarshaler) MarshalJSON() ([]byte, error) { 28 | return []byte(fmt.Sprintf(`{"message":"%s"}`, jm)), nil 29 | } 30 | 31 | func TestBody(t *testing.T) { 32 | type Post struct { 33 | Message string `json:"message"` 34 | } 35 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | switch { 37 | case r.Method == http.MethodPost && r.URL.Path == "/echo": 38 | var msg string 39 | switch r.Header.Get("Content-Type") { 40 | case "application/json": 41 | var post Post 42 | err := json.NewDecoder(r.Body).Decode(&post) 43 | if err != nil { 44 | w.WriteHeader(http.StatusBadRequest) 45 | return 46 | } 47 | msg = post.Message 48 | case "application/x-www-form-urlencoded": 49 | err := r.ParseForm() 50 | if err != nil { 51 | w.WriteHeader(http.StatusBadRequest) 52 | return 53 | } 54 | msg = r.PostFormValue("message") 55 | default: 56 | data, err := ioutil.ReadAll(r.Body) 57 | if err != nil { 58 | w.WriteHeader(http.StatusBadRequest) 59 | return 60 | } 61 | msg = string(data) 62 | } 63 | w.WriteHeader(http.StatusCreated) 64 | err := json.NewEncoder(w).Encode(map[string]string{"message": msg}) 65 | if err != nil { 66 | w.WriteHeader(http.StatusInternalServerError) 67 | return 68 | } 69 | default: 70 | w.WriteHeader(http.StatusNotFound) 71 | } 72 | })) 73 | defer ts.Close() 74 | 75 | cases := []struct { 76 | test string 77 | in interface{} 78 | }{ 79 | { 80 | test: "with io.Reader", 81 | in: bytes.NewBufferString("Hello!"), 82 | }, 83 | { 84 | test: "with string", 85 | in: "Hello!", 86 | }, 87 | { 88 | test: "with bytes", 89 | in: []byte("Hello!"), 90 | }, 91 | { 92 | test: "with url.Values", 93 | in: url.Values{"message": []string{"Hello!"}}, 94 | }, 95 | { 96 | test: "with fmt.Stringer", 97 | in: fakeStringer("Hello!"), 98 | }, 99 | { 100 | test: "with encoding.TextMarshaler", 101 | in: fakeTextMarshaler("Hello!"), 102 | }, 103 | { 104 | test: "with json.Marshaler", 105 | in: fakeJSONMarshaler("Hello!"), 106 | }, 107 | } 108 | 109 | for _, tc := range cases { 110 | t.Run(tc.test, func(t *testing.T) { 111 | var out Post 112 | err := hx.Post(context.Background(), ts.URL+"/echo", 113 | hx.Body(tc.in), 114 | hx.WhenSuccess(hx.AsJSON(&out)), 115 | hx.WhenFailure(hx.AsError()), 116 | ) 117 | if err != nil { 118 | t.Errorf("returned %v, want nil", err) 119 | } 120 | if got, want := out.Message, "Hello!"; got != want { 121 | t.Errorf("returned message is %q, want %q", got, want) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | func TestJSON(t *testing.T) { 128 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 129 | switch { 130 | case r.Method == http.MethodPost && r.URL.Path == "/echo": 131 | out := make(map[string]interface{}) 132 | err := json.NewDecoder(r.Body).Decode(&out) 133 | if err != nil || out["message"] == "" { 134 | w.WriteHeader(http.StatusBadRequest) 135 | return 136 | } 137 | w.WriteHeader(http.StatusCreated) 138 | err = json.NewEncoder(w).Encode(out) 139 | if err != nil { 140 | w.WriteHeader(http.StatusInternalServerError) 141 | return 142 | } 143 | default: 144 | w.WriteHeader(http.StatusNotFound) 145 | } 146 | })) 147 | defer ts.Close() 148 | 149 | type Post struct { 150 | Message string `json:"message"` 151 | } 152 | 153 | cases := []struct { 154 | test string 155 | in interface{} 156 | }{ 157 | { 158 | test: "with struct", 159 | in: &Post{Message: "Hello!"}, 160 | }, 161 | { 162 | test: "with string", 163 | in: `{"message": "Hello!"}`, 164 | }, 165 | { 166 | test: "with bytes", 167 | in: []byte(`{"message": "Hello!"}`), 168 | }, 169 | { 170 | test: "with io.Reader", 171 | in: bytes.NewBufferString(`{"message": "Hello!"}`), 172 | }, 173 | } 174 | 175 | for _, tc := range cases { 176 | t.Run(tc.test, func(t *testing.T) { 177 | var out Post 178 | err := hx.Post(context.Background(), ts.URL+"/echo", 179 | hx.JSON(tc.in), 180 | hx.WhenSuccess(hx.AsJSON(&out)), 181 | hx.WhenFailure(hx.AsError()), 182 | ) 183 | if err != nil { 184 | t.Errorf("returned %v, want nil", err) 185 | } 186 | if got, want := out.Message, "Hello!"; got != want { 187 | t.Errorf("returned message is %q, want %q", got, want) 188 | } 189 | }) 190 | } 191 | 192 | t.Run("failed to encode request body", func(t *testing.T) { 193 | var out Post 194 | err := hx.Post(context.Background(), ts.URL+"/echo", 195 | hx.JSON(func() {}), 196 | hx.WhenSuccess(hx.AsJSON(&out)), 197 | hx.WhenFailure(hx.AsError()), 198 | ) 199 | if err == nil { 200 | t.Error("returned nil, want an error") 201 | } 202 | if _, ok := err.(*hx.ResponseError); ok { 203 | t.Errorf("returned RequestError %v, want json error", err) 204 | } 205 | }) 206 | } 207 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | func Get(ctx context.Context, url string, opts ...Option) error { 11 | return NewClient().Get(ctx, url, opts...) 12 | } 13 | 14 | func Post(ctx context.Context, url string, opts ...Option) error { 15 | return NewClient().Post(ctx, url, opts...) 16 | } 17 | 18 | func Put(ctx context.Context, url string, opts ...Option) error { 19 | return NewClient().Put(ctx, url, opts...) 20 | } 21 | 22 | func Patch(ctx context.Context, url string, opts ...Option) error { 23 | return NewClient().Patch(ctx, url, opts...) 24 | } 25 | 26 | func Delete(ctx context.Context, url string, opts ...Option) error { 27 | return NewClient().Delete(ctx, url, opts...) 28 | } 29 | 30 | type Client struct { 31 | opts []Option 32 | } 33 | 34 | // NewClient creates a new http client instance. 35 | func NewClient(opts ...Option) *Client { 36 | return &Client{ 37 | opts: opts, 38 | } 39 | } 40 | 41 | func (c *Client) Get(ctx context.Context, url string, opts ...Option) error { 42 | return c.request(ctx, http.MethodGet, url, opts...) 43 | } 44 | 45 | func (c *Client) Post(ctx context.Context, url string, opts ...Option) error { 46 | return c.request(ctx, http.MethodPost, url, opts...) 47 | } 48 | 49 | func (c *Client) Put(ctx context.Context, url string, opts ...Option) error { 50 | return c.request(ctx, http.MethodPut, url, opts...) 51 | } 52 | 53 | func (c *Client) Patch(ctx context.Context, url string, opts ...Option) error { 54 | return c.request(ctx, http.MethodPatch, url, opts...) 55 | } 56 | 57 | func (c *Client) Delete(ctx context.Context, url string, opts ...Option) error { 58 | return c.request(ctx, http.MethodDelete, url, opts...) 59 | } 60 | 61 | // With clones the current client and applies the given options. 62 | func (c *Client) With(opts ...Option) *Client { 63 | newOpts := make([]Option, 0, len(c.opts)+len(opts)) 64 | newOpts = append(newOpts, c.opts...) 65 | newOpts = append(newOpts, opts...) 66 | return NewClient(newOpts...) 67 | } 68 | 69 | func (c *Client) request(ctx context.Context, meth string, url string, opts ...Option) error { 70 | cfg, err := NewConfig() 71 | if err != nil { 72 | return err 73 | } 74 | err = cfg.Apply(c.opts...) 75 | if err != nil { 76 | return err 77 | } 78 | err = cfg.Apply(URL(url)) 79 | if err != nil { 80 | return err 81 | } 82 | err = cfg.Apply(opts...) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | resp, err := cfg.DoRequest(ctx, meth) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | io.Copy(ioutil.Discard, resp.Body) 93 | resp.Body.Close() 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/izumin5210/hx" 17 | "github.com/izumin5210/hx/hxutil" 18 | ) 19 | 20 | func TestClient(t *testing.T) { 21 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | switch { 23 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 24 | w.Write([]byte("pong")) 25 | case r.URL.Path == "/method": 26 | if want, got := r.URL.Query().Get("method"), r.Method; got != want { 27 | w.WriteHeader(http.StatusBadRequest) 28 | } 29 | case r.Method == http.MethodGet && r.URL.Path == "/echo": 30 | msg := r.URL.Query().Get("message") 31 | if msg == "" { 32 | w.WriteHeader(http.StatusBadRequest) 33 | return 34 | } 35 | err := json.NewEncoder(w).Encode(map[string]string{"message": msg}) 36 | if err != nil { 37 | w.WriteHeader(http.StatusInternalServerError) 38 | return 39 | } 40 | case r.Method == http.MethodPost && r.URL.Path == "/echo": 41 | out := make(map[string]interface{}) 42 | err := json.NewDecoder(r.Body).Decode(&out) 43 | if err != nil || out["message"] == "" { 44 | w.WriteHeader(http.StatusBadRequest) 45 | return 46 | } 47 | w.WriteHeader(http.StatusCreated) 48 | err = json.NewEncoder(w).Encode(out) 49 | if err != nil { 50 | w.WriteHeader(http.StatusInternalServerError) 51 | return 52 | } 53 | case r.Method == http.MethodGet && r.URL.Path == "/basic_auth": 54 | if user, pass, ok := r.BasicAuth(); !(ok && user == "foo" && pass == "bar") { 55 | w.WriteHeader(http.StatusUnauthorized) 56 | return 57 | } 58 | case r.Method == http.MethodGet && r.URL.Path == "/bearer_auth": 59 | token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 60 | if token != "tokentoken" { 61 | w.WriteHeader(http.StatusUnauthorized) 62 | return 63 | } 64 | case r.Method == http.MethodGet && r.URL.Path == "/error": 65 | w.WriteHeader(http.StatusBadRequest) 66 | json.NewEncoder(w).Encode(map[string]string{"message": "invalid argument"}) 67 | case r.Method == http.MethodGet && r.URL.Path == "/timeout": 68 | time.Sleep(1 * time.Second) 69 | err := json.NewEncoder(w).Encode(map[string]string{"message": "pong"}) 70 | if err != nil { 71 | w.WriteHeader(http.StatusInternalServerError) 72 | return 73 | } 74 | default: 75 | w.WriteHeader(http.StatusNotFound) 76 | } 77 | })) 78 | 79 | checkStatusFromError := func(t *testing.T, err error, st int) { 80 | t.Helper() 81 | if err == nil { 82 | t.Error("returned nil, want an error") 83 | } else if reqErr, ok := err.(*hx.ResponseError); !ok { 84 | t.Errorf("returned %v, want *hx.ResponseError", err) 85 | } else if reqErr.Response == nil { 86 | t.Error("returned error has no response") 87 | } else if got, want := reqErr.Response.StatusCode, st; got != want { 88 | t.Errorf("returned status code is %d, want %d", got, want) 89 | } 90 | } 91 | checkErrorIsWrapped := func(t *testing.T, err error) { 92 | t.Helper() 93 | if err == nil { 94 | t.Error("returned nil, want an error") 95 | } else if reqErr, ok := err.(*hx.ResponseError); !ok { 96 | t.Errorf("returned %v, want *hx.ResponseError", err) 97 | } else if reqErr.Unwrap() == reqErr { 98 | t.Error("returned error wrapped no errors") 99 | } 100 | } 101 | checkErrorIsNotWrapped := func(t *testing.T, err error) { 102 | t.Helper() 103 | if err == nil { 104 | t.Error("returned nil, want an error") 105 | } else if reqErr, ok := err.(*hx.ResponseError); !ok { 106 | t.Errorf("returned %v, want *hx.ResponseError", err) 107 | } else if reqErr.Unwrap() != reqErr { 108 | t.Errorf("returned error wrapped %v, want nil", reqErr.Unwrap()) 109 | } 110 | } 111 | 112 | defer ts.Close() 113 | 114 | t.Run("simple", func(t *testing.T) { 115 | err := hx.Get(context.Background(), ts.URL+"/ping") 116 | if err != nil { 117 | t.Errorf("returned %v, want nil", err) 118 | } 119 | }) 120 | 121 | t.Run("method", func(t *testing.T) { 122 | t.Run(http.MethodGet, func(t *testing.T) { 123 | err := hx.Get(context.Background(), ts.URL+"/method", 124 | hx.Query("method", http.MethodGet), 125 | hx.WhenFailure(hx.AsError()), 126 | ) 127 | if err != nil { 128 | t.Errorf("returned %v, want nil", err) 129 | } 130 | }) 131 | t.Run(http.MethodPost, func(t *testing.T) { 132 | err := hx.Post(context.Background(), ts.URL+"/method", 133 | hx.Query("method", http.MethodPost), 134 | hx.WhenFailure(hx.AsError()), 135 | ) 136 | if err != nil { 137 | t.Errorf("returned %v, want nil", err) 138 | } 139 | }) 140 | t.Run(http.MethodPut, func(t *testing.T) { 141 | err := hx.Put(context.Background(), ts.URL+"/method", 142 | hx.Query("method", http.MethodPut), 143 | hx.WhenFailure(hx.AsError()), 144 | ) 145 | if err != nil { 146 | t.Errorf("returned %v, want nil", err) 147 | } 148 | }) 149 | t.Run(http.MethodPatch, func(t *testing.T) { 150 | err := hx.Patch(context.Background(), ts.URL+"/method", 151 | hx.Query("method", http.MethodPatch), 152 | hx.WhenFailure(hx.AsError()), 153 | ) 154 | if err != nil { 155 | t.Errorf("returned %v, want nil", err) 156 | } 157 | }) 158 | t.Run(http.MethodDelete, func(t *testing.T) { 159 | err := hx.Delete(context.Background(), ts.URL+"/method", 160 | hx.Query("method", http.MethodDelete), 161 | hx.WhenFailure(hx.AsError()), 162 | ) 163 | if err != nil { 164 | t.Errorf("returned %v, want nil", err) 165 | } 166 | }) 167 | }) 168 | 169 | t.Run("receive json", func(t *testing.T) { 170 | var out struct { 171 | Message string `json:"message"` 172 | } 173 | err := hx.Get(context.Background(), ts.URL+"/echo", 174 | hx.Query("message", "It, Works!"), 175 | hx.WhenSuccess(hx.AsJSON(&out)), 176 | ) 177 | if err != nil { 178 | t.Errorf("returned %v, want nil", err) 179 | } 180 | if got, want := out.Message, "It, Works!"; got != want { 181 | t.Errorf("returned %q, want %q", got, want) 182 | } 183 | }) 184 | 185 | t.Run("receive bytes", func(t *testing.T) { 186 | var out bytes.Buffer 187 | err := hx.Get(context.Background(), ts.URL+"/ping", 188 | hx.WhenSuccess(hx.AsBytesBuffer(&out)), 189 | ) 190 | if err != nil { 191 | t.Errorf("returned %v, want nil", err) 192 | } 193 | if got, want := out.String(), "pong"; got != want { 194 | t.Errorf("returned %q, want %q", got, want) 195 | } 196 | }) 197 | 198 | t.Run("when error", func(t *testing.T) { 199 | t.Run("ignore", func(t *testing.T) { 200 | var out struct { 201 | Message string `json:"message"` 202 | } 203 | err := hx.Get(context.Background(), ts.URL+"/echo", 204 | hx.WhenSuccess(hx.AsJSON(&out)), 205 | ) 206 | if err != nil { 207 | t.Errorf("returned %v, want nil", err) 208 | } 209 | if got, want := out.Message, ""; got != want { 210 | t.Errorf("returned %q, want %q", got, want) 211 | } 212 | }) 213 | 214 | t.Run("handle", func(t *testing.T) { 215 | var out struct { 216 | Message string `json:"message"` 217 | } 218 | err := hx.Get(context.Background(), ts.URL+"/echo", 219 | hx.WhenSuccess(hx.AsJSON(&out)), 220 | hx.WhenFailure(hx.AsError()), 221 | ) 222 | checkStatusFromError(t, err, http.StatusBadRequest) 223 | checkErrorIsNotWrapped(t, err) 224 | }) 225 | 226 | t.Run("failed to decode response", func(t *testing.T) { 227 | var out struct { 228 | Message string `json:"message"` 229 | } 230 | err := hx.Get(context.Background(), ts.URL+"/ping", 231 | hx.WhenSuccess(hx.AsJSON(&out)), 232 | hx.WhenFailure(hx.AsError()), 233 | ) 234 | checkStatusFromError(t, err, http.StatusOK) 235 | checkErrorIsWrapped(t, err) 236 | }) 237 | 238 | t.Run("AsJSONError", func(t *testing.T) { 239 | err := hx.Get(context.Background(), ts.URL+"/error", 240 | hx.WhenStatus(hx.AsJSONError(&fakeError{}), http.StatusBadRequest), 241 | hx.WhenFailure(hx.AsError()), 242 | ) 243 | if err == nil { 244 | t.Error("returned nil, want an error") 245 | } else if reqErr, ok := err.(*hx.ResponseError); !ok { 246 | t.Errorf("returned %v, want *hx.ResponseError", err) 247 | } else if rawErr := reqErr.Err; rawErr == nil { 248 | t.Error("returned error wrapped no errors") 249 | } else if fakeErr, ok := rawErr.(*fakeError); !ok { 250 | t.Errorf("wrapped error is unknown: %v", rawErr) 251 | } else if got, want := fakeErr.Message, "invalid argument"; got != want { 252 | t.Errorf("wrapped error has message %v, want %v", got, want) 253 | } 254 | }) 255 | }) 256 | 257 | t.Run("With BaseURL", func(t *testing.T) { 258 | u, _ := url.Parse(ts.URL) 259 | cli := hx.NewClient(hx.BaseURL(u)) 260 | err := cli.Get(context.Background(), "/ping", 261 | hx.WhenFailure(hx.AsError()), 262 | ) 263 | if err != nil { 264 | t.Errorf("returned %v, want nil", err) 265 | } 266 | }) 267 | 268 | t.Run("With BasicAuth", func(t *testing.T) { 269 | t.Run("success", func(t *testing.T) { 270 | err := hx.Get(context.Background(), ts.URL+"/basic_auth", 271 | hx.BasicAuth("foo", "bar"), 272 | hx.WhenFailure(hx.AsError()), 273 | ) 274 | if err != nil { 275 | t.Errorf("returned %v, want nil", err) 276 | } 277 | }) 278 | 279 | t.Run("failure", func(t *testing.T) { 280 | err := hx.Get(context.Background(), ts.URL+"/basic_auth", 281 | hx.BasicAuth("baz", "qux"), 282 | hx.WhenFailure(hx.AsError()), 283 | ) 284 | checkStatusFromError(t, err, http.StatusUnauthorized) 285 | checkErrorIsNotWrapped(t, err) 286 | }) 287 | }) 288 | 289 | t.Run("with Bearer", func(t *testing.T) { 290 | t.Run("success", func(t *testing.T) { 291 | err := hx.Get(context.Background(), ts.URL+"/bearer_auth", 292 | hx.Bearer("tokentoken"), 293 | hx.WhenFailure(hx.AsError()), 294 | ) 295 | if err != nil { 296 | t.Errorf("returned %v, want nil", err) 297 | } 298 | }) 299 | 300 | t.Run("failure", func(t *testing.T) { 301 | err := hx.Get(context.Background(), ts.URL+"/bearer_auth", 302 | hx.Bearer("tokentokentoken"), 303 | hx.WhenFailure(hx.AsError()), 304 | ) 305 | checkStatusFromError(t, err, http.StatusUnauthorized) 306 | checkErrorIsNotWrapped(t, err) 307 | }) 308 | }) 309 | 310 | t.Run("with Timeout", func(t *testing.T) { 311 | var out struct { 312 | Message string `json:"message"` 313 | } 314 | err := hx.Get(context.Background(), ts.URL+"/timeout", 315 | hx.WhenSuccess(hx.AsJSON(&out)), 316 | hx.Timeout(10*time.Millisecond), 317 | hx.WhenFailure(hx.AsError()), 318 | ) 319 | if err == nil { 320 | t.Error("returned nil, want an error") 321 | } 322 | }) 323 | 324 | t.Run("with Client", func(t *testing.T) { 325 | cli := &http.Client{ 326 | Timeout: 10 * time.Millisecond, 327 | } 328 | err := hx.Get(context.Background(), ts.URL+"/timeout", 329 | hx.HTTPClient(cli), 330 | hx.WhenFailure(hx.AsError()), 331 | ) 332 | if err == nil { 333 | t.Error("returned nil, want an error") 334 | } 335 | }) 336 | 337 | t.Run("with Transport", func(t *testing.T) { 338 | transport := &hxutil.RoundTripperWrapper{ 339 | Func: func(req *http.Request, rt http.RoundTripper) (*http.Response, error) { 340 | req.SetBasicAuth("foo", "bar") 341 | return rt.RoundTrip(req) 342 | }, 343 | } 344 | err := hx.Get(context.Background(), ts.URL+"/basic_auth", 345 | hx.Transport(transport), 346 | hx.WhenFailure(hx.AsError()), 347 | ) 348 | if err != nil { 349 | t.Errorf("returned %v, want nil", err) 350 | } 351 | }) 352 | 353 | t.Run("with TransportFrom", func(t *testing.T) { 354 | err := hx.Get(context.Background(), ts.URL+"/basic_auth", 355 | hx.TransportFrom(func(base http.RoundTripper) http.RoundTripper { 356 | return &hxutil.RoundTripperWrapper{ 357 | Func: func(req *http.Request, rt http.RoundTripper) (*http.Response, error) { 358 | req.SetBasicAuth("foo", "bar") 359 | return rt.RoundTrip(req) 360 | }, 361 | } 362 | }), 363 | hx.WhenFailure(hx.AsError()), 364 | ) 365 | if err != nil { 366 | t.Errorf("returned %v, want nil", err) 367 | } 368 | }) 369 | 370 | t.Run("with TransportFunc", func(t *testing.T) { 371 | err := hx.Get(context.Background(), ts.URL+"/basic_auth", 372 | hx.TransportFunc(func(r *http.Request, next http.RoundTripper) (*http.Response, error) { 373 | r.SetBasicAuth("foo", "bar") 374 | return next.RoundTrip(r) 375 | }), 376 | hx.WhenFailure(hx.AsError()), 377 | ) 378 | if err != nil { 379 | t.Errorf("returned %v, want nil", err) 380 | } 381 | }) 382 | } 383 | 384 | type fakeError struct { 385 | Message string `json:"message"` 386 | } 387 | 388 | func (e fakeError) Error() string { return e.Message } 389 | 390 | func TestClient_With(t *testing.T) { 391 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 392 | switch { 393 | case r.Method == http.MethodGet && r.URL.Path == "/echo": 394 | cnt, _ := strconv.Atoi(r.URL.Query().Get("count")) 395 | if cnt == 0 { 396 | cnt = 1 397 | } 398 | err := json.NewEncoder(w).Encode(map[string]string{"message": strings.Repeat(r.Header.Get("Message"), cnt)}) 399 | if err != nil { 400 | w.WriteHeader(http.StatusInternalServerError) 401 | return 402 | } 403 | default: 404 | w.WriteHeader(http.StatusNotFound) 405 | } 406 | })) 407 | defer ts.Close() 408 | 409 | type Response struct { 410 | Message string `json:"message"` 411 | } 412 | 413 | cli := hx.NewClient( 414 | hx.Header("Message", "foo"), 415 | hx.WhenFailure(hx.AsError()), 416 | ) 417 | 418 | var resp1 Response 419 | err := cli.Get(context.Background(), ts.URL+"/echo", 420 | hx.WhenSuccess(hx.AsJSON(&resp1)), 421 | ) 422 | if err != nil { 423 | t.Errorf("returned %v, want nil", err) 424 | } 425 | if got, want := resp1.Message, "foo"; got != want { 426 | t.Errorf("returned %q, want %q", got, want) 427 | } 428 | 429 | var resp2 Response 430 | cli = cli.With( 431 | hx.Header("Message", "bar"), 432 | hx.Query("count", "3"), 433 | ) 434 | err = cli.Get(context.Background(), ts.URL+"/echo", 435 | hx.WhenSuccess(hx.AsJSON(&resp2)), 436 | ) 437 | if err != nil { 438 | t.Errorf("returned %v, want nil", err) 439 | } 440 | if got, want := resp2.Message, "barbarbar"; got != want { 441 | t.Errorf("returned %q, want %q", got, want) 442 | } 443 | } 444 | 445 | func TestClient_DefaultOptions(t *testing.T) { 446 | type Post struct { 447 | Message string `json:"message"` 448 | } 449 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 450 | switch { 451 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 452 | msg := "pong" 453 | if got := r.URL.Query().Get("message"); got != "" { 454 | msg = got 455 | } 456 | json.NewEncoder(w).Encode(&Post{Message: msg}) 457 | default: 458 | w.WriteHeader(http.StatusNotFound) 459 | } 460 | })) 461 | defer ts.Close() 462 | 463 | var replaceDefaultOption = func(opts ...hx.Option) func() { 464 | tmp := hx.DefaultOptions 465 | hx.DefaultOptions = opts 466 | return func() { hx.DefaultOptions = tmp } 467 | } 468 | optErr := hx.OptionFunc(func(c *hx.Config) error { 469 | return errors.New("error occurred") 470 | }) 471 | 472 | t.Run("valid", func(t *testing.T) { 473 | defer replaceDefaultOption(hx.Query("message", "foobar"))() 474 | 475 | var out Post 476 | err := hx.Get(context.Background(), ts.URL+"/ping", 477 | hx.WhenSuccess(hx.AsJSON(&out)), 478 | hx.WhenFailure(hx.AsError()), 479 | ) 480 | if err != nil { 481 | t.Errorf("returned %v, want nil", err) 482 | } 483 | if got, want := out.Message, "foobar"; got != want { 484 | t.Errorf("returned %q, want %q", got, want) 485 | } 486 | }) 487 | 488 | t.Run("invalid", func(t *testing.T) { 489 | defer replaceDefaultOption(optErr)() 490 | 491 | err := hx.Get(context.Background(), ts.URL+"/ping") 492 | if err == nil { 493 | t.Error("returned nil, want an error") 494 | } 495 | }) 496 | } 497 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: off 5 | core: 6 | target: 85% 7 | flags: core 8 | plugins.hxlog: 9 | target: 70% 10 | flags: hxlog 11 | plugins.hxzap: 12 | target: 70% 13 | flags: hxzap 14 | plugins.pb: 15 | target: 70% 16 | flags: pb 17 | plugins.retry: 18 | target: 70% 19 | flags: retry 20 | patch: off 21 | 22 | flags: 23 | core: 24 | paths: 25 | - '!plugins/' 26 | hxlog: 27 | paths: 28 | - 'plugins/hxlog/' 29 | joined: false 30 | hxzap: 31 | paths: 32 | - 'plugins/hxzap/' 33 | joined: false 34 | pb: 35 | paths: 36 | - 'plugins/pb/' 37 | joined: false 38 | retry: 39 | paths: 40 | - 'plugins/retry/' 41 | joined: false 42 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | type Config struct { 11 | URL *url.URL 12 | Body io.Reader 13 | HTTPClient *http.Client 14 | QueryParams url.Values 15 | RequestHandlers []RequestHandler 16 | ResponseHandlers []ResponseHandler 17 | Interceptors []Interceptor 18 | } 19 | 20 | var newRequest func(ctx context.Context, meth, url string, body io.Reader) (*http.Request, error) 21 | 22 | func NewConfig() (*Config, error) { 23 | cfg := &Config{URL: new(url.URL), HTTPClient: new(http.Client), QueryParams: url.Values{}} 24 | err := cfg.Apply(DefaultOptions...) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return cfg, nil 29 | } 30 | 31 | func (cfg *Config) Apply(opts ...Option) error { 32 | for _, f := range opts { 33 | err := f.ApplyOption(cfg) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | return nil 39 | } 40 | 41 | func (cfg *Config) DoRequest(ctx context.Context, meth string) (*http.Response, error) { 42 | if len(cfg.QueryParams) > 0 { 43 | q, err := url.ParseQuery(cfg.URL.RawQuery) 44 | if err != nil { 45 | return nil, err 46 | } 47 | for k, values := range cfg.QueryParams { 48 | for _, v := range values { 49 | q.Add(k, v) 50 | } 51 | } 52 | cfg.URL.RawQuery = q.Encode() 53 | } 54 | 55 | req, err := newRequest(ctx, meth, cfg.URL.String(), cfg.Body) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | f := combineInterceptors(cfg.Interceptors).Wrap(cfg.doRequest) 61 | return f(cfg.HTTPClient, req) 62 | } 63 | 64 | func (cfg *Config) doRequest(cli *http.Client, req *http.Request) (resp *http.Response, err error) { 65 | for _, h := range cfg.RequestHandlers { 66 | req, err = h(req) 67 | if err != nil { 68 | return nil, err 69 | } 70 | } 71 | 72 | resp, err = cli.Do(req) 73 | 74 | for _, h := range cfg.ResponseHandlers { 75 | resp, err = h(resp, err) 76 | } 77 | 78 | return resp, err 79 | } 80 | -------------------------------------------------------------------------------- /config_go1.12.go: -------------------------------------------------------------------------------- 1 | // +build !go1.13 2 | 3 | package hx 4 | 5 | import ( 6 | "context" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | func init() { 12 | newRequest = func(ctx context.Context, meth, url string, body io.Reader) (*http.Request, error) { 13 | req, err := http.NewRequest(meth, url, body) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | return req.WithContext(ctx), nil 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config_go1.13.go: -------------------------------------------------------------------------------- 1 | // +build go1.13 2 | 3 | package hx 4 | 5 | import ( 6 | "net/http" 7 | ) 8 | 9 | func init() { 10 | newRequest = http.NewRequestWithContext 11 | } 12 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | 10 | "github.com/izumin5210/hx" 11 | ) 12 | 13 | func ExampleGet() { 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | switch { 16 | case r.Method == http.MethodGet && r.URL.Path == "/echo": 17 | err := json.NewEncoder(w).Encode(map[string]string{ 18 | "message": r.URL.Query().Get("message"), 19 | }) 20 | if err != nil { 21 | w.WriteHeader(http.StatusInternalServerError) 22 | return 23 | } 24 | default: 25 | w.WriteHeader(http.StatusNotFound) 26 | } 27 | })) 28 | defer ts.Close() 29 | 30 | var out struct { 31 | Message string `json:"message"` 32 | } 33 | 34 | ctx := context.Background() 35 | err := hx.Get( 36 | ctx, 37 | ts.URL+"/echo", 38 | hx.Query("message", "It Works!"), 39 | hx.WhenSuccess(hx.AsJSON(&out)), 40 | hx.WhenFailure(hx.AsError()), 41 | ) 42 | if err != nil { 43 | // Handle errors... 44 | } 45 | fmt.Println(out.Message) 46 | 47 | // Output: 48 | // It Works! 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izumin5210/hx 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /helper.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "path" 7 | "strings" 8 | ) 9 | 10 | func Path(elem ...interface{}) string { 11 | chunks := make([]string, len(elem)) 12 | for i, e := range elem { 13 | var s string 14 | switch v := e.(type) { 15 | case string: 16 | s = v 17 | case fmt.Stringer: 18 | s = v.String() 19 | default: 20 | s = fmt.Sprint(v) 21 | } 22 | chunks[i] = s 23 | } 24 | if u, err := url.Parse(chunks[0]); err == nil && u.IsAbs() { 25 | return strings.TrimSuffix(chunks[0], "/") + "/" + path.Join(chunks[1:]...) 26 | } 27 | return path.Join(chunks...) 28 | } 29 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/izumin5210/hx" 7 | ) 8 | 9 | func TestPath(t *testing.T) { 10 | cases := []struct { 11 | test string 12 | got string 13 | want string 14 | }{ 15 | { 16 | test: "simple", 17 | got: hx.Path("/api/contents", 1, "stargazers"), 18 | want: "/api/contents/1/stargazers", 19 | }, 20 | { 21 | test: "stringer", 22 | got: hx.Path("/api", "contents", fakeStringer("fakestringer"), "stargazers"), 23 | want: "/api/contents/fakestringer/stargazers", 24 | }, 25 | { 26 | test: "abs", 27 | got: hx.Path("https://api.example.com", "contents", uint32(3), "stargazers"), 28 | want: "https://api.example.com/contents/3/stargazers", 29 | }, 30 | } 31 | 32 | for _, tc := range cases { 33 | t.Run(tc.test, func(t *testing.T) { 34 | if got, want := tc.got, tc.want; got != want { 35 | t.Errorf("hx.Path(...) returns %q, want %q", got, want) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /hxutil/drain.go: -------------------------------------------------------------------------------- 1 | package hxutil 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | func DrainResponseBody(r *http.Response) error { 10 | var buf bytes.Buffer 11 | _, err := buf.ReadFrom(r.Body) 12 | if err != nil { 13 | return err 14 | } 15 | err = r.Body.Close() 16 | if err != nil { 17 | return err 18 | } 19 | r.Body = ioutil.NopCloser(&buf) 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /hxutil/drain_test.go: -------------------------------------------------------------------------------- 1 | package hxutil 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestDrainResponseBody(t *testing.T) { 11 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | switch { 13 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 14 | w.Write([]byte("pong")) 15 | default: 16 | w.WriteHeader(http.StatusNotFound) 17 | } 18 | })) 19 | defer ts.Close() 20 | 21 | t.Run("success", func(t *testing.T) { 22 | resp, err := http.Get(ts.URL + "/ping") 23 | if err != nil { 24 | t.Fatalf("unexpected error: %v", err) 25 | } 26 | defer resp.Body.Close() 27 | 28 | err = DrainResponseBody(resp) 29 | if err != nil { 30 | t.Errorf("returned %v, want nil", err) 31 | } 32 | 33 | data, err := ioutil.ReadAll(resp.Body) 34 | if err != nil { 35 | t.Errorf("returned %v, want nil", err) 36 | } else if got, want := string(data), "pong"; got != want { 37 | t.Errorf("returned %q, want %q", got, want) 38 | } 39 | }) 40 | 41 | t.Run("failure", func(t *testing.T) { 42 | resp, err := http.Get(ts.URL + "/ping") 43 | if err != nil { 44 | t.Fatalf("unexpected error: %v", err) 45 | } 46 | resp.Body.Close() 47 | 48 | err = DrainResponseBody(resp) 49 | if err == nil { 50 | t.Errorf("returned nil, want an error") 51 | } 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /hxutil/round_tripper.go: -------------------------------------------------------------------------------- 1 | package hxutil 2 | 3 | import "net/http" 4 | 5 | type RoundTripperFunc func(*http.Request, http.RoundTripper) (*http.Response, error) 6 | 7 | func (f RoundTripperFunc) Wrap(rt http.RoundTripper) http.RoundTripper { 8 | return &RoundTripperWrapper{Next: rt, Func: f} 9 | } 10 | 11 | type RoundTripperWrapper struct { 12 | Next http.RoundTripper 13 | Func func(*http.Request, http.RoundTripper) (*http.Response, error) 14 | } 15 | 16 | func (w *RoundTripperWrapper) RoundTrip(r *http.Request) (*http.Response, error) { 17 | next := w.Next 18 | if next == nil { 19 | next = http.DefaultTransport 20 | } 21 | return w.Func(r, next) 22 | } 23 | -------------------------------------------------------------------------------- /hxutil/round_tripper_test.go: -------------------------------------------------------------------------------- 1 | package hxutil_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strconv" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/izumin5210/hx/hxutil" 13 | ) 14 | 15 | func TestRoundTripperFunc(t *testing.T) { 16 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | switch { 18 | case r.Method == http.MethodPost && r.URL.Path == "/echo": 19 | cnt, _ := strconv.Atoi(r.Header.Get("Count")) 20 | if cnt == 0 { 21 | cnt = 1 22 | } 23 | var buf bytes.Buffer 24 | io.Copy(&buf, r.Body) 25 | w.Write([]byte(strings.Repeat(buf.String(), cnt))) 26 | default: 27 | w.WriteHeader(http.StatusNotFound) 28 | } 29 | })) 30 | defer ts.Close() 31 | 32 | cases := []struct { 33 | test string 34 | base http.RoundTripper 35 | }{ 36 | {test: "no base"}, 37 | {test: "specify base", base: http.DefaultTransport}, 38 | } 39 | 40 | for _, tc := range cases { 41 | t.Run(tc.test, func(t *testing.T) { 42 | cli := &http.Client{ 43 | Transport: hxutil.RoundTripperFunc(func(r *http.Request, rt http.RoundTripper) (*http.Response, error) { 44 | r.Header.Set("Count", "3") 45 | return rt.RoundTrip(r) 46 | }).Wrap(tc.base), 47 | } 48 | 49 | req, err := http.NewRequest(http.MethodPost, ts.URL+"/echo", bytes.NewBufferString("test")) 50 | if err != nil { 51 | t.Fatalf("unexpected error: %v", err) 52 | } 53 | 54 | resp, err := cli.Do(req) 55 | if err != nil { 56 | t.Fatalf("unexpected error: %v", err) 57 | } 58 | defer resp.Body.Close() 59 | 60 | var buf bytes.Buffer 61 | _, err = io.Copy(&buf, resp.Body) 62 | if err != nil { 63 | t.Fatalf("unexpected error: %v", err) 64 | } 65 | 66 | if got, want := buf.String(), "testtesttest"; got != want { 67 | t.Errorf("returned %q, want %q", got, want) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /hxutil/transport.go: -------------------------------------------------------------------------------- 1 | package hxutil 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | ) 7 | 8 | // CloneTransport creates a new *http.Transport object that has copied attributes from a given one. 9 | func CloneTransport(in *http.Transport) *http.Transport { 10 | out := new(http.Transport) 11 | outRv := reflect.ValueOf(out).Elem() 12 | 13 | rv := reflect.ValueOf(in).Elem() 14 | rt := rv.Type() 15 | 16 | n := rt.NumField() 17 | for i := 0; i < n; i++ { 18 | src, dst := rv.Field(i), outRv.Field(i) 19 | if src.Type().AssignableTo(dst.Type()) && dst.CanSet() { 20 | dst.Set(src) 21 | } 22 | } 23 | 24 | return out 25 | } 26 | -------------------------------------------------------------------------------- /hxutil/transport_test.go: -------------------------------------------------------------------------------- 1 | package hxutil_test 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/izumin5210/hx/hxutil" 10 | ) 11 | 12 | func TestCloneTransport(t *testing.T) { 13 | // https://github.com/golang/go/blob/go1.13.4/src/net/http/transport.go#L42-L54 14 | base := &http.Transport{ 15 | Proxy: http.ProxyFromEnvironment, 16 | DialContext: (&net.Dialer{ 17 | Timeout: 30 * time.Second, 18 | KeepAlive: 30 * time.Second, 19 | DualStack: true, 20 | }).DialContext, 21 | MaxIdleConns: 100, 22 | IdleConnTimeout: 90 * time.Second, 23 | TLSHandshakeTimeout: 10 * time.Second, 24 | ExpectContinueTimeout: 1 * time.Second, 25 | } 26 | 27 | cloned := hxutil.CloneTransport(base) 28 | cloned.MaxIdleConns = 500 29 | cloned.MaxIdleConnsPerHost = 100 30 | 31 | if cloned.Proxy == nil { 32 | t.Errorf("Proxy should be copied") 33 | } 34 | 35 | if cloned.DialContext == nil { 36 | t.Errorf("DialContext should be copied") 37 | } 38 | 39 | if got, want := cloned.IdleConnTimeout, base.IdleConnTimeout; got != want { 40 | t.Errorf("cloned IdleConnTimeout is %s, want %s", got, want) 41 | } 42 | 43 | if got, want := cloned.TLSHandshakeTimeout, base.TLSHandshakeTimeout; got != want { 44 | t.Errorf("cloned TLSHandshakeTimeout is %s, want %s", got, want) 45 | } 46 | 47 | if got, want := cloned.ExpectContinueTimeout, base.ExpectContinueTimeout; got != want { 48 | t.Errorf("cloned ExpectContinueTimeout is %s, want %s", got, want) 49 | } 50 | 51 | if got, want := base.MaxIdleConns, 100; got != want { 52 | t.Errorf("base MaxIdleConns is %d, want %d", got, want) 53 | } 54 | 55 | if got, want := cloned.MaxIdleConns, 500; got != want { 56 | t.Errorf("cloned MaxIdleConns is %d, want %d", got, want) 57 | } 58 | 59 | if got, want := base.MaxIdleConnsPerHost, 0; got != want { 60 | t.Errorf("base MaxIdleConnsPerHost is %d, want %d", got, want) 61 | } 62 | 63 | if got, want := cloned.MaxIdleConnsPerHost, 100; got != want { 64 | t.Errorf("cloned MaxIdleConnsPerHost is %d, want %d", got, want) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /interceptor.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import "net/http" 4 | 5 | func Intercept(i Interceptor) Option { 6 | return OptionFunc(func(c *Config) error { c.Interceptors = append(c.Interceptors, i); return nil }) 7 | } 8 | 9 | func InterceptFunc(f func(*http.Client, *http.Request, RequestFunc) (*http.Response, error)) Option { 10 | return Intercept(InterceptorFunc(f)) 11 | } 12 | 13 | type RequestFunc = func(*http.Client, *http.Request) (*http.Response, error) 14 | 15 | type Interceptor interface { 16 | DoRequest(*http.Client, *http.Request, RequestFunc) (*http.Response, error) 17 | Wrap(RequestFunc) RequestFunc 18 | } 19 | 20 | var _ Interceptor = InterceptorFunc(nil) 21 | 22 | type InterceptorFunc func(*http.Client, *http.Request, RequestFunc) (*http.Response, error) 23 | 24 | func (i InterceptorFunc) DoRequest(c *http.Client, r *http.Request, next RequestFunc) (*http.Response, error) { 25 | return i(c, r, next) 26 | } 27 | 28 | func (i InterceptorFunc) Wrap(f RequestFunc) RequestFunc { 29 | return func(c *http.Client, r *http.Request) (*http.Response, error) { return i.DoRequest(c, r, f) } 30 | } 31 | 32 | func combineInterceptors(interceptors []Interceptor) Interceptor { 33 | n := len(interceptors) 34 | 35 | return InterceptorFunc(func(cli *http.Client, req *http.Request, f RequestFunc) (*http.Response, error) { 36 | next := f 37 | 38 | for i := n - 1; i >= 0; i-- { 39 | next = interceptors[i].Wrap(next) 40 | } 41 | 42 | return next(cli, req) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /interceptor_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/izumin5210/hx" 11 | ) 12 | 13 | func TestInterceptor(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | switch { 16 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 17 | w.Write([]byte("pong")) 18 | default: 19 | w.WriteHeader(http.StatusNotFound) 20 | } 21 | })) 22 | defer ts.Close() 23 | 24 | var buf bytes.Buffer 25 | 26 | err := hx.Get(context.Background(), ts.URL+"/ping", 27 | hx.WhenSuccess(hx.AsBytesBuffer(&buf)), 28 | hx.WhenFailure(hx.AsError()), 29 | hx.InterceptFunc(func(c *http.Client, req *http.Request, f hx.RequestFunc) (*http.Response, error) { 30 | buf.WriteString("1") 31 | resp, err := f(c, req) 32 | buf.WriteString("4") 33 | return resp, err 34 | }), 35 | hx.InterceptFunc(func(c *http.Client, req *http.Request, f hx.RequestFunc) (*http.Response, error) { 36 | buf.WriteString("2") 37 | resp, err := f(c, req) 38 | buf.WriteString("3") 39 | return resp, err 40 | }), 41 | ) 42 | if err != nil { 43 | t.Errorf("returned %v, want nil", err) 44 | } 45 | 46 | if got, want := buf.String(), "12pong34"; got != want { 47 | t.Errorf("got %q, want %q", got, want) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | var DefaultJSONConfig = &JSONConfig{} 12 | 13 | type JSONConfig struct { 14 | EncodeFunc func(interface{}) (io.Reader, error) 15 | DecodeFunc func(io.Reader, interface{}) error 16 | } 17 | 18 | func (c *JSONConfig) JSON(v interface{}) Option { 19 | return OptionFunc(func(cfg *Config) error { 20 | r, err := c.encode(v) 21 | if err != nil { 22 | return err 23 | } 24 | cfg.Body = r 25 | return contentTypeJSON.ApplyOption(cfg) 26 | }) 27 | } 28 | 29 | func (c *JSONConfig) AsJSON(v interface{}) ResponseHandler { 30 | return func(r *http.Response, err error) (*http.Response, error) { 31 | if r == nil || err != nil { 32 | return r, err 33 | } 34 | 35 | defer r.Body.Close() 36 | err = c.decode(r.Body, v) 37 | if err != nil { 38 | return nil, &ResponseError{Response: r, Err: err} 39 | } 40 | return r, nil 41 | } 42 | } 43 | 44 | func (c *JSONConfig) AsJSONError(dst error) ResponseHandler { 45 | return func(r *http.Response, err error) (*http.Response, error) { 46 | if r == nil || err != nil { 47 | return r, err 48 | } 49 | err = c.decode(r.Body, dst) 50 | if err != nil { 51 | return nil, &ResponseError{Response: r, Err: err} 52 | } 53 | return nil, &ResponseError{Response: r, Err: dst} 54 | } 55 | } 56 | 57 | func (c *JSONConfig) encode(v interface{}) (io.Reader, error) { 58 | if f := c.EncodeFunc; f != nil { 59 | return f(v) 60 | } 61 | 62 | switch v := v.(type) { 63 | case io.Reader: 64 | return v, nil 65 | case string: 66 | return strings.NewReader(v), nil 67 | case []byte: 68 | return bytes.NewReader(v), nil 69 | default: 70 | data, err := json.Marshal(v) 71 | if err != nil { 72 | return nil, err 73 | } 74 | return bytes.NewReader(data), nil 75 | } 76 | } 77 | 78 | func (c *JSONConfig) decode(r io.Reader, v interface{}) error { 79 | if f := c.DecodeFunc; f != nil { 80 | return f(r, v) 81 | } 82 | 83 | return json.NewDecoder(r).Decode(v) 84 | } 85 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "bytes" 5 | "encoding" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "runtime" 12 | "strings" 13 | "time" 14 | 15 | "github.com/izumin5210/hx/hxutil" 16 | ) 17 | 18 | var ( 19 | DefaultUserAgent = fmt.Sprintf("hx/%s; %s", Version, runtime.Version()) 20 | DefaultOptions = []Option{ 21 | UserAgent(DefaultUserAgent), 22 | } 23 | 24 | contentTypeJSON = Header("Content-Type", "application/json") 25 | contentTypeForm = Header("Content-Type", "application/x-www-form-urlencoded") 26 | ) 27 | 28 | type Option interface { 29 | ApplyOption(*Config) error 30 | } 31 | 32 | type OptionFunc func(*Config) error 33 | 34 | func (f OptionFunc) ApplyOption(c *Config) error { return f(c) } 35 | 36 | func CombineOptions(opts ...Option) Option { 37 | return OptionFunc(func(c *Config) error { 38 | for _, o := range opts { 39 | err := o.ApplyOption(c) 40 | if err != nil { 41 | return err 42 | } 43 | } 44 | return nil 45 | }) 46 | } 47 | 48 | func BaseURL(baseURL *url.URL) Option { 49 | return OptionFunc(func(c *Config) error { 50 | c.URL = baseURL 51 | return nil 52 | }) 53 | } 54 | 55 | func URL(urlStr string) Option { 56 | return OptionFunc(func(c *Config) error { 57 | parse := url.Parse 58 | if u := c.URL; u != nil { 59 | parse = u.Parse 60 | } 61 | newURL, err := parse(urlStr) 62 | if err != nil { 63 | return err 64 | } 65 | c.URL = newURL 66 | return nil 67 | }) 68 | } 69 | 70 | // Query sets an url query parameter. 71 | func Query(k, v string) Option { 72 | return OptionFunc(func(c *Config) error { 73 | c.QueryParams.Add(k, v) 74 | return nil 75 | }) 76 | } 77 | 78 | // Body sets data to request body. 79 | func Body(v interface{}) Option { 80 | return OptionFunc(func(c *Config) error { 81 | switch v := v.(type) { 82 | case io.Reader: 83 | c.Body = v 84 | case string: 85 | c.Body = strings.NewReader(v) 86 | case []byte: 87 | c.Body = bytes.NewReader(v) 88 | case url.Values: 89 | c.Body = strings.NewReader(v.Encode()) 90 | _ = contentTypeForm.ApplyOption(c) 91 | case json.Marshaler: 92 | data, err := v.MarshalJSON() 93 | if err != nil { 94 | return err 95 | } 96 | c.Body = bytes.NewReader(data) 97 | _ = contentTypeJSON.ApplyOption(c) 98 | case encoding.TextMarshaler: 99 | data, err := v.MarshalText() 100 | if err != nil { 101 | return err 102 | } 103 | c.Body = bytes.NewReader(data) 104 | case fmt.Stringer: 105 | c.Body = strings.NewReader(v.String()) 106 | default: 107 | var buf bytes.Buffer 108 | err := json.NewEncoder(&buf).Encode(v) 109 | if err != nil { 110 | return err 111 | } 112 | c.Body = &buf 113 | } 114 | return nil 115 | }) 116 | } 117 | 118 | // JSON sets data to request body as json. 119 | func JSON(v interface{}) Option { return DefaultJSONConfig.JSON(v) } 120 | 121 | // HTTPClient sets a HTTP client that used to send HTTP request(s). 122 | func HTTPClient(cli *http.Client) Option { 123 | return OptionFunc(func(c *Config) error { 124 | c.HTTPClient = cli 125 | return nil 126 | }) 127 | } 128 | 129 | // Transport sets the round tripper to http.Client. 130 | func Transport(rt http.RoundTripper) Option { 131 | return OptionFunc(func(c *Config) error { 132 | c.HTTPClient.Transport = rt 133 | return nil 134 | }) 135 | } 136 | 137 | // TransportFrom sets the round tripper to http.Client. 138 | func TransportFrom(f func(http.RoundTripper) http.RoundTripper) Option { 139 | return OptionFunc(func(c *Config) error { 140 | c.HTTPClient.Transport = f(c.HTTPClient.Transport) 141 | return nil 142 | }) 143 | } 144 | 145 | func TransportFunc(f func(*http.Request, http.RoundTripper) (*http.Response, error)) Option { 146 | return TransportFrom(hxutil.RoundTripperFunc(f).Wrap) 147 | } 148 | 149 | // Timeout sets the max duration for http request(s). 150 | func Timeout(t time.Duration) Option { 151 | return OptionFunc(func(c *Config) error { 152 | c.HTTPClient.Timeout = t 153 | return nil 154 | }) 155 | } 156 | -------------------------------------------------------------------------------- /option_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/izumin5210/hx" 9 | ) 10 | 11 | func TestCombineOptions(t *testing.T) { 12 | opt1 := hx.OptionFunc(func(c *hx.Config) error { 13 | c.QueryParams.Add("foo", "1") 14 | return nil 15 | }) 16 | opt2 := hx.OptionFunc(func(c *hx.Config) error { 17 | c.QueryParams.Add("foo", "2") 18 | return nil 19 | }) 20 | optErr := hx.OptionFunc(func(c *hx.Config) error { 21 | return errors.New("error occurred") 22 | }) 23 | 24 | t.Run("valid", func(t *testing.T) { 25 | cfg, err := hx.NewConfig() 26 | if err != nil { 27 | t.Errorf("unexpected error: %v", err) 28 | } 29 | 30 | err = cfg.Apply(hx.CombineOptions(opt1, opt2)) 31 | if err != nil { 32 | t.Errorf("unexpected error: %v", err) 33 | } 34 | 35 | if got, want := cfg.QueryParams["foo"], []string{"1", "2"}; !reflect.DeepEqual(got, want) { 36 | t.Errorf("query foo is %v, want %v", got, want) 37 | } 38 | }) 39 | 40 | t.Run("error", func(t *testing.T) { 41 | cfg, err := hx.NewConfig() 42 | if err != nil { 43 | t.Errorf("unexpected error: %v", err) 44 | } 45 | 46 | err = cfg.Apply(hx.CombineOptions(opt1, optErr)) 47 | if err == nil { 48 | t.Error("returned nil, want an error") 49 | } 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /plugins/hxlog/export_test.go: -------------------------------------------------------------------------------- 1 | package hxlog 2 | 3 | import "time" 4 | 5 | func SetNow(f func() time.Time) func() { 6 | tmp := now 7 | now = f 8 | return func() { now = tmp } 9 | } 10 | 11 | func SetSince(f func(time.Time) time.Duration) func() { 12 | tmp := since 13 | since = f 14 | return func() { since = tmp } 15 | } 16 | -------------------------------------------------------------------------------- /plugins/hxlog/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izumin5210/hx/plugins/hxlog 2 | 3 | go 1.13 4 | 5 | require github.com/izumin5210/hx v0.3.0 6 | -------------------------------------------------------------------------------- /plugins/hxlog/go.sum: -------------------------------------------------------------------------------- 1 | github.com/izumin5210/hx v0.3.0 h1:u/f/FD5ndmmQF20T37R6HXJOfsDJw5+2vnxCybhjCD4= 2 | github.com/izumin5210/hx v0.3.0/go.mod h1:qk5q6mT+k4oIHnTt6sHNmUF4DG0Ls2wkwg8jN3ZzuP0= 3 | -------------------------------------------------------------------------------- /plugins/hxlog/transport.go: -------------------------------------------------------------------------------- 1 | package hxlog 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "github.com/izumin5210/hx/hxutil" 10 | ) 11 | 12 | func New() hxutil.RoundTripperFunc { 13 | return With(log.New(os.Stderr, "[hx] ", log.LstdFlags)) 14 | } 15 | 16 | func With(l *log.Logger) hxutil.RoundTripperFunc { 17 | return func(req *http.Request, next http.RoundTripper) (*http.Response, error) { 18 | t := now() 19 | 20 | l.Printf("Request %s %s %s", req.Proto, req.Method, req.URL.String()) 21 | 22 | resp, err := next.RoundTrip(req) 23 | 24 | d := since(t) 25 | 26 | if err != nil { 27 | l.Printf("Response error: %s: %s %s (%s)", err.Error(), req.Method, req.URL.String(), d.String()) 28 | } else { 29 | l.Printf("Response %s: %s %s (%s)", resp.Status, req.Method, req.URL.String(), d.String()) 30 | } 31 | 32 | return resp, err 33 | } 34 | } 35 | 36 | var ( 37 | now = time.Now 38 | since = time.Since 39 | ) 40 | -------------------------------------------------------------------------------- /plugins/hxlog/transport_test.go: -------------------------------------------------------------------------------- 1 | package hxlog_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | "time" 12 | 13 | "github.com/izumin5210/hx" 14 | "github.com/izumin5210/hx/plugins/hxlog" 15 | ) 16 | 17 | func TestWith(t *testing.T) { 18 | loc := time.FixedZone("Asia/Tokyo", 9*60*60) 19 | now := time.Date(2019, time.November, 24, 24, 32, 48, 0, loc) 20 | defer hxlog.SetNow(func() time.Time { return now })() 21 | respTime := 32 * time.Millisecond 22 | defer hxlog.SetSince(func(time.Time) time.Duration { return respTime })() 23 | 24 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | switch { 26 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 27 | json.NewEncoder(w).Encode(map[string]string{"message": "pong"}) 28 | case r.Method == http.MethodGet && r.URL.Path == "/sleep": 29 | time.Sleep(100 * time.Millisecond) 30 | default: 31 | w.WriteHeader(http.StatusNotFound) 32 | } 33 | })) 34 | defer ts.Close() 35 | 36 | t.Run("success", func(t *testing.T) { 37 | var buf bytes.Buffer 38 | log := log.New(&buf, "", 0) 39 | 40 | err := hx.Get(context.Background(), ts.URL+"/ping", 41 | hx.TransportFunc(hxlog.With(log)), 42 | hx.WhenFailure(hx.AsError()), 43 | ) 44 | 45 | if err != nil { 46 | t.Errorf("returned %v, want nil", err) 47 | } 48 | 49 | if got, want := buf.String(), 50 | "Request HTTP/1.1 GET "+ts.URL+"/ping\n"+ 51 | "Response 200 OK: GET "+ts.URL+"/ping (32ms)\n"; got != want { 52 | t.Errorf("got:\n%s\nwant:\n%s", got, want) 53 | } 54 | }) 55 | 56 | t.Run("failure", func(t *testing.T) { 57 | var buf bytes.Buffer 58 | log := log.New(&buf, "", 0) 59 | 60 | err := hx.Get(context.Background(), ts.URL+"/foobar", 61 | hx.TransportFunc(hxlog.With(log)), 62 | hx.WhenFailure(hx.AsError()), 63 | ) 64 | 65 | if err == nil { 66 | t.Error("returned nil, want an error") 67 | } 68 | 69 | if got, want := buf.String(), 70 | "Request HTTP/1.1 GET "+ts.URL+"/foobar\n"+ 71 | "Response 404 Not Found: GET "+ts.URL+"/foobar (32ms)\n"; got != want { 72 | t.Errorf("got:\n%s\nwant:\n%s", got, want) 73 | } 74 | }) 75 | 76 | t.Run("error", func(t *testing.T) { 77 | var buf bytes.Buffer 78 | log := log.New(&buf, "", 0) 79 | 80 | err := hx.Get(context.Background(), ts.URL+"/sleep", 81 | hx.TransportFunc(hxlog.With(log)), 82 | hx.WhenFailure(hx.AsError()), 83 | hx.Timeout(10*time.Millisecond), 84 | ) 85 | 86 | if err == nil { 87 | t.Error("returned nil, want an error") 88 | } 89 | 90 | if got, want := buf.String(), 91 | "Request HTTP/1.1 GET "+ts.URL+"/sleep\n"+ 92 | "Response error: net/http: request canceled: GET "+ts.URL+"/sleep (32ms)\n"; got != want { 93 | t.Errorf("got:\n%s\nwant:\n%s", got, want) 94 | } 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /plugins/hxzap/export_test.go: -------------------------------------------------------------------------------- 1 | package hxzap 2 | 3 | import "time" 4 | 5 | func SetNow(f func() time.Time) func() { 6 | tmp := now 7 | now = f 8 | return func() { now = tmp } 9 | } 10 | 11 | func SetSince(f func(time.Time) time.Duration) func() { 12 | tmp := since 13 | since = f 14 | return func() { since = tmp } 15 | } 16 | -------------------------------------------------------------------------------- /plugins/hxzap/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izumin5210/hx/plugins/hxzap 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/izumin5210/hx v0.3.0 7 | go.uber.org/zap v1.13.0 8 | ) 9 | -------------------------------------------------------------------------------- /plugins/hxzap/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 7 | github.com/izumin5210/hx v0.3.0 h1:u/f/FD5ndmmQF20T37R6HXJOfsDJw5+2vnxCybhjCD4= 8 | github.com/izumin5210/hx v0.3.0/go.mod h1:qk5q6mT+k4oIHnTt6sHNmUF4DG0Ls2wkwg8jN3ZzuP0= 9 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 10 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 11 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 12 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 13 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 14 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 15 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 16 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 22 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 23 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 24 | go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= 25 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 26 | go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc= 27 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 28 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= 29 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 30 | go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU= 31 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 34 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= 35 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 36 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 37 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 38 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 39 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 40 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 44 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 45 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 46 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 47 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs= 48 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 49 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 51 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 52 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 54 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 55 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 56 | honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= 57 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 58 | -------------------------------------------------------------------------------- /plugins/hxzap/transport.go: -------------------------------------------------------------------------------- 1 | package hxzap 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/izumin5210/hx/hxutil" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func New() hxutil.RoundTripperFunc { 12 | return With(zap.L().Named("hx")) 13 | } 14 | 15 | func With(l *zap.Logger) hxutil.RoundTripperFunc { 16 | return func(req *http.Request, next http.RoundTripper) (*http.Response, error) { 17 | t := now() 18 | 19 | l := l.With( 20 | zap.String("proto", req.Proto), 21 | zap.String("method", req.Method), 22 | zap.String("host", req.URL.Host), 23 | zap.String("path", req.URL.Path), 24 | zap.Stringer("url", req.URL), 25 | ) 26 | 27 | l.Info("Request", zap.Int64("content_length", req.ContentLength)) 28 | 29 | resp, err := next.RoundTrip(req) 30 | 31 | d := since(t) 32 | 33 | if err != nil { 34 | l.Info("Response error", 35 | zap.Error(err), 36 | zap.Duration("response_time", d), 37 | ) 38 | } else { 39 | l.Info("Response", 40 | zap.String("status", resp.Status), 41 | zap.Int("status_code", resp.StatusCode), 42 | zap.Int64("content_length", resp.ContentLength), 43 | zap.Duration("response_time", d), 44 | ) 45 | } 46 | 47 | return resp, err 48 | } 49 | } 50 | 51 | var ( 52 | now = time.Now 53 | since = time.Since 54 | ) 55 | -------------------------------------------------------------------------------- /plugins/hxzap/transport_test.go: -------------------------------------------------------------------------------- 1 | package hxzap_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/izumin5210/hx" 14 | "github.com/izumin5210/hx/plugins/hxzap" 15 | "go.uber.org/zap" 16 | "go.uber.org/zap/zapcore" 17 | "go.uber.org/zap/zaptest/observer" 18 | ) 19 | 20 | func TestWith(t *testing.T) { 21 | loc := time.FixedZone("Asia/Tokyo", 9*60*60) 22 | now := time.Date(2019, time.November, 24, 24, 32, 48, 0, loc) 23 | defer hxzap.SetNow(func() time.Time { return now })() 24 | respTime := 32 * time.Millisecond 25 | defer hxzap.SetSince(func(time.Time) time.Duration { return respTime })() 26 | 27 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | switch { 29 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 30 | json.NewEncoder(w).Encode(map[string]string{"message": "pong"}) 31 | case r.Method == http.MethodGet && r.URL.Path == "/sleep": 32 | time.Sleep(100 * time.Millisecond) 33 | default: 34 | w.WriteHeader(http.StatusNotFound) 35 | } 36 | })) 37 | defer ts.Close() 38 | 39 | t.Run("success", func(t *testing.T) { 40 | core, logs := observer.New(zapcore.DebugLevel) 41 | 42 | err := hx.Get(context.Background(), ts.URL+"/ping", 43 | hx.TransportFunc(hxzap.With(zap.New(core))), 44 | hx.WhenFailure(hx.AsError()), 45 | ) 46 | 47 | if err != nil { 48 | t.Errorf("returned %v, want nil", err) 49 | } 50 | 51 | if got, want := logs.Len(), 2; got != want { 52 | t.Errorf("logged %d items, want %d", got, want) 53 | } else { 54 | if got, want := logs.All()[0].ContextMap(), map[string]interface{}{ 55 | "proto": "HTTP/1.1", 56 | "method": "GET", 57 | "host": strings.TrimPrefix(ts.URL, "http://"), 58 | "path": "/ping", 59 | "url": ts.URL + "/ping", 60 | "content_length": int64(0), 61 | }; !reflect.DeepEqual(got, want) { 62 | t.Errorf("got:\n%v\nwant:\n%v", got, want) 63 | } 64 | if got, want := logs.All()[1].ContextMap(), map[string]interface{}{ 65 | "proto": "HTTP/1.1", 66 | "method": "GET", 67 | "host": strings.TrimPrefix(ts.URL, "http://"), 68 | "path": "/ping", 69 | "url": ts.URL + "/ping", 70 | "status": "200 OK", 71 | "status_code": int64(200), 72 | "response_time": respTime, 73 | "content_length": int64(19), 74 | }; !reflect.DeepEqual(got, want) { 75 | t.Errorf("got:\n%v\nwant:\n%v", got, want) 76 | } 77 | } 78 | }) 79 | 80 | t.Run("failure", func(t *testing.T) { 81 | core, logs := observer.New(zapcore.DebugLevel) 82 | 83 | err := hx.Get(context.Background(), ts.URL+"/foobar", 84 | hx.TransportFunc(hxzap.With(zap.New(core))), 85 | hx.WhenFailure(hx.AsError()), 86 | ) 87 | 88 | if err == nil { 89 | t.Error("returned nil, want an error") 90 | } 91 | 92 | if got, want := logs.Len(), 2; got != want { 93 | t.Errorf("logged %d items, want %d", got, want) 94 | } else { 95 | if got, want := logs.All()[0].ContextMap(), map[string]interface{}{ 96 | "proto": "HTTP/1.1", 97 | "method": "GET", 98 | "host": strings.TrimPrefix(ts.URL, "http://"), 99 | "path": "/foobar", 100 | "url": ts.URL + "/foobar", 101 | "content_length": int64(0), 102 | }; !reflect.DeepEqual(got, want) { 103 | t.Errorf("got:\n%v\nwant:\n%v", got, want) 104 | } 105 | if got, want := logs.All()[1].ContextMap(), map[string]interface{}{ 106 | "proto": "HTTP/1.1", 107 | "method": "GET", 108 | "host": strings.TrimPrefix(ts.URL, "http://"), 109 | "path": "/foobar", 110 | "url": ts.URL + "/foobar", 111 | "status": "404 Not Found", 112 | "status_code": int64(404), 113 | "response_time": respTime, 114 | "content_length": int64(0), 115 | }; !reflect.DeepEqual(got, want) { 116 | t.Errorf("got:\n%v\nwant:\n%v", got, want) 117 | } 118 | } 119 | }) 120 | 121 | t.Run("error", func(t *testing.T) { 122 | core, logs := observer.New(zapcore.DebugLevel) 123 | 124 | err := hx.Get(context.Background(), ts.URL+"/sleep", 125 | hx.TransportFunc(hxzap.With(zap.New(core))), 126 | hx.WhenFailure(hx.AsError()), 127 | hx.Timeout(10*time.Millisecond), 128 | ) 129 | 130 | if err == nil { 131 | t.Error("returned nil, want an error") 132 | } 133 | 134 | if got, want := logs.Len(), 2; got != want { 135 | t.Errorf("logged %d items, want %d", got, want) 136 | } else { 137 | if got, want := logs.All()[0].ContextMap(), map[string]interface{}{ 138 | "proto": "HTTP/1.1", 139 | "method": "GET", 140 | "host": strings.TrimPrefix(ts.URL, "http://"), 141 | "path": "/sleep", 142 | "url": ts.URL + "/sleep", 143 | "content_length": int64(0), 144 | }; !reflect.DeepEqual(got, want) { 145 | t.Errorf("got:\n%v\nwant:\n%v", got, want) 146 | } 147 | if got, want := logs.All()[1].ContextMap(), map[string]interface{}{ 148 | "proto": "HTTP/1.1", 149 | "method": "GET", 150 | "host": strings.TrimPrefix(ts.URL, "http://"), 151 | "path": "/sleep", 152 | "url": ts.URL + "/sleep", 153 | "error": "net/http: request canceled", 154 | "response_time": respTime, 155 | }; !reflect.DeepEqual(got, want) { 156 | t.Errorf("got:\n%#v\nwant:\n%#v", got, want) 157 | } 158 | } 159 | }) 160 | } 161 | -------------------------------------------------------------------------------- /plugins/pb/README.md: -------------------------------------------------------------------------------- 1 | # `pb` - Marshaling and Unmarshaling Protocol Buffers 2 | [![GoDoc](https://godoc.org/github.com/izumin5210/hx/pb?status.svg)](https://godoc.org/github.com/izumin5210/hx/pb) 3 | 4 | ```go 5 | err := hx.Post(ctx, "https://api.example.com/contents", 6 | pb.Proto(&in), 7 | hx.WhenSuccess(pb.AsProto(&out)), 8 | hx.WhenFailure(hx.AsError()), 9 | ) 10 | ``` 11 | -------------------------------------------------------------------------------- /plugins/pb/doc.go: -------------------------------------------------------------------------------- 1 | // A plugin for marshaling and umarshaling protocol buffers. 2 | package pb 3 | -------------------------------------------------------------------------------- /plugins/pb/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izumin5210/hx/plugins/pb 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/golang/protobuf v1.3.2 7 | github.com/google/go-cmp v0.3.1 8 | github.com/izumin5210/hx v0.3.0 9 | ) 10 | -------------------------------------------------------------------------------- /plugins/pb/go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 2 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 3 | github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= 4 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 5 | github.com/izumin5210/hx v0.3.0 h1:u/f/FD5ndmmQF20T37R6HXJOfsDJw5+2vnxCybhjCD4= 6 | github.com/izumin5210/hx v0.3.0/go.mod h1:qk5q6mT+k4oIHnTt6sHNmUF4DG0Ls2wkwg8jN3ZzuP0= 7 | -------------------------------------------------------------------------------- /plugins/pb/json.go: -------------------------------------------------------------------------------- 1 | package pb 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/golang/protobuf/jsonpb" 9 | "github.com/golang/protobuf/proto" 10 | "github.com/izumin5210/hx" 11 | ) 12 | 13 | var ( 14 | DefaultJSONConfig = &JSONConfig{} 15 | 16 | contentTypeJSON = hx.Header("Content-Type", "application/json") 17 | ) 18 | 19 | // JSON sets proto.Message to request body as json. 20 | // This will marshal a given data with jsonpb.Marshaler in default. 21 | func JSON(pb proto.Message) hx.Option { 22 | return DefaultJSONConfig.JSON(pb) 23 | } 24 | 25 | // AsJSON is hx.ResponseHandler for unmarshaling response bodies as JSON. 26 | // This will unmarshal a received data with jsonpb.Unmarshaler in default. 27 | func AsJSON(pb proto.Message) hx.ResponseHandler { 28 | return DefaultJSONConfig.AsJSON(pb) 29 | } 30 | 31 | type JSONConfig struct { 32 | jsonpb.Marshaler 33 | jsonpb.Unmarshaler 34 | EncodeFunc func(proto.Message) (io.Reader, error) 35 | DecodeFunc func(io.Reader, proto.Message) error 36 | } 37 | 38 | func (c *JSONConfig) JSON(pb proto.Message) hx.Option { 39 | return hx.OptionFunc(func(hc *hx.Config) error { 40 | r, err := c.encode(pb) 41 | if err != nil { 42 | return err 43 | } 44 | hc.Body = r 45 | return contentTypeJSON.ApplyOption(hc) 46 | }) 47 | } 48 | 49 | func (c *JSONConfig) AsJSON(pb proto.Message) hx.ResponseHandler { 50 | return func(r *http.Response, err error) (*http.Response, error) { 51 | if r == nil || err != nil { 52 | return r, err 53 | } 54 | 55 | defer r.Body.Close() 56 | err = c.decode(r.Body, pb) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return r, nil 61 | } 62 | } 63 | 64 | func (c *JSONConfig) encode(pb proto.Message) (io.Reader, error) { 65 | if f := c.EncodeFunc; f != nil { 66 | return f(pb) 67 | } 68 | 69 | var buf bytes.Buffer 70 | err := c.Marshal(&buf, pb) 71 | if err != nil { 72 | return nil, err 73 | } 74 | return &buf, nil 75 | } 76 | 77 | func (c *JSONConfig) decode(r io.Reader, pb proto.Message) error { 78 | if f := c.DecodeFunc; f != nil { 79 | return f(r, pb) 80 | } 81 | 82 | return c.Unmarshal(r, pb) 83 | } 84 | -------------------------------------------------------------------------------- /plugins/pb/json_test.go: -------------------------------------------------------------------------------- 1 | package pb_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/golang/protobuf/jsonpb" 12 | "github.com/golang/protobuf/proto" 13 | "github.com/golang/protobuf/proto/proto3_proto" 14 | "github.com/izumin5210/hx" 15 | "github.com/izumin5210/hx/plugins/pb" 16 | ) 17 | 18 | func TestJSON(t *testing.T) { 19 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | defer r.Body.Close() 21 | switch { 22 | case r.Method == http.MethodPost && r.URL.Path == "/echo": 23 | if r.Header.Get("Content-Type") != "application/json" { 24 | w.WriteHeader(http.StatusBadRequest) 25 | return 26 | } 27 | 28 | var ( 29 | msg proto3_proto.Message 30 | buf bytes.Buffer 31 | ) 32 | 33 | err := (&jsonpb.Unmarshaler{}).Unmarshal(r.Body, &msg) 34 | if err != nil { 35 | w.WriteHeader(http.StatusBadRequest) 36 | return 37 | } 38 | 39 | err = (&jsonpb.Marshaler{}).Marshal(&buf, &msg) 40 | if err != nil { 41 | w.WriteHeader(http.StatusBadRequest) 42 | return 43 | } 44 | 45 | w.Write(buf.Bytes()) 46 | 47 | default: 48 | w.WriteHeader(http.StatusNotFound) 49 | } 50 | })) 51 | defer ts.Close() 52 | 53 | want := &proto3_proto.Message{ 54 | Name: "It, Works!", 55 | Score: 120, 56 | Hilarity: proto3_proto.Message_SLAPSTICK, 57 | Children: []*proto3_proto.Message{ 58 | {Name: "foo", HeightInCm: 170}, 59 | {Name: "bar", TrueScotsman: true}, 60 | }, 61 | } 62 | 63 | t.Run("simple", func(t *testing.T) { 64 | var got proto3_proto.Message 65 | err := hx.Post(context.Background(), ts.URL+"/echo", 66 | pb.JSON(want), 67 | hx.WhenSuccess(pb.AsJSON(&got)), 68 | hx.WhenFailure(hx.AsError()), 69 | ) 70 | if err != nil { 71 | t.Errorf("returned %v, want nil", err) 72 | } 73 | assertProtoMessage(t, want, &got) 74 | }) 75 | 76 | t.Run("custom encoder", func(t *testing.T) { 77 | var got, overwrited proto3_proto.Message 78 | overwrited = *want 79 | overwrited.Name = "It, Works!!!!!!!!!!!!!!!!!!!!!!" 80 | 81 | jsonCfg := &pb.JSONConfig{ 82 | EncodeFunc: func(_ proto.Message) (io.Reader, error) { 83 | var buf bytes.Buffer 84 | err := (&jsonpb.Marshaler{}).Marshal(&buf, &overwrited) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return &buf, nil 89 | }, 90 | } 91 | err := hx.Post(context.Background(), ts.URL+"/echo", 92 | jsonCfg.JSON(want), 93 | hx.WhenSuccess(pb.AsJSON(&got)), 94 | hx.WhenFailure(hx.AsError()), 95 | ) 96 | if err != nil { 97 | t.Errorf("returned %v, want nil", err) 98 | } 99 | assertProtoMessage(t, &overwrited, &got) 100 | }) 101 | 102 | t.Run("custom decoder", func(t *testing.T) { 103 | var got, overwrited proto3_proto.Message 104 | overwrited = *want 105 | overwrited.Name = "It, Works!!!!!!!!!!!!!!!!!!!!!!" 106 | 107 | jsonCfg := &pb.JSONConfig{ 108 | DecodeFunc: func(r io.Reader, m proto.Message) error { 109 | (*m.(*proto3_proto.Message)) = *want 110 | return nil 111 | }, 112 | } 113 | err := hx.Post(context.Background(), ts.URL+"/echo", 114 | pb.JSON(&overwrited), 115 | hx.WhenSuccess(jsonCfg.AsJSON(&got)), 116 | hx.WhenFailure(hx.AsError()), 117 | ) 118 | if err != nil { 119 | t.Errorf("returned %v, want nil", err) 120 | } 121 | assertProtoMessage(t, want, &got) 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /plugins/pb/proto.go: -------------------------------------------------------------------------------- 1 | package pb 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/golang/protobuf/proto" 9 | "github.com/izumin5210/hx" 10 | ) 11 | 12 | var DefaultProtoConfig = &ProtoConfig{} 13 | 14 | // Proto sets proto.Message to request body as protocol buffers. 15 | // This will marshal a given data with proto.Marshal in default. 16 | func Proto(pb proto.Message) hx.Option { 17 | return DefaultProtoConfig.Proto(pb) 18 | } 19 | 20 | // AsProto is hx.ResponseHandler for unmarshaling response bodies as Proto. 21 | // This will unmarshal a received data with proto.Unmarshal marshaler in default. 22 | func AsProto(pb proto.Message) hx.ResponseHandler { 23 | return DefaultProtoConfig.AsProto(pb) 24 | } 25 | 26 | type ProtoConfig struct { 27 | EncodeFunc func(proto.Message) (io.Reader, error) 28 | DecodeFunc func(io.Reader, proto.Message) error 29 | } 30 | 31 | func (c *ProtoConfig) Proto(pb proto.Message) hx.Option { 32 | return hx.OptionFunc(func(hc *hx.Config) error { 33 | r, err := c.encode(pb) 34 | if err != nil { 35 | return err 36 | } 37 | hc.Body = r 38 | return nil 39 | }) 40 | } 41 | 42 | func (c *ProtoConfig) AsProto(pb proto.Message) hx.ResponseHandler { 43 | return func(r *http.Response, err error) (*http.Response, error) { 44 | if r == nil || err != nil { 45 | return r, err 46 | } 47 | 48 | defer r.Body.Close() 49 | err = c.decode(r.Body, pb) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return r, nil 54 | } 55 | } 56 | 57 | func (c *ProtoConfig) encode(pb proto.Message) (io.Reader, error) { 58 | if f := c.EncodeFunc; f != nil { 59 | return f(pb) 60 | } 61 | 62 | data, err := proto.Marshal(pb) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return bytes.NewReader(data), nil 67 | } 68 | 69 | func (c *ProtoConfig) decode(r io.Reader, pb proto.Message) error { 70 | if f := c.DecodeFunc; f != nil { 71 | return f(r, pb) 72 | } 73 | 74 | var buf bytes.Buffer 75 | _, err := io.Copy(&buf, r) 76 | if err != nil { 77 | return err 78 | } 79 | return proto.Unmarshal(buf.Bytes(), pb) 80 | } 81 | -------------------------------------------------------------------------------- /plugins/pb/proto_test.go: -------------------------------------------------------------------------------- 1 | package pb_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/golang/protobuf/proto" 13 | "github.com/golang/protobuf/proto/proto3_proto" 14 | "github.com/google/go-cmp/cmp" 15 | "github.com/izumin5210/hx" 16 | "github.com/izumin5210/hx/plugins/pb" 17 | ) 18 | 19 | func TestProto(t *testing.T) { 20 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | defer r.Body.Close() 22 | switch { 23 | case r.Method == http.MethodPost && r.URL.Path == "/echo": 24 | var ( 25 | msg proto3_proto.Message 26 | buf bytes.Buffer 27 | ) 28 | 29 | _, err := io.Copy(&buf, r.Body) 30 | if err != nil { 31 | w.WriteHeader(http.StatusBadRequest) 32 | return 33 | } 34 | 35 | err = proto.Unmarshal(buf.Bytes(), &msg) 36 | if err != nil { 37 | w.WriteHeader(http.StatusBadRequest) 38 | return 39 | } 40 | 41 | data, err := proto.Marshal(&msg) 42 | if err != nil { 43 | w.WriteHeader(http.StatusBadRequest) 44 | return 45 | } 46 | 47 | w.Write(data) 48 | 49 | default: 50 | w.WriteHeader(http.StatusNotFound) 51 | } 52 | })) 53 | defer ts.Close() 54 | 55 | want := &proto3_proto.Message{ 56 | Name: "It, Works!", 57 | Score: 120, 58 | Hilarity: proto3_proto.Message_SLAPSTICK, 59 | Children: []*proto3_proto.Message{ 60 | {Name: "foo", HeightInCm: 170}, 61 | {Name: "bar", TrueScotsman: true}, 62 | }, 63 | } 64 | 65 | t.Run("simple", func(t *testing.T) { 66 | var got proto3_proto.Message 67 | err := hx.Post(context.Background(), ts.URL+"/echo", 68 | pb.Proto(want), 69 | hx.WhenSuccess(pb.AsProto(&got)), 70 | hx.WhenFailure(hx.AsError()), 71 | ) 72 | if err != nil { 73 | t.Errorf("returned %v, want nil", err) 74 | } 75 | assertProtoMessage(t, want, &got) 76 | }) 77 | 78 | t.Run("custom encoder", func(t *testing.T) { 79 | var got, overwrited proto3_proto.Message 80 | overwrited = *want 81 | overwrited.Name = "It, Works!!!!!!!!!!!!!!!!!!!!!!" 82 | 83 | protoCfg := &pb.ProtoConfig{ 84 | EncodeFunc: func(_ proto.Message) (io.Reader, error) { 85 | data, err := proto.Marshal(&overwrited) 86 | if err != nil { 87 | return nil, err 88 | } 89 | return bytes.NewReader(data), nil 90 | }, 91 | } 92 | err := hx.Post(context.Background(), ts.URL+"/echo", 93 | protoCfg.Proto(want), 94 | hx.WhenSuccess(pb.AsProto(&got)), 95 | hx.WhenFailure(hx.AsError()), 96 | ) 97 | if err != nil { 98 | t.Errorf("returned %v, want nil", err) 99 | } 100 | assertProtoMessage(t, &overwrited, &got) 101 | }) 102 | 103 | t.Run("custom decoder", func(t *testing.T) { 104 | var got, overwrited proto3_proto.Message 105 | overwrited = *want 106 | overwrited.Name = "It, Works!!!!!!!!!!!!!!!!!!!!!!" 107 | 108 | protoCfg := &pb.ProtoConfig{ 109 | DecodeFunc: func(r io.Reader, m proto.Message) error { 110 | (*m.(*proto3_proto.Message)) = *want 111 | return nil 112 | }, 113 | } 114 | err := hx.Post(context.Background(), ts.URL+"/echo", 115 | pb.Proto(&overwrited), 116 | hx.WhenSuccess(protoCfg.AsProto(&got)), 117 | hx.WhenFailure(hx.AsError()), 118 | ) 119 | if err != nil { 120 | t.Errorf("returned %v, want nil", err) 121 | } 122 | assertProtoMessage(t, want, &got) 123 | }) 124 | } 125 | 126 | func assertProtoMessage(t *testing.T, want proto.Message, got proto.Message) { 127 | t.Helper() 128 | if diff := cmp.Diff(want, got, cmp.FilterPath( 129 | func(p cmp.Path) bool { return !strings.HasPrefix(p.Last().String(), "XXX_") }, 130 | cmp.Ignore(), 131 | )); diff != "" { 132 | t.Errorf("response mismatch(-want +got)\n%s", diff) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /plugins/retry/README.md: -------------------------------------------------------------------------------- 1 | # retry 2 | 3 | Backoff retry with `Idempotency-Key` 4 | 5 | ```go 6 | var cont Content 7 | 8 | bo := backoff.NewExponentialBackOff() 9 | bo.InitialInterval = 50 * time.Millisecond 10 | bo.MaxInterval = 500 * time.Millisecond 11 | 12 | err := hx.Get(ctx, "https://api.example.com/contents/1", 13 | retry.When(hx.Any(hx.IsServerError, hx.IsTemporaryError), bo), 14 | hx.WhenSuccess(hx.AsJSON(&cont)), 15 | hx.WhenFailure(hx.AsError()), 16 | ) 17 | ``` 18 | -------------------------------------------------------------------------------- /plugins/retry/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izumin5210/hx/plugins/retry 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/cenkalti/backoff/v3 v3.0.0 7 | github.com/google/uuid v1.1.1 8 | github.com/izumin5210/hx v0.3.0 9 | ) 10 | -------------------------------------------------------------------------------- /plugins/retry/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= 2 | github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= 3 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 4 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/izumin5210/hx v0.3.0 h1:u/f/FD5ndmmQF20T37R6HXJOfsDJw5+2vnxCybhjCD4= 6 | github.com/izumin5210/hx v0.3.0/go.mod h1:qk5q6mT+k4oIHnTt6sHNmUF4DG0Ls2wkwg8jN3ZzuP0= 7 | -------------------------------------------------------------------------------- /plugins/retry/options.go: -------------------------------------------------------------------------------- 1 | // A plugin for retry HTTP requests. 2 | package retry 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/cenkalti/backoff/v3" 8 | "github.com/izumin5210/hx" 9 | ) 10 | 11 | // When creates an option that provides retry mechanism for your http client. 12 | // bo := backoff.NewExponentialBackOff() 13 | // bo.InitialInterval = 50 * time.Millisecond 14 | // bo.MaxInterval = 500 * time.Millisecond 15 | // 16 | // err := hx.Post(ctx, "https://example.com/api/messages", 17 | // retry.When(hx.Any(hx.IsServerError(), hx.IsTemporaryError()), bo), 18 | // hx.JSON(&in), 19 | // hx.WhenSuccess(hx.AsJSON(&out)), 20 | // hx.WhenFailure(hx.AsError()), 21 | // ) 22 | func When(cond hx.ResponseHandlerCond, bo backoff.BackOff) hx.Option { 23 | return hx.TransportFrom(func(t http.RoundTripper) http.RoundTripper { 24 | return NewTransport(t, cond, bo) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /plugins/retry/retry_test.go: -------------------------------------------------------------------------------- 1 | package retry_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | 12 | "github.com/cenkalti/backoff/v3" 13 | "github.com/izumin5210/hx" 14 | "github.com/izumin5210/hx/plugins/retry" 15 | ) 16 | 17 | func TestRetry(t *testing.T) { 18 | type Message struct { 19 | UserID int `json:"user_id"` 20 | Body string `json:"body"` 21 | } 22 | var ( 23 | failCount = 2 24 | gotMessages []Message 25 | ) 26 | idempotencyKeys := map[string]interface{}{} 27 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | switch { 29 | case r.Method == http.MethodPost && r.URL.Path == "/messages": 30 | if failCount > 0 { 31 | failCount-- 32 | w.WriteHeader(http.StatusBadGateway) 33 | return 34 | } 35 | failCount-- 36 | if _, ok := idempotencyKeys[r.Header.Get("Idempotency-Key")]; ok { 37 | w.WriteHeader(http.StatusNoContent) 38 | return 39 | } 40 | var msg Message 41 | json.NewDecoder(r.Body).Decode(&msg) 42 | gotMessages = append(gotMessages, msg) 43 | 44 | w.WriteHeader(http.StatusCreated) 45 | json.NewEncoder(w).Encode(&msg) 46 | default: 47 | w.WriteHeader(http.StatusNotFound) 48 | } 49 | })) 50 | defer ts.Close() 51 | 52 | bo := backoff.NewExponentialBackOff() 53 | bo.InitialInterval = 50 * time.Millisecond 54 | bo.MaxInterval = 500 * time.Millisecond 55 | 56 | in := Message{ 57 | UserID: 123, 58 | Body: "Hello!", 59 | } 60 | var out Message 61 | 62 | err := hx.Post(context.Background(), ts.URL+"/messages", 63 | retry.When(hx.Any(hx.IsServerError, hx.IsTemporaryError), bo), 64 | hx.JSON(&in), 65 | hx.WhenSuccess(hx.AsJSON(&out)), 66 | hx.WhenFailure(hx.AsError()), 67 | ) 68 | if err != nil { 69 | t.Errorf("returned %v, want nil", err) 70 | } 71 | 72 | if got, want := in, out; !reflect.DeepEqual(got, want) { 73 | t.Errorf("returned %v, want %v", got, want) 74 | } 75 | 76 | if got, want := gotMessages, []Message{in}; !reflect.DeepEqual(got, want) { 77 | t.Errorf("got %v, want %v", got, want) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /plugins/retry/transport.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/cenkalti/backoff/v3" 10 | "github.com/google/uuid" 11 | "github.com/izumin5210/hx" 12 | ) 13 | 14 | type Transport struct { 15 | parent http.RoundTripper 16 | cond hx.ResponseHandlerCond 17 | bo backoff.BackOff 18 | } 19 | 20 | var _ http.RoundTripper = (*Transport)(nil) 21 | 22 | func NewTransport( 23 | parent http.RoundTripper, 24 | cond hx.ResponseHandlerCond, 25 | bo backoff.BackOff, 26 | ) *Transport { 27 | return &Transport{ 28 | parent: parent, 29 | cond: cond, 30 | bo: bo, 31 | } 32 | } 33 | 34 | func (t *Transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { 35 | bo := backoff.WithContext(t.bo, req.Context()) 36 | bo.Reset() 37 | 38 | if req.Body != nil { 39 | var buf bytes.Buffer 40 | _, err := buf.ReadFrom(req.Body) 41 | if err != nil { 42 | return nil, err 43 | } 44 | err = req.Body.Close() 45 | if err != nil { 46 | return nil, err 47 | } 48 | req.Body = ioutil.NopCloser(&buf) 49 | } 50 | 51 | setIdempotencyKey(req) 52 | 53 | next := t.parent 54 | if next == nil { 55 | next = http.DefaultTransport 56 | } 57 | 58 | _ = backoff.Retry(func() error { 59 | resp, err = next.RoundTrip(req) 60 | if t.cond(resp, err) { 61 | return errors.New("retry") 62 | } 63 | return nil 64 | }, bo) 65 | 66 | return 67 | } 68 | 69 | func setIdempotencyKey(r *http.Request) { 70 | if r.Header.Get("Idempotency-Key") == "" { 71 | r.Header.Set("Idempotency-Key", uuid.New().String()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /request_handler.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type RequestHandler func(*http.Request) (*http.Request, error) 8 | 9 | func HandleRequest(f func(*http.Request) (*http.Request, error)) Option { 10 | return OptionFunc(func(c *Config) error { 11 | c.RequestHandlers = append(c.RequestHandlers, f) 12 | return nil 13 | }) 14 | } 15 | 16 | // BasicAuth sets an username and a password for basic authentication. 17 | func BasicAuth(username, password string) Option { 18 | return HandleRequest(func(r *http.Request) (*http.Request, error) { 19 | r.SetBasicAuth(username, password) 20 | return r, nil 21 | }) 22 | } 23 | 24 | // Header sets a value to request header. 25 | func Header(k, v string) Option { 26 | return HandleRequest(func(r *http.Request) (*http.Request, error) { 27 | r.Header.Set(k, v) 28 | return r, nil 29 | }) 30 | } 31 | 32 | // Authorization sets an authorization scheme and a token of an user. 33 | func Authorization(scheme, token string) Option { 34 | return Header("Authorization", scheme+" "+token) 35 | } 36 | 37 | func Bearer(token string) Option { 38 | return Authorization("Bearer", token) 39 | } 40 | 41 | func UserAgent(ua string) Option { 42 | return Header("User-Agent", ua) 43 | } 44 | -------------------------------------------------------------------------------- /response_handler.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/izumin5210/hx/hxutil" 9 | ) 10 | 11 | type ResponseHandler func(*http.Response, error) (*http.Response, error) 12 | 13 | func HandleResponse(f func(*http.Response, error) (*http.Response, error)) Option { 14 | return OptionFunc(func(c *Config) error { 15 | c.ResponseHandlers = append(c.ResponseHandlers, f) 16 | return nil 17 | }) 18 | } 19 | 20 | type ResponseError struct { 21 | Response *http.Response 22 | Err error 23 | } 24 | 25 | func (e *ResponseError) Error() string { 26 | msg := fmt.Sprintf("the server responeded with status %d", e.Response.StatusCode) 27 | if e.Err != nil { 28 | msg = fmt.Sprintf("%s: %s", msg, e.Err.Error()) 29 | } 30 | return msg 31 | } 32 | 33 | func (e *ResponseError) Unwrap() error { 34 | if e.Err != nil { 35 | return e.Err 36 | } 37 | return e 38 | } 39 | 40 | func AsJSON(dst interface{}) ResponseHandler { return DefaultJSONConfig.AsJSON(dst) } 41 | 42 | func AsBytesBuffer(dst *bytes.Buffer) ResponseHandler { 43 | return func(r *http.Response, err error) (*http.Response, error) { 44 | if r == nil || err != nil { 45 | return r, err 46 | } 47 | defer r.Body.Close() 48 | _, err = dst.ReadFrom(r.Body) 49 | if err != nil { 50 | return nil, &ResponseError{Response: r, Err: err} 51 | } 52 | return r, nil 53 | } 54 | } 55 | 56 | func AsError() ResponseHandler { 57 | return func(r *http.Response, err error) (*http.Response, error) { 58 | if r == nil || err != nil { 59 | return r, err 60 | } 61 | err = hxutil.DrainResponseBody(r) 62 | if err != nil { 63 | return nil, &ResponseError{Response: r, Err: err} 64 | } 65 | return r, &ResponseError{Response: r} 66 | } 67 | } 68 | 69 | // AsJSONError is ResponseHandler that will populate an error with the JSON returned within the response body. 70 | // And it will wrap the error with ResponseError and return it. 71 | // err := hx.Post(ctx, "https://example.com/posts", 72 | // hx.JSON(body) 73 | // hx.WhenSuccess(hx.AsJSON(&post), http.StatusBadRequest), 74 | // hx.WhenStatus(hx.AsErrorOf(&InvalidArgument{}), http.StatusBadRequest), 75 | // hx.WhenFailure(hx.AsError()), 76 | // ) 77 | // if err != nil { 78 | // var ( 79 | // invalidArgErr *InvalidArgument 80 | // respErr *hx.ResponseError 81 | // ) 82 | // if errors.As(err, &invalidArgErr) { 83 | // // handle known error 84 | // } else if errors.As(err, &respErr) { 85 | // // handle unknown response error 86 | // } else { 87 | // err := errors.Unwrap(err) 88 | // // handle unknown error 89 | // } 90 | // } 91 | func AsJSONError(dst error) ResponseHandler { return DefaultJSONConfig.AsJSONError(dst) } 92 | 93 | func checkStatus(f func(int) bool) func(*http.Response, error) bool { 94 | return func(r *http.Response, err error) bool { 95 | return err == nil && r != nil && f(r.StatusCode) 96 | } 97 | } 98 | 99 | type ResponseHandlerCond func(*http.Response, error) bool 100 | 101 | func Any(conds ...ResponseHandlerCond) ResponseHandlerCond { 102 | return func(r *http.Response, err error) bool { 103 | for _, c := range conds { 104 | if c(r, err) { 105 | return true 106 | } 107 | } 108 | return false 109 | } 110 | } 111 | 112 | func Not(cond ResponseHandlerCond) ResponseHandlerCond { 113 | return func(r *http.Response, err error) bool { return !cond(r, err) } 114 | } 115 | 116 | var ( 117 | IsSuccess ResponseHandlerCond = checkStatus(func(c int) bool { return c/100 == 2 }) 118 | IsFailure ResponseHandlerCond = Not(IsSuccess) 119 | IsClientError ResponseHandlerCond = checkStatus(func(c int) bool { return c/100 == 4 }) 120 | IsServerError ResponseHandlerCond = checkStatus(func(c int) bool { return c/100 == 5 }) 121 | IsTemporaryError ResponseHandlerCond = func(r *http.Response, err error) bool { 122 | terr, ok := err.(interface{ Temporary() bool }) 123 | return ok && terr.Temporary() 124 | } 125 | ) 126 | 127 | func IsStatus(codes ...int) ResponseHandlerCond { 128 | m := make(map[int]struct{}, len(codes)) 129 | for _, c := range codes { 130 | m[c] = struct{}{} 131 | } 132 | return checkStatus(func(code int) bool { _, ok := m[code]; return ok }) 133 | } 134 | 135 | func When(cond ResponseHandlerCond, rh ResponseHandler) Option { 136 | return HandleResponse(func(resp *http.Response, err error) (*http.Response, error) { 137 | if cond(resp, err) { 138 | return rh(resp, err) 139 | } 140 | return resp, err 141 | }) 142 | } 143 | 144 | func WhenSuccess(h ResponseHandler) Option { return When(IsSuccess, h) } 145 | func WhenFailure(h ResponseHandler) Option { return When(IsFailure, h) } 146 | func WhenClientError(h ResponseHandler) Option { return When(IsClientError, h) } 147 | func WhenServerError(h ResponseHandler) Option { return When(IsServerError, h) } 148 | func WhenStatus(h ResponseHandler, codes ...int) Option { return When(IsStatus(codes...), h) } 149 | -------------------------------------------------------------------------------- /response_handler_test.go: -------------------------------------------------------------------------------- 1 | package hx_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "strconv" 10 | "testing" 11 | "time" 12 | 13 | "github.com/izumin5210/hx" 14 | ) 15 | 16 | func TestResponseHandlerCond(t *testing.T) { 17 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | switch { 19 | case r.Method == http.MethodGet && r.URL.Path == "/ping": 20 | status, _ := strconv.Atoi(r.URL.Query().Get("status")) 21 | if status == 0 { 22 | w.WriteHeader(http.StatusBadRequest) 23 | return 24 | } 25 | w.WriteHeader(status) 26 | w.Write([]byte("pong")) 27 | default: 28 | w.WriteHeader(http.StatusNotFound) 29 | } 30 | })) 31 | defer ts.Close() 32 | 33 | l, err := net.Listen("tcp", ":0") 34 | if err != nil { 35 | t.Fatalf("failed to get free port: %v", err) 36 | } 37 | defer l.Close() 38 | freePort := l.Addr().(*net.TCPAddr).Port 39 | 40 | ctx := context.Background() 41 | 42 | t.Run("client error", func(t *testing.T) { 43 | for _, st := range []int{400, 401, 498, 499} { 44 | t.Run(fmt.Sprint(st), func(t *testing.T) { 45 | err := hx.Get(ctx, ts.URL+"/ping", 46 | hx.Query("status", fmt.Sprint(st)), 47 | hx.WhenClientError(hx.AsError()), 48 | ) 49 | if err == nil { 50 | t.Error("returned nil, want an error") 51 | } 52 | }) 53 | } 54 | for _, st := range []int{398, 399, 500, 501} { 55 | t.Run(fmt.Sprint(st), func(t *testing.T) { 56 | err := hx.Get(ctx, ts.URL+"/ping", 57 | hx.Query("status", fmt.Sprint(st)), 58 | hx.WhenClientError(hx.AsError()), 59 | ) 60 | if err != nil { 61 | t.Errorf("returned %v, want nil", err) 62 | } 63 | }) 64 | } 65 | }) 66 | 67 | t.Run("server error", func(t *testing.T) { 68 | for _, st := range []int{500, 501, 598, 599} { 69 | t.Run(fmt.Sprint(st), func(t *testing.T) { 70 | err := hx.Get(ctx, ts.URL+"/ping", 71 | hx.Query("status", fmt.Sprint(st)), 72 | hx.WhenServerError(hx.AsError()), 73 | ) 74 | if err == nil { 75 | t.Error("returned nil, want an error") 76 | } 77 | }) 78 | } 79 | for _, st := range []int{498, 499, 600, 601} { 80 | t.Run(fmt.Sprint(st), func(t *testing.T) { 81 | err := hx.Get(ctx, ts.URL+"/ping", 82 | hx.Query("status", fmt.Sprint(st)), 83 | hx.WhenServerError(hx.AsError()), 84 | ) 85 | if err != nil { 86 | t.Errorf("returned %v, want nil", err) 87 | } 88 | }) 89 | } 90 | }) 91 | 92 | t.Run("temporary error", func(t *testing.T) { 93 | for _, st := range []int{400, 500} { 94 | t.Run(fmt.Sprint(st), func(t *testing.T) { 95 | var handled bool 96 | err := hx.Get(ctx, ts.URL+"/ping", 97 | hx.Timeout(10*time.Millisecond), 98 | hx.Query("status", fmt.Sprint(st)), 99 | hx.When(hx.IsTemporaryError, func(r *http.Response, e error) (*http.Response, error) { 100 | handled = true 101 | return r, e 102 | }), 103 | ) 104 | if err != nil { 105 | t.Errorf("returned %v, want nil", err) 106 | } 107 | if handled { 108 | t.Errorf("should not handle error as temporary: %v", err) 109 | } 110 | }) 111 | } 112 | t.Run("when server stopped", func(t *testing.T) { 113 | var handled bool 114 | err := hx.Get(ctx, fmt.Sprintf("http://localhost:%d/ping", freePort), 115 | hx.Timeout(10*time.Millisecond), 116 | hx.When(hx.IsTemporaryError, func(r *http.Response, e error) (*http.Response, error) { 117 | handled = true 118 | return r, e 119 | }), 120 | ) 121 | if err == nil { 122 | t.Error("returned nil, want an error") 123 | } 124 | if !handled { 125 | t.Errorf("should handle error as temporary: %v", err) 126 | } 127 | }) 128 | }) 129 | 130 | t.Run("Any", func(t *testing.T) { 131 | cond := hx.Any(hx.IsServerError, hx.IsTemporaryError) 132 | t.Run(fmt.Sprint(500), func(t *testing.T) { 133 | err := hx.Get(ctx, ts.URL+"/ping", 134 | hx.Timeout(10*time.Millisecond), 135 | hx.Query("status", fmt.Sprint(500)), 136 | hx.When(cond, hx.AsError()), 137 | ) 138 | if err == nil { 139 | t.Error("returned nil, want an error") 140 | } 141 | }) 142 | t.Run(fmt.Sprint(400), func(t *testing.T) { 143 | err := hx.Get(ctx, ts.URL+"/ping", 144 | hx.Timeout(10*time.Millisecond), 145 | hx.Query("status", fmt.Sprint(400)), 146 | hx.When(cond, hx.AsError()), 147 | ) 148 | if err != nil { 149 | t.Errorf("returned %v, want nil", err) 150 | } 151 | }) 152 | t.Run("when server stopped", func(t *testing.T) { 153 | err := hx.Get(ctx, fmt.Sprintf("http://localhost:%d/ping", freePort), 154 | hx.Timeout(10*time.Millisecond), 155 | hx.When(cond, hx.AsError()), 156 | ) 157 | if err == nil { 158 | t.Error("returned nil, want an error") 159 | } 160 | }) 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package hx 2 | 3 | const Version = "0.3.0" 4 | --------------------------------------------------------------------------------