David K. Bainbridge | 528b318 | 2017-01-23 08:51:59 -0800 | [diff] [blame] | 1 | // Copyright 2012-2016 Canonical Ltd. |
| 2 | // Licensed under the LGPLv3, see LICENCE file for details. |
| 3 | |
| 4 | package gomaasapi |
| 5 | |
| 6 | import ( |
| 7 | "bytes" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "io/ioutil" |
| 11 | "mime/multipart" |
| 12 | "net/http" |
| 13 | "net/url" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/juju/errors" |
| 19 | ) |
| 20 | |
| 21 | const ( |
| 22 | // Number of retries performed when the server returns a 503 |
| 23 | // response with a 'Retry-after' header. A request will be issued |
| 24 | // at most NumberOfRetries + 1 times. |
| 25 | NumberOfRetries = 4 |
| 26 | |
| 27 | RetryAfterHeaderName = "Retry-After" |
| 28 | ) |
| 29 | |
| 30 | // Client represents a way to communicating with a MAAS API instance. |
| 31 | // It is stateless, so it can have concurrent requests in progress. |
| 32 | type Client struct { |
| 33 | APIURL *url.URL |
| 34 | Signer OAuthSigner |
| 35 | } |
| 36 | |
| 37 | // ServerError is an http error (or at least, a non-2xx result) received from |
| 38 | // the server. It contains the numerical HTTP status code as well as an error |
| 39 | // string and the response's headers. |
| 40 | type ServerError struct { |
| 41 | error |
| 42 | StatusCode int |
| 43 | Header http.Header |
| 44 | BodyMessage string |
| 45 | } |
| 46 | |
| 47 | // GetServerError returns the ServerError from the cause of the error if it is a |
| 48 | // ServerError, and also returns the bool to indicate if it was a ServerError or |
| 49 | // not. |
| 50 | func GetServerError(err error) (ServerError, bool) { |
| 51 | svrErr, ok := errors.Cause(err).(ServerError) |
| 52 | return svrErr, ok |
| 53 | } |
| 54 | |
| 55 | // readAndClose reads and closes the given ReadCloser. |
| 56 | // |
| 57 | // Trying to read from a nil simply returns nil, no error. |
| 58 | func readAndClose(stream io.ReadCloser) ([]byte, error) { |
| 59 | if stream == nil { |
| 60 | return nil, nil |
| 61 | } |
| 62 | defer stream.Close() |
| 63 | return ioutil.ReadAll(stream) |
| 64 | } |
| 65 | |
| 66 | // dispatchRequest sends a request to the server, and interprets the response. |
| 67 | // Client-side errors will return an empty response and a non-nil error. For |
| 68 | // server-side errors however (i.e. responses with a non 2XX status code), the |
| 69 | // returned error will be ServerError and the returned body will reflect the |
| 70 | // server's response. If the server returns a 503 response with a 'Retry-after' |
| 71 | // header, the request will be transparenty retried. |
| 72 | func (client Client) dispatchRequest(request *http.Request) ([]byte, error) { |
| 73 | // First, store the request's body into a byte[] to be able to restore it |
| 74 | // after each request. |
| 75 | bodyContent, err := readAndClose(request.Body) |
| 76 | if err != nil { |
| 77 | return nil, err |
| 78 | } |
| 79 | for retry := 0; retry < NumberOfRetries; retry++ { |
| 80 | // Restore body before issuing request. |
| 81 | newBody := ioutil.NopCloser(bytes.NewReader(bodyContent)) |
| 82 | request.Body = newBody |
| 83 | body, err := client.dispatchSingleRequest(request) |
| 84 | // If this is a 503 response with a non-void "Retry-After" header: wait |
| 85 | // as instructed and retry the request. |
| 86 | if err != nil { |
| 87 | serverError, ok := errors.Cause(err).(ServerError) |
| 88 | if ok && serverError.StatusCode == http.StatusServiceUnavailable { |
| 89 | retry_time_int, errConv := strconv.Atoi(serverError.Header.Get(RetryAfterHeaderName)) |
| 90 | if errConv == nil { |
| 91 | select { |
| 92 | case <-time.After(time.Duration(retry_time_int) * time.Second): |
| 93 | } |
| 94 | continue |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | return body, err |
| 99 | } |
| 100 | // Restore body before issuing request. |
| 101 | newBody := ioutil.NopCloser(bytes.NewReader(bodyContent)) |
| 102 | request.Body = newBody |
| 103 | return client.dispatchSingleRequest(request) |
| 104 | } |
| 105 | |
| 106 | func (client Client) dispatchSingleRequest(request *http.Request) ([]byte, error) { |
| 107 | client.Signer.OAuthSign(request) |
| 108 | httpClient := http.Client{} |
| 109 | // See https://code.google.com/p/go/issues/detail?id=4677 |
| 110 | // We need to force the connection to close each time so that we don't |
| 111 | // hit the above Go bug. |
| 112 | request.Close = true |
| 113 | response, err := httpClient.Do(request) |
| 114 | if err != nil { |
| 115 | return nil, err |
| 116 | } |
| 117 | body, err := readAndClose(response.Body) |
| 118 | if err != nil { |
| 119 | return nil, err |
| 120 | } |
| 121 | if response.StatusCode < 200 || response.StatusCode > 299 { |
| 122 | err := errors.Errorf("ServerError: %v (%s)", response.Status, body) |
| 123 | return body, errors.Trace(ServerError{error: err, StatusCode: response.StatusCode, Header: response.Header, BodyMessage: string(body)}) |
| 124 | } |
| 125 | return body, nil |
| 126 | } |
| 127 | |
| 128 | // GetURL returns the URL to a given resource on the API, based on its URI. |
| 129 | // The resource URI may be absolute or relative; either way the result is a |
| 130 | // full absolute URL including the network part. |
| 131 | func (client Client) GetURL(uri *url.URL) *url.URL { |
| 132 | return client.APIURL.ResolveReference(uri) |
| 133 | } |
| 134 | |
| 135 | // Get performs an HTTP "GET" to the API. This may be either an API method |
| 136 | // invocation (if you pass its name in "operation") or plain resource |
| 137 | // retrieval (if you leave "operation" blank). |
| 138 | func (client Client) Get(uri *url.URL, operation string, parameters url.Values) ([]byte, error) { |
| 139 | if parameters == nil { |
| 140 | parameters = make(url.Values) |
| 141 | } |
| 142 | opParameter := parameters.Get("op") |
| 143 | if opParameter != "" { |
| 144 | msg := errors.Errorf("reserved parameter 'op' passed (with value '%s')", opParameter) |
| 145 | return nil, msg |
| 146 | } |
| 147 | if operation != "" { |
| 148 | parameters.Set("op", operation) |
| 149 | } |
| 150 | queryUrl := client.GetURL(uri) |
| 151 | queryUrl.RawQuery = parameters.Encode() |
| 152 | request, err := http.NewRequest("GET", queryUrl.String(), nil) |
| 153 | if err != nil { |
| 154 | return nil, err |
| 155 | } |
| 156 | return client.dispatchRequest(request) |
| 157 | } |
| 158 | |
| 159 | // writeMultiPartFiles writes the given files as parts of a multipart message |
| 160 | // using the given writer. |
| 161 | func writeMultiPartFiles(writer *multipart.Writer, files map[string][]byte) error { |
| 162 | for fileName, fileContent := range files { |
| 163 | |
| 164 | fw, err := writer.CreateFormFile(fileName, fileName) |
| 165 | if err != nil { |
| 166 | return err |
| 167 | } |
| 168 | io.Copy(fw, bytes.NewBuffer(fileContent)) |
| 169 | } |
| 170 | return nil |
| 171 | } |
| 172 | |
| 173 | // writeMultiPartParams writes the given parameters as parts of a multipart |
| 174 | // message using the given writer. |
| 175 | func writeMultiPartParams(writer *multipart.Writer, parameters url.Values) error { |
| 176 | for key, values := range parameters { |
| 177 | for _, value := range values { |
| 178 | fw, err := writer.CreateFormField(key) |
| 179 | if err != nil { |
| 180 | return err |
| 181 | } |
| 182 | buffer := bytes.NewBufferString(value) |
| 183 | io.Copy(fw, buffer) |
| 184 | } |
| 185 | } |
| 186 | return nil |
| 187 | |
| 188 | } |
| 189 | |
| 190 | // nonIdempotentRequestFiles implements the common functionality of PUT and |
| 191 | // POST requests (but not GET or DELETE requests) when uploading files is |
| 192 | // needed. |
| 193 | func (client Client) nonIdempotentRequestFiles(method string, uri *url.URL, parameters url.Values, files map[string][]byte) ([]byte, error) { |
| 194 | buf := new(bytes.Buffer) |
| 195 | writer := multipart.NewWriter(buf) |
| 196 | err := writeMultiPartFiles(writer, files) |
| 197 | if err != nil { |
| 198 | return nil, err |
| 199 | } |
| 200 | err = writeMultiPartParams(writer, parameters) |
| 201 | if err != nil { |
| 202 | return nil, err |
| 203 | } |
| 204 | writer.Close() |
| 205 | url := client.GetURL(uri) |
| 206 | request, err := http.NewRequest(method, url.String(), buf) |
| 207 | if err != nil { |
| 208 | return nil, err |
| 209 | } |
| 210 | request.Header.Set("Content-Type", writer.FormDataContentType()) |
| 211 | return client.dispatchRequest(request) |
| 212 | |
| 213 | } |
| 214 | |
| 215 | // nonIdempotentRequest implements the common functionality of PUT and POST |
| 216 | // requests (but not GET or DELETE requests). |
| 217 | func (client Client) nonIdempotentRequest(method string, uri *url.URL, parameters url.Values) ([]byte, error) { |
| 218 | url := client.GetURL(uri) |
| 219 | request, err := http.NewRequest(method, url.String(), strings.NewReader(string(parameters.Encode()))) |
| 220 | if err != nil { |
| 221 | return nil, err |
| 222 | } |
| 223 | request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 224 | return client.dispatchRequest(request) |
| 225 | } |
| 226 | |
| 227 | // Post performs an HTTP "POST" to the API. This may be either an API method |
| 228 | // invocation (if you pass its name in "operation") or plain resource |
| 229 | // retrieval (if you leave "operation" blank). |
| 230 | func (client Client) Post(uri *url.URL, operation string, parameters url.Values, files map[string][]byte) ([]byte, error) { |
| 231 | queryParams := url.Values{"op": {operation}} |
| 232 | uri.RawQuery = queryParams.Encode() |
| 233 | if files != nil { |
| 234 | return client.nonIdempotentRequestFiles("POST", uri, parameters, files) |
| 235 | } |
| 236 | return client.nonIdempotentRequest("POST", uri, parameters) |
| 237 | } |
| 238 | |
| 239 | // Put updates an object on the API, using an HTTP "PUT" request. |
| 240 | func (client Client) Put(uri *url.URL, parameters url.Values) ([]byte, error) { |
| 241 | return client.nonIdempotentRequest("PUT", uri, parameters) |
| 242 | } |
| 243 | |
| 244 | // Delete deletes an object on the API, using an HTTP "DELETE" request. |
| 245 | func (client Client) Delete(uri *url.URL) error { |
| 246 | url := client.GetURL(uri) |
| 247 | request, err := http.NewRequest("DELETE", url.String(), strings.NewReader("")) |
| 248 | if err != nil { |
| 249 | return err |
| 250 | } |
| 251 | _, err = client.dispatchRequest(request) |
| 252 | if err != nil { |
| 253 | return err |
| 254 | } |
| 255 | return nil |
| 256 | } |
| 257 | |
| 258 | // Anonymous "signature method" implementation. |
| 259 | type anonSigner struct{} |
| 260 | |
| 261 | func (signer anonSigner) OAuthSign(request *http.Request) error { |
| 262 | return nil |
| 263 | } |
| 264 | |
| 265 | // *anonSigner implements the OAuthSigner interface. |
| 266 | var _ OAuthSigner = anonSigner{} |
| 267 | |
| 268 | func composeAPIURL(BaseURL string, apiVersion string) (*url.URL, error) { |
| 269 | baseurl := EnsureTrailingSlash(BaseURL) |
| 270 | apiurl := fmt.Sprintf("%sapi/%s/", baseurl, apiVersion) |
| 271 | return url.Parse(apiurl) |
| 272 | } |
| 273 | |
| 274 | // NewAnonymousClient creates a client that issues anonymous requests. |
| 275 | // BaseURL should refer to the root of the MAAS server path, e.g. |
| 276 | // http://my.maas.server.example.com/MAAS/ |
| 277 | // apiVersion should contain the version of the MAAS API that you want to use. |
| 278 | func NewAnonymousClient(BaseURL string, apiVersion string) (*Client, error) { |
| 279 | parsedBaseURL, err := composeAPIURL(BaseURL, apiVersion) |
| 280 | if err != nil { |
| 281 | return nil, err |
| 282 | } |
| 283 | return &Client{Signer: &anonSigner{}, APIURL: parsedBaseURL}, nil |
| 284 | } |
| 285 | |
| 286 | // NewAuthenticatedClient parses the given MAAS API key into the individual |
| 287 | // OAuth tokens and creates an Client that will use these tokens to sign the |
| 288 | // requests it issues. |
| 289 | // BaseURL should refer to the root of the MAAS server path, e.g. |
| 290 | // http://my.maas.server.example.com/MAAS/ |
| 291 | // apiVersion should contain the version of the MAAS API that you want to use. |
| 292 | func NewAuthenticatedClient(BaseURL string, apiKey string, apiVersion string) (*Client, error) { |
| 293 | elements := strings.Split(apiKey, ":") |
| 294 | if len(elements) != 3 { |
| 295 | errString := fmt.Sprintf("invalid API key %q; expected \"<consumer secret>:<token key>:<token secret>\"", apiKey) |
| 296 | return nil, errors.NewNotValid(nil, errString) |
| 297 | } |
| 298 | token := &OAuthToken{ |
| 299 | ConsumerKey: elements[0], |
| 300 | // The consumer secret is the empty string in MAAS' authentication. |
| 301 | ConsumerSecret: "", |
| 302 | TokenKey: elements[1], |
| 303 | TokenSecret: elements[2], |
| 304 | } |
| 305 | signer, err := NewPlainTestOAuthSigner(token, "MAAS API") |
| 306 | if err != nil { |
| 307 | return nil, err |
| 308 | } |
| 309 | parsedBaseURL, err := composeAPIURL(BaseURL, apiVersion) |
| 310 | if err != nil { |
| 311 | return nil, err |
| 312 | } |
| 313 | return &Client{Signer: signer, APIURL: parsedBaseURL}, nil |
| 314 | } |