5538: using fake arvados server to generate errors, added tests with retries.
[arvados.git] / sdk / go / arvadosclient / arvadosclient.go
1 /* Simple Arvados Go SDK for communicating with API server. */
2
3 package arvadosclient
4
5 import (
6         "bytes"
7         "crypto/tls"
8         "encoding/json"
9         "errors"
10         "fmt"
11         "io"
12         "net/http"
13         "net/url"
14         "os"
15         "regexp"
16         "strings"
17 )
18
19 type StringMatcher func(string) bool
20
21 var UUIDMatch StringMatcher = regexp.MustCompile(`^[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}$`).MatchString
22 var PDHMatch StringMatcher = regexp.MustCompile(`^[0-9a-f]{32}\+\d+$`).MatchString
23
24 var MissingArvadosApiHost = errors.New("Missing required environment variable ARVADOS_API_HOST")
25 var MissingArvadosApiToken = errors.New("Missing required environment variable ARVADOS_API_TOKEN")
26 var ErrInvalidArgument = errors.New("Invalid argument")
27
28 // Indicates an error that was returned by the API server.
29 type APIServerError struct {
30         // Address of server returning error, of the form "host:port".
31         ServerAddress string
32
33         // Components of server response.
34         HttpStatusCode    int
35         HttpStatusMessage string
36
37         // Additional error details from response body.
38         ErrorDetails []string
39 }
40
41 func (e APIServerError) Error() string {
42         if len(e.ErrorDetails) > 0 {
43                 return fmt.Sprintf("arvados API server error: %s (%d: %s) returned by %s",
44                         strings.Join(e.ErrorDetails, "; "),
45                         e.HttpStatusCode,
46                         e.HttpStatusMessage,
47                         e.ServerAddress)
48         } else {
49                 return fmt.Sprintf("arvados API server error: %d: %s returned by %s",
50                         e.HttpStatusCode,
51                         e.HttpStatusMessage,
52                         e.ServerAddress)
53         }
54 }
55
56 // Helper type so we don't have to write out 'map[string]interface{}' every time.
57 type Dict map[string]interface{}
58
59 // Information about how to contact the Arvados server
60 type ArvadosClient struct {
61         // https
62         Scheme string
63
64         // Arvados API server, form "host:port"
65         ApiServer string
66
67         // Arvados API token for authentication
68         ApiToken string
69
70         // Whether to require a valid SSL certificate or not
71         ApiInsecure bool
72
73         // Client object shared by client requests.  Supports HTTP KeepAlive.
74         Client *http.Client
75
76         // If true, sets the X-External-Client header to indicate
77         // the client is outside the cluster.
78         External bool
79
80         // Discovery document
81         DiscoveryDoc Dict
82
83         // Number of retries
84         Retries int
85 }
86
87 // Create a new ArvadosClient, initialized with standard Arvados environment
88 // variables ARVADOS_API_HOST, ARVADOS_API_TOKEN, and (optionally)
89 // ARVADOS_API_HOST_INSECURE.
90 func MakeArvadosClient() (ac ArvadosClient, err error) {
91         var matchTrue = regexp.MustCompile("^(?i:1|yes|true)$")
92         insecure := matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE"))
93         external := matchTrue.MatchString(os.Getenv("ARVADOS_EXTERNAL_CLIENT"))
94
95         ac = ArvadosClient{
96                 Scheme:      "https",
97                 ApiServer:   os.Getenv("ARVADOS_API_HOST"),
98                 ApiToken:    os.Getenv("ARVADOS_API_TOKEN"),
99                 ApiInsecure: insecure,
100                 Client: &http.Client{Transport: &http.Transport{
101                         TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}}},
102                 External: external,
103                 Retries:  2}
104
105         if ac.ApiServer == "" {
106                 return ac, MissingArvadosApiHost
107         }
108         if ac.ApiToken == "" {
109                 return ac, MissingArvadosApiToken
110         }
111
112         return ac, err
113 }
114
115 // CallRaw is the same as Call() but returns a Reader that reads the
116 // response body, instead of taking an output object.
117 func (c ArvadosClient) CallRaw(method string, resourceType string, uuid string, action string, parameters Dict) (reader io.ReadCloser, err error) {
118         scheme := c.Scheme
119         if scheme == "" {
120                 scheme = "https"
121         }
122         u := url.URL{
123                 Scheme: scheme,
124                 Host:   c.ApiServer}
125
126         if resourceType != API_DISCOVERY_RESOURCE {
127                 u.Path = "/arvados/v1"
128         }
129
130         if resourceType != "" {
131                 u.Path = u.Path + "/" + resourceType
132         }
133         if uuid != "" {
134                 u.Path = u.Path + "/" + uuid
135         }
136         if action != "" {
137                 u.Path = u.Path + "/" + action
138         }
139
140         if parameters == nil {
141                 parameters = make(Dict)
142         }
143
144         vals := make(url.Values)
145         for k, v := range parameters {
146                 if s, ok := v.(string); ok {
147                         vals.Set(k, s)
148                 } else if m, err := json.Marshal(v); err == nil {
149                         vals.Set(k, string(m))
150                 }
151         }
152
153         // Make the request
154         remainingTries := 1 + c.Retries
155         var req *http.Request
156         var resp *http.Response
157         var errs []string
158         var badResp bool
159
160         for remainingTries > 0 {
161                 if method == "GET" || method == "HEAD" {
162                         u.RawQuery = vals.Encode()
163                         if req, err = http.NewRequest(method, u.String(), nil); err != nil {
164                                 return nil, err
165                         }
166                 } else {
167                         if req, err = http.NewRequest(method, u.String(), bytes.NewBufferString(vals.Encode())); err != nil {
168                                 return nil, err
169                         }
170                         req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
171                 }
172
173                 // Add api token header
174                 req.Header.Add("Authorization", fmt.Sprintf("OAuth2 %s", c.ApiToken))
175                 if c.External {
176                         req.Header.Add("X-External-Client", "1")
177                 }
178
179                 resp, err = c.Client.Do(req)
180                 if err != nil {
181                         if method == "GET" || method == "HEAD" || method == "PUT" {
182                                 errs = append(errs, err.Error())
183                                 badResp = false
184                                 remainingTries -= 1
185                                 continue
186                         } else {
187                                 return nil, err
188                         }
189                 }
190
191                 if resp.StatusCode == http.StatusOK {
192                         return resp.Body, nil
193                 }
194
195                 defer resp.Body.Close()
196
197                 if resp.StatusCode == 408 ||
198                         resp.StatusCode == 409 ||
199                         resp.StatusCode == 422 ||
200                         resp.StatusCode == 423 ||
201                         resp.StatusCode == 500 ||
202                         resp.StatusCode == 502 ||
203                         resp.StatusCode == 503 ||
204                         resp.StatusCode == 504 {
205                         badResp = true
206                         remainingTries -= 1
207                         continue
208                 } else {
209                         return nil, newAPIServerError(c.ApiServer, resp)
210                 }
211         }
212
213         if badResp {
214                 return nil, newAPIServerError(c.ApiServer, resp)
215         } else {
216                 return nil, fmt.Errorf("%v", errs)
217         }
218 }
219
220 func newAPIServerError(ServerAddress string, resp *http.Response) APIServerError {
221
222         ase := APIServerError{
223                 ServerAddress:     ServerAddress,
224                 HttpStatusCode:    resp.StatusCode,
225                 HttpStatusMessage: resp.Status}
226
227         // If the response body has {"errors":["reason1","reason2"]}
228         // then return those reasons.
229         var errInfo = Dict{}
230         if err := json.NewDecoder(resp.Body).Decode(&errInfo); err == nil {
231                 if errorList, ok := errInfo["errors"]; ok {
232                         if errArray, ok := errorList.([]interface{}); ok {
233                                 for _, errItem := range errArray {
234                                         // We expect an array of strings here.
235                                         // Non-strings will be passed along
236                                         // JSON-encoded.
237                                         if s, ok := errItem.(string); ok {
238                                                 ase.ErrorDetails = append(ase.ErrorDetails, s)
239                                         } else if j, err := json.Marshal(errItem); err == nil {
240                                                 ase.ErrorDetails = append(ase.ErrorDetails, string(j))
241                                         }
242                                 }
243                         }
244                 }
245         }
246         return ase
247 }
248
249 // Call an API endpoint and parse the JSON response into an object.
250 //
251 //   method - HTTP method: GET, HEAD, PUT, POST, PATCH or DELETE.
252 //   resourceType - the type of arvados resource to act on (e.g., "collections", "pipeline_instances").
253 //   uuid - the uuid of the specific item to access. May be empty.
254 //   action - API method name (e.g., "lock"). This is often empty if implied by method and uuid.
255 //   parameters - method parameters.
256 //   output - a map or annotated struct which is a legal target for encoding/json/Decoder.
257 //
258 // Returns a non-nil error if an error occurs making the API call, the
259 // API responds with a non-successful HTTP status, or an error occurs
260 // parsing the response body.
261 func (c ArvadosClient) Call(method string, resourceType string, uuid string, action string, parameters Dict, output interface{}) error {
262         reader, err := c.CallRaw(method, resourceType, uuid, action, parameters)
263         if reader != nil {
264                 defer reader.Close()
265         }
266         if err != nil {
267                 return err
268         }
269
270         if output != nil {
271                 dec := json.NewDecoder(reader)
272                 if err = dec.Decode(output); err != nil {
273                         return err
274                 }
275         }
276         return nil
277 }
278
279 // Create a new resource. See Call for argument descriptions.
280 func (c ArvadosClient) Create(resourceType string, parameters Dict, output interface{}) error {
281         return c.Call("POST", resourceType, "", "", parameters, output)
282 }
283
284 // Delete a resource. See Call for argument descriptions.
285 func (c ArvadosClient) Delete(resource string, uuid string, parameters Dict, output interface{}) (err error) {
286         return c.Call("DELETE", resource, uuid, "", parameters, output)
287 }
288
289 // Modify attributes of a resource. See Call for argument descriptions.
290 func (c ArvadosClient) Update(resourceType string, uuid string, parameters Dict, output interface{}) (err error) {
291         return c.Call("PUT", resourceType, uuid, "", parameters, output)
292 }
293
294 // Get a resource. See Call for argument descriptions.
295 func (c ArvadosClient) Get(resourceType string, uuid string, parameters Dict, output interface{}) (err error) {
296         if !UUIDMatch(uuid) && !(resourceType == "collections" && PDHMatch(uuid)) {
297                 // No object has uuid == "": there is no need to make
298                 // an API call. Furthermore, the HTTP request for such
299                 // an API call would be "GET /arvados/v1/type/", which
300                 // is liable to be misinterpreted as the List API.
301                 return ErrInvalidArgument
302         }
303         return c.Call("GET", resourceType, uuid, "", parameters, output)
304 }
305
306 // List resources of a given type. See Call for argument descriptions.
307 func (c ArvadosClient) List(resource string, parameters Dict, output interface{}) (err error) {
308         return c.Call("GET", resource, "", "", parameters, output)
309 }
310
311 const API_DISCOVERY_RESOURCE = "discovery/v1/apis/arvados/v1/rest"
312
313 // Discovery returns the value of the given parameter in the discovery
314 // document. Returns a non-nil error if the discovery document cannot
315 // be retrieved/decoded. Returns ErrInvalidArgument if the requested
316 // parameter is not found in the discovery document.
317 func (c *ArvadosClient) Discovery(parameter string) (value interface{}, err error) {
318         if len(c.DiscoveryDoc) == 0 {
319                 c.DiscoveryDoc = make(Dict)
320                 err = c.Call("GET", API_DISCOVERY_RESOURCE, "", "", nil, &c.DiscoveryDoc)
321                 if err != nil {
322                         return nil, err
323                 }
324         }
325
326         var found bool
327         value, found = c.DiscoveryDoc[parameter]
328         if found {
329                 return value, nil
330         } else {
331                 return value, ErrInvalidArgument
332         }
333 }