9005: Fix missing error checks.
[arvados.git] / sdk / go / arvados / client.go
1 package arvados
2
3 import (
4         "crypto/tls"
5         "encoding/json"
6         "fmt"
7         "io"
8         "io/ioutil"
9         "math"
10         "net/http"
11         "net/url"
12         "os"
13         "regexp"
14         "strings"
15         "time"
16 )
17
18 // A Client is an HTTP client with an API endpoint and a set of
19 // Arvados credentials.
20 //
21 // It offers methods for accessing individual Arvados APIs, and
22 // methods that implement common patterns like fetching multiple pages
23 // of results using List APIs.
24 type Client struct {
25         // HTTP client used to make requests. If nil,
26         // DefaultSecureClient or InsecureHTTPClient will be used.
27         Client *http.Client `json:"-"`
28
29         // Hostname (or host:port) of Arvados API server.
30         APIHost string
31
32         // User authentication token.
33         AuthToken string
34
35         // Accept unverified certificates. This works only if the
36         // Client field is nil: otherwise, it has no effect.
37         Insecure bool
38
39         // Override keep service discovery with a list of base
40         // URIs. (Currently there are no Client methods for
41         // discovering keep services so this is just a convenience for
42         // callers who use a Client to initialize an
43         // arvadosclient.ArvadosClient.)
44         KeepServiceURIs []string `json:",omitempty"`
45
46         dd *DiscoveryDocument
47 }
48
49 // The default http.Client used by a Client with Insecure==true and
50 // Client==nil.
51 var InsecureHTTPClient = &http.Client{
52         Transport: &http.Transport{
53                 TLSClientConfig: &tls.Config{
54                         InsecureSkipVerify: true}},
55         Timeout: 5 * time.Minute}
56
57 // The default http.Client used by a Client otherwise.
58 var DefaultSecureClient = &http.Client{
59         Timeout: 5 * time.Minute}
60
61 // NewClientFromEnv creates a new Client that uses the default HTTP
62 // client with the API endpoint and credentials given by the
63 // ARVADOS_API_* environment variables.
64 func NewClientFromEnv() *Client {
65         var svcs []string
66         if s := os.Getenv("ARVADOS_KEEP_SERVICES"); s != "" {
67                 svcs = strings.Split(s, " ")
68         }
69         return &Client{
70                 APIHost:         os.Getenv("ARVADOS_API_HOST"),
71                 AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
72                 Insecure:        os.Getenv("ARVADOS_API_HOST_INSECURE") != "",
73                 KeepServiceURIs: svcs,
74         }
75 }
76
77 // Do adds authentication headers and then calls (*http.Client)Do().
78 func (c *Client) Do(req *http.Request) (*http.Response, error) {
79         if c.AuthToken != "" {
80                 req.Header.Add("Authorization", "OAuth2 "+c.AuthToken)
81         }
82         return c.httpClient().Do(req)
83 }
84
85 // DoAndDecode performs req and unmarshals the response (which must be
86 // JSON) into dst. Use this instead of RequestAndDecode if you need
87 // more control of the http.Request object.
88 func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
89         resp, err := c.Do(req)
90         if err != nil {
91                 return err
92         }
93         defer resp.Body.Close()
94         buf, err := ioutil.ReadAll(resp.Body)
95         if err != nil {
96                 return err
97         }
98         if resp.StatusCode != 200 {
99                 return newTransactionError(req, resp, buf)
100         }
101         if dst == nil {
102                 return nil
103         }
104         return json.Unmarshal(buf, dst)
105 }
106
107 // Convert an arbitrary struct to url.Values. For example,
108 //
109 //     Foo{Bar: []int{1,2,3}, Baz: "waz"}
110 //
111 // becomes
112 //
113 //     url.Values{`bar`:`{"a":[1,2,3]}`,`Baz`:`waz`}
114 //
115 // params itself is returned if it is already an url.Values.
116 func anythingToValues(params interface{}) (url.Values, error) {
117         if v, ok := params.(url.Values); ok {
118                 return v, nil
119         }
120         // TODO: Do this more efficiently, possibly using
121         // json.Decode/Encode, so the whole thing doesn't have to get
122         // encoded, decoded, and re-encoded.
123         j, err := json.Marshal(params)
124         if err != nil {
125                 return nil, err
126         }
127         var generic map[string]interface{}
128         err = json.Unmarshal(j, &generic)
129         if err != nil {
130                 return nil, err
131         }
132         urlValues := url.Values{}
133         for k, v := range generic {
134                 if v, ok := v.(string); ok {
135                         urlValues.Set(k, v)
136                         continue
137                 }
138                 if v, ok := v.(float64); ok {
139                         // Unmarshal decodes all numbers as float64,
140                         // which can be written as 1.2345e4 in JSON,
141                         // but this form is not accepted for ints in
142                         // url params. If a number fits in an int64,
143                         // encode it as int64 rather than float64.
144                         if v, frac := math.Modf(v); frac == 0 && v <= math.MaxInt64 && v >= math.MinInt64 {
145                                 urlValues.Set(k, fmt.Sprintf("%d", int64(v)))
146                                 continue
147                         }
148                 }
149                 j, err := json.Marshal(v)
150                 if err != nil {
151                         return nil, err
152                 }
153                 urlValues.Set(k, string(j))
154         }
155         return urlValues, nil
156 }
157
158 // RequestAndDecode performs an API request and unmarshals the
159 // response (which must be JSON) into dst. Method and body arguments
160 // are the same as for http.NewRequest(). The given path is added to
161 // the server's scheme/host/port to form the request URL. The given
162 // params are passed via POST form or query string.
163 //
164 // path must not contain a query string.
165 func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error {
166         urlString := c.apiURL(path)
167         urlValues, err := anythingToValues(params)
168         if err != nil {
169                 return err
170         }
171         if (method == "GET" || body != nil) && urlValues != nil {
172                 // FIXME: what if params don't fit in URL
173                 u, err := url.Parse(urlString)
174                 if err != nil {
175                         return err
176                 }
177                 u.RawQuery = urlValues.Encode()
178                 urlString = u.String()
179         }
180         req, err := http.NewRequest(method, urlString, body)
181         if err != nil {
182                 return err
183         }
184         req.Header.Set("Content-type", "application/x-www-form-urlencoded")
185         return c.DoAndDecode(dst, req)
186 }
187
188 func (c *Client) httpClient() *http.Client {
189         switch {
190         case c.Client != nil:
191                 return c.Client
192         case c.Insecure:
193                 return InsecureHTTPClient
194         default:
195                 return DefaultSecureClient
196         }
197 }
198
199 func (c *Client) apiURL(path string) string {
200         return "https://" + c.APIHost + "/" + path
201 }
202
203 // DiscoveryDocument is the Arvados server's description of itself.
204 type DiscoveryDocument struct {
205         BasePath                     string              `json:"basePath"`
206         DefaultCollectionReplication int                 `json:"defaultCollectionReplication"`
207         BlobSignatureTTL             int64               `json:"blobSignatureTtl"`
208         Schemas                      map[string]Schema   `json:"schemas"`
209         Resources                    map[string]Resource `json:"resources"`
210 }
211
212 type Resource struct {
213         Methods map[string]ResourceMethod `json:"methods"`
214 }
215
216 type ResourceMethod struct {
217         HTTPMethod string         `json:"httpMethod"`
218         Path       string         `json:"path"`
219         Response   MethodResponse `json:"response"`
220 }
221
222 type MethodResponse struct {
223         Ref string `json:"$ref"`
224 }
225
226 type Schema struct {
227         UUIDPrefix string `json:"uuidPrefix"`
228 }
229
230 // DiscoveryDocument returns a *DiscoveryDocument. The returned object
231 // should not be modified: the same object may be returned by
232 // subsequent calls.
233 func (c *Client) DiscoveryDocument() (*DiscoveryDocument, error) {
234         if c.dd != nil {
235                 return c.dd, nil
236         }
237         var dd DiscoveryDocument
238         err := c.RequestAndDecode(&dd, "GET", "discovery/v1/apis/arvados/v1/rest", nil, nil)
239         if err != nil {
240                 return nil, err
241         }
242         c.dd = &dd
243         return c.dd, nil
244 }
245
246 var pdhRegexp = regexp.MustCompile(`^[0-9a-f]{32}\+\d+$`)
247
248 func (c *Client) modelForUUID(dd *DiscoveryDocument, uuid string) (string, error) {
249         if pdhRegexp.MatchString(uuid) {
250                 return "Collection", nil
251         }
252         if len(uuid) != 27 {
253                 return "", fmt.Errorf("invalid UUID: %q", uuid)
254         }
255         infix := uuid[6:11]
256         var model string
257         for m, s := range dd.Schemas {
258                 if s.UUIDPrefix == infix {
259                         model = m
260                         break
261                 }
262         }
263         if model == "" {
264                 return "", fmt.Errorf("unrecognized type portion %q in UUID %q", infix, uuid)
265         }
266         return model, nil
267 }
268
269 func (c *Client) KindForUUID(uuid string) (string, error) {
270         dd, err := c.DiscoveryDocument()
271         if err != nil {
272                 return "", err
273         }
274         model, err := c.modelForUUID(dd, uuid)
275         if err != nil {
276                 return "", err
277         }
278         return "arvados#" + strings.ToLower(model[:1]) + model[1:], nil
279 }
280
281 func (c *Client) PathForUUID(method, uuid string) (string, error) {
282         dd, err := c.DiscoveryDocument()
283         if err != nil {
284                 return "", err
285         }
286         model, err := c.modelForUUID(dd, uuid)
287         if err != nil {
288                 return "", err
289         }
290         var resource string
291         for r, rsc := range dd.Resources {
292                 if rsc.Methods["get"].Response.Ref == model {
293                         resource = r
294                         break
295                 }
296         }
297         if resource == "" {
298                 return "", fmt.Errorf("no resource for model: %q", model)
299         }
300         m, ok := dd.Resources[resource].Methods[method]
301         if !ok {
302                 return "", fmt.Errorf("no method %q for resource %q", method, resource)
303         }
304         path := dd.BasePath + strings.Replace(m.Path, "{uuid}", uuid, -1)
305         if path[0] == '/' {
306                 path = path[1:]
307         }
308         return path, nil
309 }