+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
package arvados
import (
+ "bytes"
+ "context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"io/ioutil"
- "math"
+ "log"
"net/http"
"net/url"
"os"
+ "regexp"
"strings"
"time"
+
+ "git.curoverse.com/arvados.git/sdk/go/httpserver"
)
// A Client is an HTTP client with an API endpoint and a set of
// 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
KeepServiceURIs []string `json:",omitempty"`
dd *DiscoveryDocument
+
+ ctx context.Context
}
// The default http.Client used by a Client with Insecure==true and
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.
func NewClientFromEnv() *Client {
var svcs []string
- if s := os.Getenv("ARVADOS_KEEP_SERVICES"); s != "" {
- svcs = strings.Split(s, " ")
+ for _, s := range strings.Split(os.Getenv("ARVADOS_KEEP_SERVICES"), " ") {
+ if s == "" {
+ continue
+ } else if u, err := url.Parse(s); err != nil {
+ log.Printf("ARVADOS_KEEP_SERVICES: %q: %s", s, err)
+ } else if !u.IsAbs() {
+ log.Printf("ARVADOS_KEEP_SERVICES: %q: not an absolute URI", s)
+ } else {
+ svcs = append(svcs, s)
+ }
+ }
+ var insecure bool
+ if s := strings.ToLower(os.Getenv("ARVADOS_API_HOST_INSECURE")); s == "1" || s == "yes" || s == "true" {
+ insecure = true
}
return &Client{
+ Scheme: "https",
APIHost: os.Getenv("ARVADOS_API_HOST"),
AuthToken: os.Getenv("ARVADOS_API_TOKEN"),
- Insecure: os.Getenv("ARVADOS_API_HOST_INSECURE") != "",
+ Insecure: insecure,
KeepServiceURIs: svcs,
}
}
-// 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)
}
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
}
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
//
// 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()
+ }
urlString := c.apiURL(path)
urlValues, err := anythingToValues(params)
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 method == "GET" || method == "HEAD" || body != nil {
+ // Must send params in query part of URL (FIXME: what
+ // if resulting URL is too long?)
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
}
+ req = req.WithContext(ctx)
+ req.Header.Set("Content-type", "application/x-www-form-urlencoded")
return c.DoAndDecode(dst, req)
}
+type resource interface {
+ resourceName() string
+}
+
+// UpdateBody returns an io.Reader suitable for use as an http.Request
+// Body for a create or update API call.
+func (c *Client) UpdateBody(rsc resource) io.Reader {
+ j, err := json.Marshal(rsc)
+ if err != nil {
+ // Return a reader that returns errors.
+ r, w := io.Pipe()
+ w.CloseWithError(err)
+ return r
+ }
+ v := url.Values{rsc.resourceName(): {string(j)}}
+ 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:
}
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.
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"`
}
return c.dd, nil
}
+var pdhRegexp = regexp.MustCompile(`^[0-9a-f]{32}\+\d+$`)
+
func (c *Client) modelForUUID(dd *DiscoveryDocument, uuid string) (string, error) {
+ if pdhRegexp.MatchString(uuid) {
+ return "Collection", nil
+ }
if len(uuid) != 27 {
return "", fmt.Errorf("invalid UUID: %q", uuid)
}