-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathclient.go
252 lines (216 loc) · 7.2 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
package clccam
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httputil"
"reflect"
"regexp"
"strings"
"github.com/grrtrr/clccam/logger"
"github.com/pkg/errors"
)
// Client is a reusable REST client for CAM API calls.
type Client struct {
// client performs the actual requests.
client *http.Client
// Base URL to use.
baseURL string
// Per-request options.
requestOptions []RequestOption
// Cancellation context (used by @cancel). Can be overridden via WithContext()
ctx context.Context
// token makes the authentication token accessible to the client
token Token
// Print request / response to stderr.
requestDebug bool
// Print JSON response to stdout.
jsonResponse bool
}
// NewClient returns a new standalone client.
func NewClient(options ...ClientOption) *Client {
var c = &Client{
baseURL: "https://cam.ctl.io",
client: &http.Client{
// Make default explicit, needed/used by InsecureTLS() and Retryer()
Transport: http.DefaultTransport,
},
}
return c.With(options...)
}
// With enables @options on @c.
func (c *Client) With(options ...ClientOption) *Client {
for _, setOption := range options {
setOption(c)
}
return c
}
// WithDebug enables debugging on @c.
func (c *Client) WithDebug() *Client {
return c.With(Debug(true))
}
// WithContext sets the context to @ctx.
func (c *Client) WithContext(ctx context.Context) *Client {
return c.With(Context(ctx))
}
// WithJsonResponse enables printing the JSON response to stdout.
func (c *Client) WithJsonResponse() *Client {
return c.With(JsonResponse(true))
}
// GetTokenSubject returns the subject of the client's token if set.
func (c *Client) GetTokenSubject() (user string, err error) {
if c.token == "" {
return "", errors.Errorf("token not set")
} else if c, err := c.token.Claims(); err != nil {
return "", errors.Wrapf(err, "failed to parse token")
} else {
return c.Subject, nil
}
}
// Get performs a GET /path, with output into @resModel
func (c *Client) Get(path string, resModel interface{}) error {
return c.getResponse(path, "GET", nil, resModel)
}
// getResponse performs a generic request
// @urlPath: request path relative to %BaseURL
// @verb: request verb
// @reqModel: request model to serialize, or nil. This can be one of two things:
// (a) []byte slice - will be transmitted without further encoding,
// attempting to infer the Content Type from the contents of the buffer;
// (b) anything else - will be JSON encoded, with corresponding content-type.
// @resModel: result model to deserialize, must be a pointer to the expected result, or nil.
// @opts: per-request options (will override any static RequestOptions that @c has).
// Evaluates the StatusCode of the BaseResponse (embedded) in @inModel and sets @err accordingly.
// If @err == nil, fills in @resModel, else returns error.
func (c *Client) getResponse(urlPath, verb string, reqModel, resModel interface{}, opts ...RequestOption) error {
var (
url = fmt.Sprintf("%s/%s", c.baseURL, strings.TrimLeft(urlPath, "/"))
contentType string // Request content type
reqBody io.Reader
)
if reqModel != nil {
var body []byte
if b, ok := reqModel.([]byte); ok {
body = b
contentType = http.DetectContentType(b)
} else if jsonReq, err := json.Marshal(reqModel); err != nil {
return errors.Wrapf(err, "failed to encode request model %T %+v", reqModel, reqModel)
} else {
body = jsonReq
contentType = "application/json; charset=utf-8"
}
opts = append(opts, Headers(map[string]string{
"Content-Type": contentType,
"Content-Length": fmt.Sprint(len(body)),
}))
reqBody = bytes.NewBuffer(body)
}
// resModel must be a pointer type (call-by-value)
if resModel != nil {
if resType := reflect.TypeOf(resModel); resType.Kind() != reflect.Ptr {
return errors.Errorf("expecting pointer to result model %T", resModel)
}
}
req, err := http.NewRequest(verb, url, reqBody)
if err != nil {
return err
}
if c.ctx != nil {
req = req.WithContext(c.ctx)
}
// Options: set static client options first, so that @opts can override them if necessary.
for _, setOption := range append(c.requestOptions, opts...) {
setOption(req)
}
// This function expects/accepts a JSON response.
req.Header.Set("Accept", "application/json")
if c.requestDebug {
var reqDump []byte
switch {
case strings.HasPrefix(contentType, "image"):
reqDump, _ = httputil.DumpRequest(req, false)
default:
reqDump, _ = httputil.DumpRequest(req, true)
}
logger.Debugf("%s", reqDump)
}
res, err := c.client.Do(req)
if err != nil {
return err
}
if c.requestDebug {
resDump, _ := httputil.DumpResponse(res, true)
logger.Debugf("%s", resDump)
}
body, err := ioutil.ReadAll(res.Body)
if err != nil && res.ContentLength > 0 {
res.Body.Close()
return errors.Wrapf(err, "failed to read error response %d body", res.StatusCode)
} else if err := res.Body.Close(); err != nil {
return errors.Wrapf(err, "failed to close after reading response body")
}
switch res.StatusCode {
case 200, 201, 202, 204: // OK | CREATED | ACCEPTED | NO CONTENT
if c.requestDebug && len(body) > 0 && !strings.Contains(http.DetectContentType(body), "html") {
logger.Debugf("%s", string(body))
}
if c.jsonResponse && len(body) > 0 {
var b bytes.Buffer
if err := json.Indent(&b, body, "", "\t"); err != nil {
return errors.Wrapf(err, "failed to decode JSON response %q", string(body))
}
fmt.Println(b.String())
}
if resModel != nil {
switch val := resModel.(type) {
case *string:
*val = string(body)
case *[]string:
*val = strings.Split(string(body), "\n")
default:
if res.ContentLength == 0 {
return errors.Errorf("unable do populate %T result model, due to empty %q response",
resModel, res.Status)
}
return json.Unmarshal(body, resModel)
}
return nil
} else if res.ContentLength > 0 {
return errors.Errorf("unable to decode non-empty %q response (%d bytes) to nil response model",
res.Status, res.ContentLength)
}
return nil
default: // Errors and temporary failures
if len(body) > 0 && !strings.Contains(http.DetectContentType(body), "html") {
// Decode possible CAM error response:
// 1) text/html: HTML page - skip as per above check
// 2) text/plain: use body after stripping whitespace
// 3) bare JSON string
// 4) struct { message: "string" }
var payload map[string]interface{}
var errMsg = string(bytes.TrimSpace(body))
if err := json.Unmarshal(body, &payload); err != nil {
// Failed to decode as struct, try string (2,3)
if err = json.Unmarshal(body, &errMsg); err != nil {
var nl = regexp.MustCompile(`(\r?\n)+`)
errMsg = nl.ReplaceAllString(string(bytes.TrimSpace(body)), "; ")
}
} else if errors, ok := payload["message"]; ok {
if msg, ok := errors.(string); ok {
errMsg = strings.TrimRight(msg, " .") // sometimes they end error messages in '.'
}
} else if error, ok := payload["error"]; ok {
if msg, ok := error.(string); ok {
errMsg = fmt.Sprintf("Error - %s", msg)
}
}
return errors.Errorf("%s (status: %d)", errMsg, res.StatusCode)
}
// FIXME: implement temporary / retryable errors (300)
return errors.New(res.Status)
}
}