blob: ef887e638e6c090465f167bb0c1813815c1b60af [file] [log] [blame]
David K. Bainbridge528b3182017-01-23 08:51:59 -08001// Copyright 2012-2016 Canonical Ltd.
2// Licensed under the LGPLv3, see LICENCE file for details.
3
4package gomaasapi
5
6import (
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
21const (
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.
32type 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.
40type 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.
50func 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.
58func 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.
72func (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
106func (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.
131func (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).
138func (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.
161func 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.
175func 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.
193func (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).
217func (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).
230func (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.
240func (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.
245func (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.
259type anonSigner struct{}
260
261func (signer anonSigner) OAuthSign(request *http.Request) error {
262 return nil
263}
264
265// *anonSigner implements the OAuthSigner interface.
266var _ OAuthSigner = anonSigner{}
267
268func 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.
278func 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.
292func 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}