-
Notifications
You must be signed in to change notification settings - Fork 0
/
ezhttp.go
203 lines (166 loc) · 6.27 KB
/
ezhttp.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// This package aims to wrap Go HTTP Client's request-response with sane defaults:
//
// - You are forced to consider timeouts by having to specify Context
// - Instead of not considering non-2xx status codes as a failure, check that by default
// (unless explicitly asked to)
// - Sending and receiving JSON requires much less boilerplate, and on receiving JSON you
// are forced to think whether to "allowUnknownFields"
package ezhttp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
var (
DefaultTimeout10s = 10 * time.Second
NoOpConfig = ConfigPiece{} // sometimes it's beneficial to give an option that does nothing (so user doesn't have to do varargs)
)
type ConfigHook func(conf *Config)
func After(fn ConfigHook) ConfigPiece {
return ConfigPiece{AfterInit: fn}
}
// same as After(), but Config.Request is nil. used mainly for specifying
// request body, which must be known on call to http.NewRequest()
func Before(fn ConfigHook) ConfigPiece {
return ConfigPiece{BeforeInit: fn}
}
type ConfigPiece struct {
BeforeInit ConfigHook
AfterInit ConfigHook
}
type Config struct {
Abort error // ConfigHook can set this to abort request send
Client *http.Client
Request *http.Request
TolerateNon2xxResponse bool
RequestBody io.Reader
OutputsJson bool
OutputsJsonRef interface{}
OutputsJsonAllowUnknownFields bool
}
type ResponseStatusError struct {
error
statusCode int
}
// returns the (non-2xx) status code that caused the error
func (e ResponseStatusError) StatusCode() int {
return e.statusCode
}
// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Get(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return newRequest(ctx, http.MethodGet, url, confPieces...).Send()
}
// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Post(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return newRequest(ctx, http.MethodPost, url, confPieces...).Send()
}
// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Put(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return newRequest(ctx, http.MethodPut, url, confPieces...).Send()
}
// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Head(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return newRequest(ctx, http.MethodHead, url, confPieces...).Send()
}
// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Del(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return newRequest(ctx, http.MethodDelete, url, confPieces...).Send()
}
func newRequest(ctx context.Context, method string, url string, confPieces ...ConfigPiece) *Config {
conf := &Config{
Client: http.DefaultClient,
}
withErr := func(err error) *Config {
conf.Abort = err // will be early-error-returned in `Send()`
return conf
}
for _, configure := range confPieces {
if configure.BeforeInit == nil {
continue
}
configure.BeforeInit(conf)
}
if conf.Abort != nil {
return withErr(conf.Abort)
}
// "Request has body = No" for:
// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET
// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD
if conf.RequestBody != nil && (method == http.MethodGet || method == http.MethodHead) {
// Technically, these can have body, but it's usually a mistake so if we need it we'll
// make it an opt-in flag.
return withErr(fmt.Errorf("ezhttp: %s with non-nil body is usually a mistake", method))
}
req, err := http.NewRequest(
method,
url,
conf.RequestBody)
if err != nil {
return withErr(err)
}
req = req.WithContext(ctx)
conf.Request = req
for _, configure := range confPieces {
if configure.AfterInit == nil {
continue
}
configure.AfterInit(conf)
}
return conf
}
func (conf *Config) Send() (*http.Response, error) {
if conf.Abort != nil {
return nil, conf.Abort
}
resp, err := conf.Client.Do(conf.Request)
if err != nil {
return resp, err // this is a transport-level error
}
// 304 is an error unless caller is expecting such response by sending caching headers
if resp.StatusCode == http.StatusNotModified && conf.Request.Header.Get("If-None-Match") != "" {
return resp, nil
}
// handle application-level errors
if !conf.TolerateNon2xxResponse && (resp.StatusCode < 200 || resp.StatusCode > 299) {
defer resp.Body.Close()
// TODO: if caller wants to process error herself, we need an opt-out for this mechanism
return resp, errorWithResponseBodySample(resp)
}
if conf.OutputsJson {
defer resp.Body.Close()
jsonDecoder := json.NewDecoder(resp.Body)
if !conf.OutputsJsonAllowUnknownFields {
jsonDecoder.DisallowUnknownFields()
}
if err := jsonDecoder.Decode(conf.OutputsJsonRef); err != nil {
return resp, err
}
}
return resp, nil
}
func errorWithResponseBodySample(resp *http.Response) error {
errContentSampleLength := 128
truncatedIndicator := ""
// .Body is documented as always non-nil
errContent, err := io.ReadAll(io.LimitReader(resp.Body, int64(errContentSampleLength)))
if err != nil {
errContent = []byte(fmt.Sprintf("<failed reading response body: %v>", err))
} else if len(errContent) == errContentSampleLength {
truncatedIndicator = ".."
}
if len(errContent) == 0 {
errContent = []byte("<no response body>")
}
return &ResponseStatusError{
statusCode: resp.StatusCode,
error: fmt.Errorf("%s; %s%s", resp.Status, errContent, truncatedIndicator),
}
}