Merge branch 'patch-1' of https://github.com/mr-c/arvados into mr-c-patch-1
[arvados.git] / sdk / go / arvados / client.go
index 37ff4d818a2ee33d1930a4842927f6a4994e33b7..562c8c1e7d7c66528a2ce0874eca034c9eb7b328 100644 (file)
@@ -13,7 +13,6 @@ import (
        "io"
        "io/ioutil"
        "log"
-       "math"
        "net/http"
        "net/url"
        "os"
@@ -21,7 +20,7 @@ import (
        "strings"
        "time"
 
-       "git.curoverse.com/arvados.git/sdk/go/httpserver"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
 )
 
 // A Client is an HTTP client with an API endpoint and a set of
@@ -35,6 +34,9 @@ type Client struct {
        // DefaultSecureClient or InsecureHTTPClient will be used.
        Client *http.Client `json:"-"`
 
+       // Protocol scheme: "http", "https", or "" (https)
+       Scheme string
+
        // Hostname (or host:port) of Arvados API server.
        APIHost string
 
@@ -52,9 +54,19 @@ type Client struct {
        // arvadosclient.ArvadosClient.)
        KeepServiceURIs []string `json:",omitempty"`
 
+       // HTTP headers to add/override in outgoing requests.
+       SendHeader http.Header
+
+       // Timeout for requests. NewClientFromConfig and
+       // NewClientFromEnv return a Client with a default 5 minute
+       // timeout.  To disable this timeout and rely on each
+       // http.Request's context deadline instead, set Timeout to
+       // zero.
+       Timeout time.Duration
+
        dd *DiscoveryDocument
 
-       ctx context.Context
+       defaultRequestID string
 }
 
 // The default http.Client used by a Client with Insecure==true and
@@ -62,12 +74,10 @@ type Client struct {
 var InsecureHTTPClient = &http.Client{
        Transport: &http.Transport{
                TLSClientConfig: &tls.Config{
-                       InsecureSkipVerify: true}},
-       Timeout: 5 * time.Minute}
+                       InsecureSkipVerify: true}}}
 
 // The default http.Client used by a Client otherwise.
-var DefaultSecureClient = &http.Client{
-       Timeout: 5 * time.Minute}
+var DefaultSecureClient = &http.Client{}
 
 // NewClientFromConfig creates a new Client that uses the endpoints in
 // the given cluster.
@@ -79,8 +89,10 @@ func NewClientFromConfig(cluster *Cluster) (*Client, error) {
                return nil, fmt.Errorf("no host in config Services.Controller.ExternalURL: %v", ctrlURL)
        }
        return &Client{
-               APIHost:  fmt.Sprintf("%v", ctrlURL),
+               Scheme:   ctrlURL.Scheme,
+               APIHost:  ctrlURL.Host,
                Insecure: cluster.TLS.Insecure,
+               Timeout:  5 * time.Minute,
        }, nil
 }
 
@@ -105,10 +117,12 @@ func NewClientFromEnv() *Client {
                insecure = true
        }
        return &Client{
+               Scheme:          "https",
                APIHost:         os.Getenv("ARVADOS_API_HOST"),
                AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
                Insecure:        insecure,
                KeepServiceURIs: svcs,
+               Timeout:         5 * time.Minute,
        }
 }
 
@@ -117,13 +131,19 @@ var reqIDGen = httpserver.IDGenerator{Prefix: "req-"}
 // Do adds Authorization and X-Request-Id headers and then calls
 // (*http.Client)Do().
 func (c *Client) Do(req *http.Request) (*http.Response, error) {
-       if c.AuthToken != "" {
+       if auth, _ := req.Context().Value(contextKeyAuthorization{}).(string); auth != "" {
+               req.Header.Add("Authorization", auth)
+       } else if c.AuthToken != "" {
                req.Header.Add("Authorization", "OAuth2 "+c.AuthToken)
        }
 
        if req.Header.Get("X-Request-Id") == "" {
-               reqid, _ := c.context().Value(contextKeyRequestID).(string)
-               if reqid == "" {
+               var reqid string
+               if ctxreqid, _ := req.Context().Value(contextKeyRequestID{}).(string); ctxreqid != "" {
+                       reqid = ctxreqid
+               } else if c.defaultRequestID != "" {
+                       reqid = c.defaultRequestID
+               } else {
                        reqid = reqIDGen.Next()
                }
                if req.Header == nil {
@@ -132,12 +152,54 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
                        req.Header.Set("X-Request-Id", reqid)
                }
        }
-       return c.httpClient().Do(req)
+       var cancel context.CancelFunc
+       if c.Timeout > 0 {
+               ctx := req.Context()
+               ctx, cancel = context.WithDeadline(ctx, time.Now().Add(c.Timeout))
+               req = req.WithContext(ctx)
+       }
+       resp, err := c.httpClient().Do(req)
+       if err == nil && cancel != nil {
+               // We need to call cancel() eventually, but we can't
+               // use "defer cancel()" because the context has to
+               // stay alive until the caller has finished reading
+               // the response body.
+               resp.Body = cancelOnClose{ReadCloser: resp.Body, cancel: cancel}
+       } else if cancel != nil {
+               cancel()
+       }
+       return resp, err
+}
+
+// cancelOnClose calls a provided CancelFunc when its wrapped
+// ReadCloser's Close() method is called.
+type cancelOnClose struct {
+       io.ReadCloser
+       cancel context.CancelFunc
+}
+
+func (coc cancelOnClose) Close() error {
+       err := coc.ReadCloser.Close()
+       coc.cancel()
+       return err
+}
+
+func isRedirectStatus(code int) bool {
+       switch code {
+       case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
+               return true
+       default:
+               return false
+       }
 }
 
 // DoAndDecode performs req and unmarshals the response (which must be
 // JSON) into dst. Use this instead of RequestAndDecode if you need
 // more control of the http.Request object.
+//
+// If the response status indicates an HTTP redirect, the Location
+// header value is unmarshalled to dst as a RedirectLocation
+// key/field.
 func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
        resp, err := c.Do(req)
        if err != nil {
@@ -148,13 +210,28 @@ func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
        if err != nil {
                return err
        }
-       if resp.StatusCode != 200 {
-               return newTransactionError(req, resp, buf)
-       }
-       if dst == nil {
+       switch {
+       case resp.StatusCode == http.StatusOK && dst == nil:
+               return nil
+       case resp.StatusCode == http.StatusOK:
+               return json.Unmarshal(buf, dst)
+
+       // If the caller uses a client with a custom CheckRedirect
+       // func, Do() might return the 3xx response instead of
+       // following it.
+       case isRedirectStatus(resp.StatusCode) && dst == nil:
                return nil
+       case isRedirectStatus(resp.StatusCode):
+               // Copy the redirect target URL to dst.RedirectLocation.
+               buf, err := json.Marshal(map[string]string{"redirect_location": resp.Header.Get("Location")})
+               if err != nil {
+                       return err
+               }
+               return json.Unmarshal(buf, dst)
+
+       default:
+               return newTransactionError(req, resp, buf)
        }
-       return json.Unmarshal(buf, dst)
 }
 
 // Convert an arbitrary struct to url.Values. For example,
@@ -178,7 +255,9 @@ func anythingToValues(params interface{}) (url.Values, error) {
                return nil, err
        }
        var generic map[string]interface{}
-       err = json.Unmarshal(j, &generic)
+       dec := json.NewDecoder(bytes.NewBuffer(j))
+       dec.UseNumber()
+       err = dec.Decode(&generic)
        if err != nil {
                return nil, err
        }
@@ -188,21 +267,29 @@ func anythingToValues(params interface{}) (url.Values, error) {
                        urlValues.Set(k, v)
                        continue
                }
-               if v, ok := v.(float64); ok {
-                       // Unmarshal decodes all numbers as float64,
-                       // which can be written as 1.2345e4 in JSON,
-                       // but this form is not accepted for ints in
-                       // url params. If a number fits in an int64,
-                       // encode it as int64 rather than float64.
-                       if v, frac := math.Modf(v); frac == 0 && v <= math.MaxInt64 && v >= math.MinInt64 {
-                               urlValues.Set(k, fmt.Sprintf("%d", int64(v)))
-                               continue
+               if v, ok := v.(json.Number); ok {
+                       urlValues.Set(k, v.String())
+                       continue
+               }
+               if v, ok := v.(bool); ok {
+                       if v {
+                               urlValues.Set(k, "true")
+                       } else {
+                               // "foo=false", "foo=0", and "foo="
+                               // are all taken as true strings, so
+                               // don't send false values at all --
+                               // rely on the default being false.
                        }
+                       continue
                }
                j, err := json.Marshal(v)
                if err != nil {
                        return nil, err
                }
+               if bytes.Equal(j, []byte("null")) {
+                       // don't add it to urlValues at all
+                       continue
+               }
                urlValues.Set(k, string(j))
        }
        return urlValues, nil
@@ -216,6 +303,10 @@ func anythingToValues(params interface{}) (url.Values, error) {
 //
 // path must not contain a query string.
 func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error {
+       return c.RequestAndDecodeContext(context.Background(), dst, method, path, body, params)
+}
+
+func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, method, path string, body io.Reader, params interface{}) error {
        if body, ok := body.(io.Closer); ok {
                // Ensure body is closed even if we error out early
                defer body.Close()
@@ -227,9 +318,8 @@ func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.
        }
        if urlValues == nil {
                // Nothing to send
-       } else if method == "GET" || method == "HEAD" || body != nil {
-               // Must send params in query part of URL (FIXME: what
-               // if resulting URL is too long?)
+       } else if body != nil || ((method == "GET" || method == "HEAD") && len(urlValues.Encode()) < 1000) {
+               // Send params in query part of URL
                u, err := url.Parse(urlString)
                if err != nil {
                        return err
@@ -243,7 +333,15 @@ func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.
        if err != nil {
                return err
        }
+       if (method == "GET" || method == "HEAD") && body != nil {
+               req.Header.Set("X-Http-Method-Override", method)
+               req.Method = "POST"
+       }
+       req = req.WithContext(ctx)
        req.Header.Set("Content-type", "application/x-www-form-urlencoded")
+       for k, v := range c.SendHeader {
+               req.Header[k] = v
+       }
        return c.DoAndDecode(dst, req)
 }
 
@@ -265,23 +363,16 @@ func (c *Client) UpdateBody(rsc resource) io.Reader {
        return bytes.NewBufferString(v.Encode())
 }
 
-type contextKey string
-
-var contextKeyRequestID contextKey = "X-Request-Id"
-
+// WithRequestID returns a new shallow copy of c that sends the given
+// X-Request-Id value (instead of a new randomly generated one) with
+// each subsequent request that doesn't provide its own via context or
+// header.
 func (c *Client) WithRequestID(reqid string) *Client {
        cc := *c
-       cc.ctx = context.WithValue(cc.context(), contextKeyRequestID, reqid)
+       cc.defaultRequestID = reqid
        return &cc
 }
 
-func (c *Client) context() context.Context {
-       if c.ctx == nil {
-               return context.Background()
-       }
-       return c.ctx
-}
-
 func (c *Client) httpClient() *http.Client {
        switch {
        case c.Client != nil:
@@ -294,7 +385,11 @@ func (c *Client) httpClient() *http.Client {
 }
 
 func (c *Client) apiURL(path string) string {
-       return "https://" + c.APIHost + "/" + path
+       scheme := c.Scheme
+       if scheme == "" {
+               scheme = "https"
+       }
+       return scheme + "://" + c.APIHost + "/" + path
 }
 
 // DiscoveryDocument is the Arvados server's description of itself.