forked from globalsign/hvclient
-
Notifications
You must be signed in to change notification settings - Fork 0
/
client.go
289 lines (248 loc) · 8.99 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
/*
Copyright (c) 2019-2021 GMO GlobalSign Pte. Ltd.
Licensed under the MIT License (the "License"); you may not use this file except
in compliance with the License. You may obtain a copy of the License at
https://opensource.org/licenses/MIT
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package hvclient
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/globalsign/hvclient/internal/httputils"
)
// Client is a fully-featured client through which HVCA API calls can be made.
//
// A client is created from either a configuration object or a configuration
// file containing the account and other information. Once a client is
// created, it can then be used to make HVCA API calls.
//
// The user does not need to explicitly login. The client object will log the
// user in automatically, and refresh their login if the authentication token
// expires. In the event of a HTTP 503 service unavailable response, or a
// response indicating that a request has been accepted but the corresponding
// resource is not yet available, the client will automatically wait and retry
// the call a predetermined number of times. The maximum wait time for this
// process may be controlled through the context passed to each API call.
//
// It is safe to make concurrent API calls from a single client object.
type Client struct {
config *Config
url *url.URL
httpClient *http.Client
token string
lastLogin time.Time
tokenMtx sync.RWMutex
loginMtx sync.Mutex
}
const (
// numberOfRetries is the number of times to retry a request.
numberOfRetries = 5
// Initial time to wait before retrying. Subsequent retries will be more
// widely spaced
retryWaitDuration = time.Second
)
// makeRequest sends an API request to the HVCA server. If out is non-nil,
// the HTTP response body will be unmarshalled into it. In all code paths,
// the response body will be fully consumed and closed before returning.
func (c *Client) makeRequest(
ctx context.Context,
path string,
method string,
in interface{},
out interface{},
) (*http.Response, error) {
var retriesRemaining = numberOfRetries
var response *http.Response
// Loop so we can retry requests if necessary.
for {
var body io.Reader
if in != nil {
var data, err = json.Marshal(in)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
body = bytes.NewReader(data)
}
var request, err = http.NewRequestWithContext(ctx, method, c.url.String()+path, body)
if err != nil {
return nil, fmt.Errorf("failed to create new HTTP request: %w", err)
}
// Add a content type header when we have a request body. Note that
// HVCA specifically requires a UTF-8 charset parameter with the
// media type.
if in != nil {
request.Header.Set(httputils.ContentTypeHeader, httputils.ContentTypeJSONUTF8)
}
// Add any extra headers to the request first, so they can't override
// any headers we add ourselves.
for key, value := range c.config.ExtraHeaders {
request.Header.Add(key, value)
}
// Perform specific processing for non-login requests.
if !strings.HasPrefix(path, endpointLogin) {
// Since this is not a login request, preemptively login again if
// the stored authentication token is believed to be expired.
err = c.loginIfTokenHasExpired(ctx)
if err != nil {
return nil, err
}
// Add the authentication token to all requests except login requests.
request.Header.Set(httputils.AuthorizationHeader, "Bearer "+c.tokenRead())
}
// Execute the request.
if response, err = c.httpClient.Do(request); err != nil {
return nil, fmt.Errorf("failed to execute HTTP request: %w", err)
}
defer httputils.ConsumeAndCloseResponseBody(response)
// HVCA doesn't return any 3XX HTTP status codes, so treat everything outside
// of the 2XX range as an error. Also treat 202 status codes as "errors",
// because we want to retry in that event.
if response.StatusCode < 200 || response.StatusCode > 299 || response.StatusCode == http.StatusAccepted {
var apiErr = newAPIError(response)
// Depending on the status code, we may want to retry the request.
switch apiErr.StatusCode {
case http.StatusUnauthorized:
// If we get an unauthorized status from a login request
// then we just have bad login credentials. This is a
// fatal error, so just stop and return it.
if strings.HasPrefix(path, endpointLogin) {
return nil, apiErr
}
// Otherwise, the token may have expired, so attempt to login
// again, and retry the original request on success. Note that
// this should be unusual, since we checked whether the token
// had expired before executing this request. However, since
// HVCA doesn't return information about the actual lifetime
// of the token, we're having to assume that the currently
// documented token lifetime will remain the same. If the
// lifetime ever is shortened, this will act as a safeguard
// and prevent otherwise fatal failures that a reactive
// re-login could easily resolve.
var err = c.login(ctx)
if err != nil {
return nil, err
}
case http.StatusServiceUnavailable, http.StatusAccepted:
// Return the error if we're out of retries.
if retriesRemaining <= 0 {
return nil, apiErr
}
// Otherwise we want to retry, so decrement the number of
// remaining retries and pause for a progressively increasing
// period of time.
retriesRemaining--
time.Sleep(retryWaitDuration * time.Duration((numberOfRetries - retriesRemaining)))
default:
// Return the error on any other status code.
return nil, apiErr
}
// Continue around the loop to retry the request.
continue
}
// No errors, so break from the loop.
break
}
// Return early if we're not expecting a response body.
if out == nil {
return response, nil
}
// All response bodies from successful HVCA requests have a JSON content
// type, so verify that's what we have before reading the body.
var err = httputils.VerifyResponseContentType(response, httputils.ContentTypeJSON)
if err != nil {
return nil, err
}
// Read and unmarshal the response body.
var data []byte
data, err = ioutil.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to read HTTP response body: %w", err)
}
err = json.Unmarshal(data, out)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal HTTP response body: %w", err)
}
return response, nil
}
// DefaultTimeout returns the timeout specified in the configuration object or
// file used to create the client, or the default timeout provided if no value
// was specified. This is useful for honoring the timeout requested by the
// configuration when creating the context to pass to an API method if the
// original configuration information is no longer available.
func (c *Client) DefaultTimeout() time.Duration {
return c.config.Timeout
}
// NewClient creates a new HVCA client from a configuration object. An initial
// login is made, and the returned client is immediately ready to make API
// calls.
func NewClient(ctx context.Context, conf *Config) (*Client, error) {
// Validate configuration object before continuing.
var err = conf.Validate()
if err != nil {
return nil, err
}
// Build an HTTP transport using any proxy settings from the environment.
// Experimentation suggests that the other values seem to reasonably
// maximally encourage the sharing of TCP connections.
var tnspt = &http.Transport{
MaxIdleConnsPerHost: 1024,
MaxIdleConns: 1024,
MaxConnsPerHost: 1024,
Proxy: http.ProxyFromEnvironment,
}
if conf.url.Scheme == "https" {
// Populate TLS client certificates only if one was provided.
var tlsCerts []tls.Certificate
if conf.TLSCert != nil {
tlsCerts = []tls.Certificate{
tls.Certificate{
Certificate: [][]byte{conf.TLSCert.Raw},
PrivateKey: conf.TLSKey,
Leaf: conf.TLSCert,
},
}
}
tnspt.TLSClientConfig = &tls.Config{
RootCAs: conf.TLSRoots,
Certificates: tlsCerts,
InsecureSkipVerify: conf.InsecureSkipVerify,
}
}
// Build a new client.
var newClient = Client{
config: conf,
url: conf.url,
httpClient: &http.Client{Transport: tnspt},
}
// Perform the initial login and return the new client.
err = newClient.login(ctx)
if err != nil {
return nil, err
}
return &newClient, nil
}
// NewClientFromFile returns a new HVCA client from a configuration file. An
// initial login is made, and the returned client is immediately ready to make
// API calls.
func NewClientFromFile(ctx context.Context, filename string) (*Client, error) {
var conf, err = NewConfigFromFile(filename)
if err != nil {
return nil, err
}
return NewClient(ctx, conf)
}