-
Notifications
You must be signed in to change notification settings - Fork 0
/
client.go
373 lines (305 loc) · 10.8 KB
/
client.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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
package gojsonclient
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/blizzy78/gobackoff"
"github.com/go-json-experiment/json"
)
// Client is a client for JSON/REST HTTP services.
type Client struct {
logger *slog.Logger
httpClient *http.Client
baseURI string
requestMiddlewares []RequestMiddlewareFunc
requestTimeout time.Duration
maxAttempts int
retryFunc RetryFunc
backoff *gobackoff.Backoff
}
// ClientOpt is a function that configures a Client.
type ClientOpt func(client *Client)
// RequestMiddlewareFunc is a function that modifies an HTTP request.
type RequestMiddlewareFunc func(req *http.Request) error
// RetryFunc is a function that decides whether to retry an HTTP request.
// Depending on the outcome of the previous attempt, httpRes and/or err may be nil.
// A new attempt is made if the function returns a nil error.
type RetryFunc func(ctx context.Context, httpRes *http.Response, err error) error
// Request represents a JSON/REST HTTP request.
type Request[Req any, Res any] struct {
uri string
method string
req Req
ignoreResponseBody bool
marshalRequest MarshalJSONFunc[Req]
unmarshalResponse UnmarshalJSONFunc[Res]
}
// RequestOpt is a function that configures a Request.
type RequestOpt[Req any, Res any] func(req *Request[Req, Res])
// MarshalJSONFunc is a function that encodes a value to JSON and outputs it to writer.
type MarshalJSONFunc[T any] func(writer io.Writer, val T) error
// UnmarshalJSONFunc is a function that decodes JSON from httpRes.Body and stores it in val.
type UnmarshalJSONFunc[T any] func(httpRes *http.Response, val *T) error
// Response represents a JSON/REST HTTP response.
type Response[T any] struct {
// Res is the value decoded from the response body.
// Res will be the default value of T if StatusCode==http.StatusNoContent, or if the response body is ignored.
Res T
// StatusCode is the HTTP response status code.
StatusCode int
// Status is the HTTP response status.
Status string
}
type httpError string
var _ error = httpError("")
// New creates a new Client with the given options.
//
// The default options are: slog.Default() as the logger, http.DefaultClient as the HTTP client,
// request timeout of 30s, maximum number of attempts of 5, gobackoff.New() as the backoff,
// and a retry function that returns an error if the HTTP response status code is http.StatusBadRequest.
func New(opts ...ClientOpt) *Client {
client := Client{
logger: slog.Default(),
httpClient: http.DefaultClient,
requestTimeout: 30 * time.Second,
maxAttempts: 5,
backoff: gobackoff.New(),
retryFunc: func(_ context.Context, httpRes *http.Response, _ error) error {
if httpRes != nil && httpRes.StatusCode == http.StatusBadRequest {
return httpError(httpRes.Status)
}
return nil
},
}
for _, opt := range opts {
opt(&client)
}
return &client
}
// WithLogger configures a Client to use logger.
func WithLogger(logger *slog.Logger) ClientOpt {
return func(client *Client) {
client.logger = logger
}
}
// WithHTTPClient configures a Client to use httpClient to make requests.
func WithHTTPClient(httpClient *http.Client) ClientOpt {
return func(client *Client) {
client.httpClient = httpClient
}
}
// WithBaseURI configures a Client to use baseURI as the URI prefix for all requests.
func WithBaseURI(baseURI string) ClientOpt {
return func(client *Client) {
client.baseURI = baseURI
}
}
// WithRequestMiddleware configures a Client to use fun as a request middleware.
// Any number of request middlewares may be added.
func WithRequestMiddleware(fun RequestMiddlewareFunc) ClientOpt {
return func(client *Client) {
client.Use(fun)
}
}
// WithRequestTimeout configures a Client to use timeout for each HTTP request made.
func WithRequestTimeout(timeout time.Duration) ClientOpt {
return func(client *Client) {
client.requestTimeout = timeout
}
}
// WithMaxAttempts configures a Client to make at most max attempts for each request.
func WithMaxAttempts(max int) ClientOpt {
if max < 1 {
panic("max must be >=1")
}
return func(client *Client) {
client.maxAttempts = max
}
}
// WithRetry configures a Client to use retry as the retry function.
func WithRetry(retry RetryFunc) ClientOpt {
if retry == nil {
panic("retry must not be nil")
}
return func(client *Client) {
client.retryFunc = retry
}
}
// WithBackoff configures a Client to use backoff.
func WithBackoff(backoff *gobackoff.Backoff) ClientOpt {
return func(client *Client) {
client.backoff = backoff
}
}
// Use configures c to use fun as a request middleware. Any number of request middlewares may be added.
//
// A Client should usually be configured using WithRequestMiddleware, but it may sometimes be necessary to add new
// middlewares after the Client has been created.
func (c *Client) Use(fun RequestMiddlewareFunc) {
c.requestMiddlewares = append(c.requestMiddlewares, fun)
}
// NewRequest creates a new Request with the given client, URI, method, request data, and options.
func NewRequest[Req any, Res any](uri string, method string, req Req, opts ...RequestOpt[Req, Res]) *Request[Req, Res] {
request := Request[Req, Res]{
uri: uri,
method: method,
req: req,
marshalRequest: func(writer io.Writer, val Req) error {
return json.MarshalWrite(writer, val)
},
unmarshalResponse: func(httpRes *http.Response, val *Res) error {
return json.UnmarshalRead(httpRes.Body, val)
},
}
for _, opt := range opts {
opt(&request)
}
return &request
}
// WithMarshalRequestFunc configures a Request to use fun as the marshal function.
func WithMarshalRequestFunc[Req any, Res any](fun MarshalJSONFunc[Req]) RequestOpt[Req, Res] {
return func(req *Request[Req, Res]) {
req.marshalRequest = fun
}
}
// WithUnmarshalResponseFunc configures a Request to use fun as the unmarshal function.
func WithUnmarshalResponseFunc[Req any, Res any](fun UnmarshalJSONFunc[Res]) RequestOpt[Req, Res] {
return func(req *Request[Req, Res]) {
req.unmarshalResponse = fun
}
}
// WithIgnoreResponseBody configures a Request to ignore the response body, regardless of status code.
// The response body will always be ignored if the status code is http.StatusNoContent.
func WithIgnoreResponseBody[Req any, Res any]() RequestOpt[Req, Res] {
return func(req *Request[Req, Res]) {
req.ignoreResponseBody = true
}
}
// Do executes req with client and returns the response.
//
// If the request data is nil, the request will be made without a body.
// If the response status code is http.StatusNoContent or the response body should be ignored,
// Response.Res will be the default value of Res.
//
// If an HTTP request fails, it is retried using backoff according to the retry function, up to the
// maximum number of attempts.
// If the context is canceled, or if the retry function returns a non-nil error, Do stops and returns
// a gobackoff.AbortError.
//
// Do is safe to call concurrently with the same Request.
func Do[Req any, Res any](ctx context.Context, client *Client, req *Request[Req, Res]) (*Response[Res], error) {
var res *Response[Res]
err := client.backoff.Do(ctx, func(ctx context.Context) error {
var (
httpRes *http.Response
err error
)
res, httpRes, err = do(ctx, client, req) //nolint:bodyclose // body is already closed
if errors.Is(err, context.Canceled) {
return &gobackoff.AbortError{
Err: err,
}
}
if retryErr := client.retryFunc(ctx, httpRes, err); retryErr != nil {
return &gobackoff.AbortError{
Err: retryErr,
}
}
return err
}, client.maxAttempts)
if err != nil {
return nil, err //nolint:wrapcheck // we don't add new info here
}
return res, nil
}
func do[Req any, Res any](ctx context.Context, client *Client, req *Request[Req, Res]) (*Response[Res], *http.Response, error) {
httpReq, err := newHTTPRequest(ctx, client, req)
if err != nil {
return nil, nil, fmt.Errorf("new HTTP request: %w", err)
}
attempt := gobackoff.AttemptFromContext(ctx)
client.logger.InfoContext(ctx, "execute HTTP request",
slog.Group("request",
slog.String("uri", httpReq.URL.String()),
slog.String("method", httpReq.Method),
),
slog.Int("attempt", attempt),
)
ctx, cancel := context.WithTimeout(ctx, client.requestTimeout) //nolint:ineffassign,staticcheck // better be safe than sorry
defer cancel()
httpRes, err := client.httpClient.Do(httpReq)
if err != nil {
return nil, httpRes, fmt.Errorf("execute HTTP request: %w", err)
}
defer httpRes.Body.Close() //nolint:errcheck // we're only reading
res, err := response(httpRes, req)
if err != nil {
return nil, httpRes, fmt.Errorf("get response: %w", err)
}
return res, httpRes, nil
}
func newHTTPRequest[Req any, Res any](ctx context.Context, client *Client, req *Request[Req, Res]) (*http.Request, error) {
var jsonReqData io.Reader = http.NoBody
if any(req.req) != nil {
buf := bytes.Buffer{}
if err := req.marshalRequest(&buf, req.req); err != nil {
return nil, fmt.Errorf("encode request body: %w", err)
}
jsonReqData = &buf
}
httpReq, err := http.NewRequestWithContext(ctx, req.method, client.baseURI+req.uri, jsonReqData)
if err != nil {
return nil, fmt.Errorf("new HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json; charset=UTF-8")
httpReq.Header.Set("Accept", "application/json")
for _, m := range client.requestMiddlewares {
if err = m(httpReq); err != nil {
return nil, fmt.Errorf("request middleware: %w", err)
}
}
return httpReq, nil
}
func response[Req any, Res any](httpRes *http.Response, req *Request[Req, Res]) (*Response[Res], error) {
if httpRes.StatusCode == http.StatusNoContent || req.ignoreResponseBody {
return &Response[Res]{
StatusCode: httpRes.StatusCode,
Status: httpRes.Status,
}, nil
}
var jsonRes Res
if err := req.unmarshalResponse(httpRes, &jsonRes); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &Response[Res]{
Res: jsonRes,
StatusCode: httpRes.StatusCode,
Status: httpRes.Status,
}, nil
}
// BasicAuth returns a request middleware that sets the request's Authorization header to use
// HTTP Basic authentication with the provided username and password.
func BasicAuth(login string, password string) RequestMiddlewareFunc {
return func(req *http.Request) error {
req.SetBasicAuth(login, password)
return nil
}
}
// BearerAuth returns a request middleware that sets the request's Authorization header to use
// HTTP Bearer authentication with the provided token. The token will be inserted verbatim and
// may need to be encoded first.
func BearerAuth(token string) RequestMiddlewareFunc {
return func(req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+token)
return nil
}
}
// Error implements error.
func (e httpError) Error() string {
return string(e)
}