X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/c7399ec7afdf0cfdd0f3177f410f102083a26e15..6d95130da47af9fd0290d3c8f80a0364faf74957:/sdk/go/arvados/client.go diff --git a/sdk/go/arvados/client.go b/sdk/go/arvados/client.go index a38d95c2e6..67e3f63117 100644 --- a/sdk/go/arvados/client.go +++ b/sdk/go/arvados/client.go @@ -6,19 +6,21 @@ package arvados import ( "bytes" + "context" "crypto/tls" "encoding/json" "fmt" "io" "io/ioutil" "log" - "math" "net/http" "net/url" "os" "regexp" "strings" "time" + + "git.arvados.org/arvados.git/sdk/go/httpserver" ) // A Client is an HTTP client with an API endpoint and a set of @@ -32,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 @@ -49,7 +54,12 @@ 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 } // The default http.Client used by a Client with Insecure==true and @@ -64,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. @@ -85,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, @@ -92,17 +119,50 @@ func NewClientFromEnv() *Client { } } -// Do adds authentication headers and then calls (*http.Client)Do(). +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, _ := req.Context().Value(contextKeyRequestID{}).(string) + if reqid == "" { + reqid, _ = c.context().Value(contextKeyRequestID{}).(string) + } + if reqid == "" { + reqid = reqIDGen.Next() + } + if req.Header == nil { + req.Header = http.Header{"X-Request-Id": {reqid}} + } else { + req.Header.Set("X-Request-Id", reqid) + } + } 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 { @@ -113,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, @@ -143,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 } @@ -153,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 @@ -181,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() @@ -190,20 +279,32 @@ func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io. if err != nil { return err } - if (method == "GET" || body != nil) && urlValues != nil { - // FIXME: what if params don't fit in URL + if urlValues == nil { + // Nothing to send + } 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 } u.RawQuery = urlValues.Encode() urlString = u.String() + } else { + body = strings.NewReader(urlValues.Encode()) } req, err := http.NewRequest(method, urlString, body) 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) } @@ -225,6 +326,23 @@ func (c *Client) UpdateBody(rsc resource) io.Reader { return bytes.NewBufferString(v.Encode()) } +// 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 = ContextWithRequestID(cc.context(), 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: @@ -237,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. @@ -245,6 +367,7 @@ type DiscoveryDocument struct { BasePath string `json:"basePath"` DefaultCollectionReplication int `json:"defaultCollectionReplication"` BlobSignatureTTL int64 `json:"blobSignatureTtl"` + GitURL string `json:"gitUrl"` Schemas map[string]Schema `json:"schemas"` Resources map[string]Resource `json:"resources"` }