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