/
executor.go
179 lines (156 loc) · 4.98 KB
/
executor.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
package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/cloudfoundry-community/go-cfclient/v3/internal/path"
"golang.org/x/oauth2"
"io"
"net/http"
)
var errNilContext = errors.New("context cannot be nil")
// supportedHTTPMethods are the HTTP verbs this executor supports
var supportedHTTPMethods = []string{
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodDelete,
http.MethodPatch,
}
type unauthorizedError struct {
Err error
}
func (e unauthorizedError) Error() string {
return fmt.Sprintf("unable to get new access token: %s", e.Err)
}
// Executor handles executing HTTP requests
type Executor struct {
userAgent string
apiAddress string
clientProvider ClientProvider
}
// NewExecutor creates a new HTTP Executor instance
func NewExecutor(clientProvider ClientProvider, apiAddress, userAgent string) *Executor {
return &Executor{
userAgent: userAgent,
apiAddress: apiAddress,
clientProvider: clientProvider,
}
}
// ExecuteRequest executes the specified request using the http.Client provided by the client provider
func (c *Executor) ExecuteRequest(request *Request) (*http.Response, error) {
followRedirects := request.followRedirects
req, err := c.newHTTPRequest(request)
if err != nil {
return nil, err
}
// do the request to the remote API
r, err := c.do(req, followRedirects)
// it's possible the access token expired and the oauth subsystem could not obtain a new one because the
// refresh token is expired or revoked. Attempt to get a new refresh and access token and retry the request.
var authErr *unauthorizedError
if errors.As(err, &authErr) {
err = c.reAuthenticate(req.Context())
if err != nil {
return nil, err
}
r, err = c.do(req, followRedirects)
}
return r, err
}
// newHTTPRequest creates a new *http.Request instance from the internal model
func (c *Executor) newHTTPRequest(request *Request) (*http.Request, error) {
if request.context == nil {
return nil, errNilContext
}
if !isSupportedHTTPMethod(request.method) {
return nil, fmt.Errorf("error executing request, found unsupported HTTP method %s", request.method)
}
// JSON encode the object and use that as the body if specified, otherwise use the body as-is
reqBody := request.body
if request.object != nil {
b, err := encodeBody(request.object)
if err != nil {
return nil, fmt.Errorf("error executing request, failed to encode the request object to JSON: %w", err)
}
reqBody = b
}
u := path.Join(c.apiAddress, request.pathAndQuery)
r, err := http.NewRequestWithContext(request.context, request.method, u, reqBody)
if err != nil {
return nil, fmt.Errorf("error executing request, failed to create a new underlying HTTP request: %w", err)
}
r.Header.Set("User-Agent", c.userAgent)
if request.contentType != "" {
r.Header.Set("Content-type", request.contentType)
}
if request.contentLength != nil {
r.ContentLength = *request.contentLength
}
for k, v := range request.headers {
r.Header.Set(k, v)
}
return r, nil
}
// do will get the proper http.Client and calls Do on it using the specified http.Request
func (c *Executor) do(request *http.Request, followRedirects bool) (*http.Response, error) {
client, err := c.clientProvider.Client(request.Context(), followRedirects)
if err != nil {
return nil, fmt.Errorf("error executing request, failed to get the underlying HTTP client: %w", err)
}
r, err := client.Do(request)
if err != nil {
// if we get an error because the context was cancelled, the context's error is more useful.
ctx := request.Context()
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// see if the oauth subsystem was unable to use the refresh token to get a new access token
var oauthErr *oauth2.RetrieveError
if errors.As(err, &oauthErr) {
if oauthErr.Response.StatusCode == http.StatusUnauthorized {
return nil, &unauthorizedError{
Err: err,
}
}
}
return nil, fmt.Errorf("error executing request, failed during HTTP request send: %w", err)
}
// perhaps the token looked valid, but was revoked etc
if r.StatusCode == http.StatusUnauthorized {
_ = r.Body.Close()
return nil, &unauthorizedError{}
}
return r, nil
}
// reAuthenticate tells the client provider to restart authentication anew because we received a 401
func (c *Executor) reAuthenticate(ctx context.Context) error {
err := c.clientProvider.ReAuthenticate(ctx)
if err != nil {
return fmt.Errorf("an error occurred attempting to reauthenticate "+
"after initially receiving a 401 executing a request: %w", err)
}
return nil
}
// encodeBody is used to encode a request body
func encodeBody(obj any) (io.Reader, error) {
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
if err := enc.Encode(obj); err != nil {
return nil, err
}
return buf, nil
}
// isSupportedHTTPMethod returns true if the executor supports this HTTP method
func isSupportedHTTPMethod(method string) bool {
for _, v := range supportedHTTPMethods {
if v == method {
return true
}
}
return false
}