-
Notifications
You must be signed in to change notification settings - Fork 7
/
client.go
296 lines (250 loc) · 7.16 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
package knock
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"github.com/hashicorp/go-cleanhttp"
)
const (
DefaultBaseUrl = "https://api.knock.app/"
jsonMediaType = "application/json"
)
// ErrorCode defines the code of an error.
type ErrorCode string
const (
ErrInternal ErrorCode = "internal" // Internal error.
ErrInvalid ErrorCode = "invalid" // Invalid operation, e.g wrong params
ErrPermission ErrorCode = "permission" // Permission denied.
ErrNotFound ErrorCode = "not_found" // Resource not found.
ErrRetry ErrorCode = "retry" // Operation should be retried.
ErrResponseMalformed ErrorCode = "response_malformed" // Response body is malformed.
)
// Client encapsulates a client that talks to the Knock API
type Client struct {
// client represents the HTTP client used for making HTTP requests.
client *http.Client
// base URL for the API
baseURL *url.URL
BulkOperations BulkOperationsService
Messages MessagesService
Objects ObjectsService
Tenants TenantsService
Users UsersService
Workflows WorkflowsService
}
// ClientOption provides a variadic option for configuring the client
type ClientOption func(c *Client) error
// WithBaseURL overrides the base URL for the API.
func WithBaseURL(baseURL string) ClientOption {
return func(c *Client) error {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return err
}
c.baseURL = parsedURL
return nil
}
}
// WithAccessToken configures a client with the given Knock access token.
func WithAccessToken(token string) ClientOption {
return func(c *Client) error {
if token == "" {
return errors.New("missing access token")
}
transport := accessTokenTransport{
rt: c.client.Transport,
token: token,
}
c.client.Transport = &transport
return nil
}
}
// WithHTTPClient configures the Knock client with the given HTTP client.
func WithHTTPClient(client *http.Client) ClientOption {
return func(c *Client) error {
if client == nil {
client = cleanhttp.DefaultClient()
}
c.client = client
return nil
}
}
// NewClient instantiates an instance of the Knock API client.
func NewClient(opts ...ClientOption) (*Client, error) {
baseURL, err := url.Parse(DefaultBaseUrl)
if err != nil {
return nil, err
}
c := &Client{
client: cleanhttp.DefaultClient(),
baseURL: baseURL,
}
for _, opt := range opts {
err := opt(c)
if err != nil {
return nil, err
}
}
c.BulkOperations = &bulkOperationsService{client: c}
c.Messages = &messagesService{client: c}
c.Objects = &objectsService{client: c}
c.Tenants = &tenantsService{client: c}
c.Users = &usersService{client: c}
c.Workflows = &workflowsService{client: c}
return c, nil
}
// do makes an HTTP request and populates the given struct v from the response.
func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) ([]byte, error) {
req = req.WithContext(ctx)
res, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
return c.handleResponse(ctx, res, v)
}
// handleResponse makes an HTTP request and populates the given struct v from
// the response. This is meant for internal testing and shouldn't be used
// directly. Instead please use `Client.do`.
func (c *Client) handleResponse(ctx context.Context, res *http.Response, v interface{}) ([]byte, error) {
out, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode >= 400 {
// errorResponse represents an error response from the API
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
errorRes := &errorResponse{}
err = json.Unmarshal(out, errorRes)
if err != nil {
var jsonErr *json.SyntaxError
if errors.As(err, &jsonErr) {
return nil, &Error{
msg: "malformed error response body received",
Code: ErrResponseMalformed,
Meta: map[string]string{
"body": string(out),
"err": jsonErr.Error(),
"http_status": http.StatusText(res.StatusCode),
},
}
}
return nil, err
}
// json.Unmarshal doesn't return an error if the response
// body has a different protocol then "ErrorResponse". We
// check here to make sure that errorRes is populated. If
// not, we return the full response back to the user, so
// they can debug the issue.
if *errorRes == (errorResponse{}) {
return nil, &Error{
msg: "internal error, response body doesn't match error type signature",
Code: ErrInternal,
Meta: map[string]string{
"body": string(out),
"http_status": http.StatusText(res.StatusCode),
},
}
}
var errCode ErrorCode
switch errorRes.Code {
case "not_found", "resource_missing":
errCode = ErrNotFound
case "unauthorized":
errCode = ErrPermission
case "invalid_params":
errCode = ErrInvalid
case "unprocessable":
errCode = ErrRetry
}
return nil, &Error{
msg: errorRes.Message,
Code: errCode,
}
}
// this means we don't care about un-marshalling the response body into v
if v == nil {
return out, nil
}
err = json.Unmarshal(out, &v)
if err != nil {
var jsonErr *json.SyntaxError
if errors.As(err, &jsonErr) {
return nil, &Error{
msg: "malformed response body received",
Code: ErrResponseMalformed,
Meta: map[string]string{
"body": string(out),
"http_status": http.StatusText(res.StatusCode),
},
}
}
return nil, err
}
return out, nil
}
func (c *Client) newRequest(method string, path string, body interface{}, methodOptions *MethodOptions) (*http.Request, error) {
u, err := c.baseURL.Parse(path)
if err != nil {
return nil, err
}
var req *http.Request
switch method {
case http.MethodGet:
req, err = http.NewRequest(method, u.String(), nil)
if err != nil {
return nil, err
}
default:
buf := new(bytes.Buffer)
if body != nil {
err = json.NewEncoder(buf).Encode(body)
if err != nil {
return nil, err
}
}
req, err = http.NewRequest(method, u.String(), buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", jsonMediaType)
}
req.Header.Set("Accept", jsonMediaType)
if methodOptions != nil {
if methodOptions.IdempotencyKey != "" {
req.Header.Set("Idempotency-Key", methodOptions.IdempotencyKey)
}
}
return req, nil
}
type accessTokenTransport struct {
rt http.RoundTripper
token string
}
func (t *accessTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("Authorization", "Bearer "+t.token)
return t.rt.RoundTrip(req)
}
// Error represents common errors originating from the Client.
type Error struct {
// msg contains the human readable string
msg string
// Code specifies the error code. i.e; NotFound, RateLimited, etc...
Code ErrorCode
// Meta contains additional information depending on the error code. As an
// example, if the Code is "ErrResponseMalformed", the map will be: ["body"]
// = "body of the response"
Meta map[string]string
}
// Error returns the string representation of the error.
func (e *Error) Error() string { return e.msg }
type MethodOptions struct {
IdempotencyKey string
}