Merge pull request #1 from arvados/master
[arvados.git] / sdk / go / arvados / client.go
index 787e01ab8f7dc8be892e7c754bca4a29cba84b13..67e3f631174ce86741190a25aa4ce8a63864fd1f 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,6 +54,9 @@ type Client struct {
        // arvadosclient.ArvadosClient.)
        KeepServiceURIs []string `json:",omitempty"`
 
+       // HTTP headers to add/override in outgoing requests.
+       SendHeader http.Header
+
        dd *DiscoveryDocument
 
        ctx context.Context
@@ -69,6 +74,22 @@ var InsecureHTTPClient = &http.Client{
 var DefaultSecureClient = &http.Client{
        Timeout: 5 * time.Minute}
 
+// NewClientFromConfig creates a new Client that uses the endpoints in
+// the given cluster.
+//
+// AuthToken is left empty for the caller to populate.
+func NewClientFromConfig(cluster *Cluster) (*Client, error) {
+       ctrlURL := cluster.Services.Controller.ExternalURL
+       if ctrlURL.Host == "" {
+               return nil, fmt.Errorf("no host in config Services.Controller.ExternalURL: %v", ctrlURL)
+       }
+       return &Client{
+               Scheme:   ctrlURL.Scheme,
+               APIHost:  ctrlURL.Host,
+               Insecure: cluster.TLS.Insecure,
+       }, nil
+}
+
 // NewClientFromEnv creates a new Client that uses the default HTTP
 // client with the API endpoint and credentials given by the
 // ARVADOS_API_* environment variables.
@@ -90,6 +111,7 @@ func NewClientFromEnv() *Client {
                insecure = true
        }
        return &Client{
+               Scheme:          "https",
                APIHost:         os.Getenv("ARVADOS_API_HOST"),
                AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
                Insecure:        insecure,
@@ -102,12 +124,17 @@ 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)
+               reqid, _ := req.Context().Value(contextKeyRequestID{}).(string)
+               if reqid == "" {
+                       reqid, _ = c.context().Value(contextKeyRequestID{}).(string)
+               }
                if reqid == "" {
                        reqid = reqIDGen.Next()
                }
@@ -120,9 +147,22 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
        return c.httpClient().Do(req)
 }
 
+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 {
@@ -133,13 +173,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{"RedirectLocation": 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,
@@ -163,7 +218,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
        }
@@ -173,21 +230,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
@@ -201,6 +266,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(c.context(), 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()
@@ -212,9 +281,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
@@ -228,7 +296,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)
 }
 
@@ -250,13 +326,13 @@ 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.ctx = ContextWithRequestID(cc.context(), reqid)
        return &cc
 }
 
@@ -279,7 +355,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.