CWL spec -> CWL standards
[arvados.git] / sdk / go / arvados / client.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: Apache-2.0
4
5 package arvados
6
7 import (
8         "bytes"
9         "context"
10         "crypto/tls"
11         "encoding/json"
12         "fmt"
13         "io"
14         "io/ioutil"
15         "log"
16         "net/http"
17         "net/url"
18         "os"
19         "regexp"
20         "strings"
21         "time"
22
23         "git.arvados.org/arvados.git/sdk/go/httpserver"
24 )
25
26 // A Client is an HTTP client with an API endpoint and a set of
27 // Arvados credentials.
28 //
29 // It offers methods for accessing individual Arvados APIs, and
30 // methods that implement common patterns like fetching multiple pages
31 // of results using List APIs.
32 type Client struct {
33         // HTTP client used to make requests. If nil,
34         // DefaultSecureClient or InsecureHTTPClient will be used.
35         Client *http.Client `json:"-"`
36
37         // Protocol scheme: "http", "https", or "" (https)
38         Scheme string
39
40         // Hostname (or host:port) of Arvados API server.
41         APIHost string
42
43         // User authentication token.
44         AuthToken string
45
46         // Accept unverified certificates. This works only if the
47         // Client field is nil: otherwise, it has no effect.
48         Insecure bool
49
50         // Override keep service discovery with a list of base
51         // URIs. (Currently there are no Client methods for
52         // discovering keep services so this is just a convenience for
53         // callers who use a Client to initialize an
54         // arvadosclient.ArvadosClient.)
55         KeepServiceURIs []string `json:",omitempty"`
56
57         // HTTP headers to add/override in outgoing requests.
58         SendHeader http.Header
59
60         dd *DiscoveryDocument
61
62         ctx context.Context
63 }
64
65 // The default http.Client used by a Client with Insecure==true and
66 // Client==nil.
67 var InsecureHTTPClient = &http.Client{
68         Transport: &http.Transport{
69                 TLSClientConfig: &tls.Config{
70                         InsecureSkipVerify: true}},
71         Timeout: 5 * time.Minute}
72
73 // The default http.Client used by a Client otherwise.
74 var DefaultSecureClient = &http.Client{
75         Timeout: 5 * time.Minute}
76
77 // NewClientFromConfig creates a new Client that uses the endpoints in
78 // the given cluster.
79 //
80 // AuthToken is left empty for the caller to populate.
81 func NewClientFromConfig(cluster *Cluster) (*Client, error) {
82         ctrlURL := cluster.Services.Controller.ExternalURL
83         if ctrlURL.Host == "" {
84                 return nil, fmt.Errorf("no host in config Services.Controller.ExternalURL: %v", ctrlURL)
85         }
86         return &Client{
87                 Scheme:   ctrlURL.Scheme,
88                 APIHost:  ctrlURL.Host,
89                 Insecure: cluster.TLS.Insecure,
90         }, nil
91 }
92
93 // NewClientFromEnv creates a new Client that uses the default HTTP
94 // client with the API endpoint and credentials given by the
95 // ARVADOS_API_* environment variables.
96 func NewClientFromEnv() *Client {
97         var svcs []string
98         for _, s := range strings.Split(os.Getenv("ARVADOS_KEEP_SERVICES"), " ") {
99                 if s == "" {
100                         continue
101                 } else if u, err := url.Parse(s); err != nil {
102                         log.Printf("ARVADOS_KEEP_SERVICES: %q: %s", s, err)
103                 } else if !u.IsAbs() {
104                         log.Printf("ARVADOS_KEEP_SERVICES: %q: not an absolute URI", s)
105                 } else {
106                         svcs = append(svcs, s)
107                 }
108         }
109         var insecure bool
110         if s := strings.ToLower(os.Getenv("ARVADOS_API_HOST_INSECURE")); s == "1" || s == "yes" || s == "true" {
111                 insecure = true
112         }
113         return &Client{
114                 Scheme:          "https",
115                 APIHost:         os.Getenv("ARVADOS_API_HOST"),
116                 AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
117                 Insecure:        insecure,
118                 KeepServiceURIs: svcs,
119         }
120 }
121
122 var reqIDGen = httpserver.IDGenerator{Prefix: "req-"}
123
124 // Do adds Authorization and X-Request-Id headers and then calls
125 // (*http.Client)Do().
126 func (c *Client) Do(req *http.Request) (*http.Response, error) {
127         if auth, _ := req.Context().Value(contextKeyAuthorization{}).(string); auth != "" {
128                 req.Header.Add("Authorization", auth)
129         } else if c.AuthToken != "" {
130                 req.Header.Add("Authorization", "OAuth2 "+c.AuthToken)
131         }
132
133         if req.Header.Get("X-Request-Id") == "" {
134                 reqid, _ := req.Context().Value(contextKeyRequestID{}).(string)
135                 if reqid == "" {
136                         reqid, _ = c.context().Value(contextKeyRequestID{}).(string)
137                 }
138                 if reqid == "" {
139                         reqid = reqIDGen.Next()
140                 }
141                 if req.Header == nil {
142                         req.Header = http.Header{"X-Request-Id": {reqid}}
143                 } else {
144                         req.Header.Set("X-Request-Id", reqid)
145                 }
146         }
147         return c.httpClient().Do(req)
148 }
149
150 func isRedirectStatus(code int) bool {
151         switch code {
152         case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
153                 return true
154         default:
155                 return false
156         }
157 }
158
159 // DoAndDecode performs req and unmarshals the response (which must be
160 // JSON) into dst. Use this instead of RequestAndDecode if you need
161 // more control of the http.Request object.
162 //
163 // If the response status indicates an HTTP redirect, the Location
164 // header value is unmarshalled to dst as a RedirectLocation
165 // key/field.
166 func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
167         resp, err := c.Do(req)
168         if err != nil {
169                 return err
170         }
171         defer resp.Body.Close()
172         buf, err := ioutil.ReadAll(resp.Body)
173         if err != nil {
174                 return err
175         }
176         switch {
177         case resp.StatusCode == http.StatusOK && dst == nil:
178                 return nil
179         case resp.StatusCode == http.StatusOK:
180                 return json.Unmarshal(buf, dst)
181
182         // If the caller uses a client with a custom CheckRedirect
183         // func, Do() might return the 3xx response instead of
184         // following it.
185         case isRedirectStatus(resp.StatusCode) && dst == nil:
186                 return nil
187         case isRedirectStatus(resp.StatusCode):
188                 // Copy the redirect target URL to dst.RedirectLocation.
189                 buf, err := json.Marshal(map[string]string{"redirect_location": resp.Header.Get("Location")})
190                 if err != nil {
191                         return err
192                 }
193                 return json.Unmarshal(buf, dst)
194
195         default:
196                 return newTransactionError(req, resp, buf)
197         }
198 }
199
200 // Convert an arbitrary struct to url.Values. For example,
201 //
202 //     Foo{Bar: []int{1,2,3}, Baz: "waz"}
203 //
204 // becomes
205 //
206 //     url.Values{`bar`:`{"a":[1,2,3]}`,`Baz`:`waz`}
207 //
208 // params itself is returned if it is already an url.Values.
209 func anythingToValues(params interface{}) (url.Values, error) {
210         if v, ok := params.(url.Values); ok {
211                 return v, nil
212         }
213         // TODO: Do this more efficiently, possibly using
214         // json.Decode/Encode, so the whole thing doesn't have to get
215         // encoded, decoded, and re-encoded.
216         j, err := json.Marshal(params)
217         if err != nil {
218                 return nil, err
219         }
220         var generic map[string]interface{}
221         dec := json.NewDecoder(bytes.NewBuffer(j))
222         dec.UseNumber()
223         err = dec.Decode(&generic)
224         if err != nil {
225                 return nil, err
226         }
227         urlValues := url.Values{}
228         for k, v := range generic {
229                 if v, ok := v.(string); ok {
230                         urlValues.Set(k, v)
231                         continue
232                 }
233                 if v, ok := v.(json.Number); ok {
234                         urlValues.Set(k, v.String())
235                         continue
236                 }
237                 if v, ok := v.(bool); ok {
238                         if v {
239                                 urlValues.Set(k, "true")
240                         } else {
241                                 // "foo=false", "foo=0", and "foo="
242                                 // are all taken as true strings, so
243                                 // don't send false values at all --
244                                 // rely on the default being false.
245                         }
246                         continue
247                 }
248                 j, err := json.Marshal(v)
249                 if err != nil {
250                         return nil, err
251                 }
252                 if bytes.Equal(j, []byte("null")) {
253                         // don't add it to urlValues at all
254                         continue
255                 }
256                 urlValues.Set(k, string(j))
257         }
258         return urlValues, nil
259 }
260
261 // RequestAndDecode performs an API request and unmarshals the
262 // response (which must be JSON) into dst. Method and body arguments
263 // are the same as for http.NewRequest(). The given path is added to
264 // the server's scheme/host/port to form the request URL. The given
265 // params are passed via POST form or query string.
266 //
267 // path must not contain a query string.
268 func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error {
269         return c.RequestAndDecodeContext(c.context(), dst, method, path, body, params)
270 }
271
272 func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, method, path string, body io.Reader, params interface{}) error {
273         if body, ok := body.(io.Closer); ok {
274                 // Ensure body is closed even if we error out early
275                 defer body.Close()
276         }
277         urlString := c.apiURL(path)
278         urlValues, err := anythingToValues(params)
279         if err != nil {
280                 return err
281         }
282         if urlValues == nil {
283                 // Nothing to send
284         } else if body != nil || ((method == "GET" || method == "HEAD") && len(urlValues.Encode()) < 1000) {
285                 // Send params in query part of URL
286                 u, err := url.Parse(urlString)
287                 if err != nil {
288                         return err
289                 }
290                 u.RawQuery = urlValues.Encode()
291                 urlString = u.String()
292         } else {
293                 body = strings.NewReader(urlValues.Encode())
294         }
295         req, err := http.NewRequest(method, urlString, body)
296         if err != nil {
297                 return err
298         }
299         if (method == "GET" || method == "HEAD") && body != nil {
300                 req.Header.Set("X-Http-Method-Override", method)
301                 req.Method = "POST"
302         }
303         req = req.WithContext(ctx)
304         req.Header.Set("Content-type", "application/x-www-form-urlencoded")
305         for k, v := range c.SendHeader {
306                 req.Header[k] = v
307         }
308         return c.DoAndDecode(dst, req)
309 }
310
311 type resource interface {
312         resourceName() string
313 }
314
315 // UpdateBody returns an io.Reader suitable for use as an http.Request
316 // Body for a create or update API call.
317 func (c *Client) UpdateBody(rsc resource) io.Reader {
318         j, err := json.Marshal(rsc)
319         if err != nil {
320                 // Return a reader that returns errors.
321                 r, w := io.Pipe()
322                 w.CloseWithError(err)
323                 return r
324         }
325         v := url.Values{rsc.resourceName(): {string(j)}}
326         return bytes.NewBufferString(v.Encode())
327 }
328
329 // WithRequestID returns a new shallow copy of c that sends the given
330 // X-Request-Id value (instead of a new randomly generated one) with
331 // each subsequent request that doesn't provide its own via context or
332 // header.
333 func (c *Client) WithRequestID(reqid string) *Client {
334         cc := *c
335         cc.ctx = ContextWithRequestID(cc.context(), reqid)
336         return &cc
337 }
338
339 func (c *Client) context() context.Context {
340         if c.ctx == nil {
341                 return context.Background()
342         }
343         return c.ctx
344 }
345
346 func (c *Client) httpClient() *http.Client {
347         switch {
348         case c.Client != nil:
349                 return c.Client
350         case c.Insecure:
351                 return InsecureHTTPClient
352         default:
353                 return DefaultSecureClient
354         }
355 }
356
357 func (c *Client) apiURL(path string) string {
358         scheme := c.Scheme
359         if scheme == "" {
360                 scheme = "https"
361         }
362         return scheme + "://" + c.APIHost + "/" + path
363 }
364
365 // DiscoveryDocument is the Arvados server's description of itself.
366 type DiscoveryDocument struct {
367         BasePath                     string              `json:"basePath"`
368         DefaultCollectionReplication int                 `json:"defaultCollectionReplication"`
369         BlobSignatureTTL             int64               `json:"blobSignatureTtl"`
370         GitURL                       string              `json:"gitUrl"`
371         Schemas                      map[string]Schema   `json:"schemas"`
372         Resources                    map[string]Resource `json:"resources"`
373 }
374
375 type Resource struct {
376         Methods map[string]ResourceMethod `json:"methods"`
377 }
378
379 type ResourceMethod struct {
380         HTTPMethod string         `json:"httpMethod"`
381         Path       string         `json:"path"`
382         Response   MethodResponse `json:"response"`
383 }
384
385 type MethodResponse struct {
386         Ref string `json:"$ref"`
387 }
388
389 type Schema struct {
390         UUIDPrefix string `json:"uuidPrefix"`
391 }
392
393 // DiscoveryDocument returns a *DiscoveryDocument. The returned object
394 // should not be modified: the same object may be returned by
395 // subsequent calls.
396 func (c *Client) DiscoveryDocument() (*DiscoveryDocument, error) {
397         if c.dd != nil {
398                 return c.dd, nil
399         }
400         var dd DiscoveryDocument
401         err := c.RequestAndDecode(&dd, "GET", "discovery/v1/apis/arvados/v1/rest", nil, nil)
402         if err != nil {
403                 return nil, err
404         }
405         c.dd = &dd
406         return c.dd, nil
407 }
408
409 var pdhRegexp = regexp.MustCompile(`^[0-9a-f]{32}\+\d+$`)
410
411 func (c *Client) modelForUUID(dd *DiscoveryDocument, uuid string) (string, error) {
412         if pdhRegexp.MatchString(uuid) {
413                 return "Collection", nil
414         }
415         if len(uuid) != 27 {
416                 return "", fmt.Errorf("invalid UUID: %q", uuid)
417         }
418         infix := uuid[6:11]
419         var model string
420         for m, s := range dd.Schemas {
421                 if s.UUIDPrefix == infix {
422                         model = m
423                         break
424                 }
425         }
426         if model == "" {
427                 return "", fmt.Errorf("unrecognized type portion %q in UUID %q", infix, uuid)
428         }
429         return model, nil
430 }
431
432 func (c *Client) KindForUUID(uuid string) (string, error) {
433         dd, err := c.DiscoveryDocument()
434         if err != nil {
435                 return "", err
436         }
437         model, err := c.modelForUUID(dd, uuid)
438         if err != nil {
439                 return "", err
440         }
441         return "arvados#" + strings.ToLower(model[:1]) + model[1:], nil
442 }
443
444 func (c *Client) PathForUUID(method, uuid string) (string, error) {
445         dd, err := c.DiscoveryDocument()
446         if err != nil {
447                 return "", err
448         }
449         model, err := c.modelForUUID(dd, uuid)
450         if err != nil {
451                 return "", err
452         }
453         var resource string
454         for r, rsc := range dd.Resources {
455                 if rsc.Methods["get"].Response.Ref == model {
456                         resource = r
457                         break
458                 }
459         }
460         if resource == "" {
461                 return "", fmt.Errorf("no resource for model: %q", model)
462         }
463         m, ok := dd.Resources[resource].Methods[method]
464         if !ok {
465                 return "", fmt.Errorf("no method %q for resource %q", method, resource)
466         }
467         path := dd.BasePath + strings.Replace(m.Path, "{uuid}", uuid, -1)
468         if path[0] == '/' {
469                 path = path[1:]
470         }
471         return path, nil
472 }