github.com/vmware/govmomi@v0.51.0/vapi/rest/client.go (about)

     1  // © Broadcom. All Rights Reserved.
     2  // The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package rest
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"encoding/json"
    11  	"fmt"
    12  	"io"
    13  	"mime"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	"github.com/vmware/govmomi/vapi/internal"
    22  	"github.com/vmware/govmomi/vim25"
    23  	"github.com/vmware/govmomi/vim25/soap"
    24  	"github.com/vmware/govmomi/vim25/types"
    25  )
    26  
    27  // Client extends soap.Client to support JSON encoding, while inheriting security features, debug tracing and session persistence.
    28  type Client struct {
    29  	mu sync.Mutex
    30  
    31  	*soap.Client
    32  	sessionID string
    33  }
    34  
    35  // Session information
    36  type Session struct {
    37  	User         string    `json:"user"`
    38  	Created      time.Time `json:"created_time"`
    39  	LastAccessed time.Time `json:"last_accessed_time"`
    40  }
    41  
    42  // LocalizableMessage represents a localizable error
    43  type LocalizableMessage struct {
    44  	Args           []string `json:"args,omitempty"`
    45  	DefaultMessage string   `json:"default_message,omitempty"`
    46  	ID             string   `json:"id,omitempty"`
    47  }
    48  
    49  func (m *LocalizableMessage) Error() string {
    50  	return m.DefaultMessage
    51  }
    52  
    53  // NewClient creates a new Client instance.
    54  func NewClient(c *vim25.Client) *Client {
    55  	sc := c.Client.NewServiceClient(Path, "")
    56  
    57  	return &Client{Client: sc}
    58  }
    59  
    60  // SessionID is set by calling Login() or optionally with the given id param
    61  func (c *Client) SessionID(id ...string) string {
    62  	c.mu.Lock()
    63  	defer c.mu.Unlock()
    64  	if len(id) != 0 {
    65  		c.sessionID = id[0]
    66  	}
    67  	return c.sessionID
    68  }
    69  
    70  type marshaledClient struct {
    71  	SoapClient *soap.Client
    72  	SessionID  string
    73  }
    74  
    75  func (c *Client) MarshalJSON() ([]byte, error) {
    76  	m := marshaledClient{
    77  		SoapClient: c.Client,
    78  		SessionID:  c.sessionID,
    79  	}
    80  
    81  	return json.Marshal(m)
    82  }
    83  
    84  func (c *Client) UnmarshalJSON(b []byte) error {
    85  	var m marshaledClient
    86  
    87  	err := json.Unmarshal(b, &m)
    88  	if err != nil {
    89  		return err
    90  	}
    91  
    92  	*c = Client{
    93  		Client:    m.SoapClient,
    94  		sessionID: m.SessionID,
    95  	}
    96  
    97  	return nil
    98  }
    99  
   100  // isAPI returns true if path starts with "/api"
   101  // This hack allows helpers to support both endpoints:
   102  // "/rest" - value wrapped responses and structured error responses
   103  // "/api" - raw responses and no structured error responses
   104  func isAPI(path string) bool {
   105  	return strings.HasPrefix(path, "/api")
   106  }
   107  
   108  // Resource helper for the given path.
   109  func (c *Client) Resource(path string) *Resource {
   110  	r := &Resource{u: c.URL()}
   111  	if !isAPI(path) {
   112  		path = Path + path
   113  	}
   114  	r.u.Path = path
   115  	return r
   116  }
   117  
   118  type Signer interface {
   119  	SignRequest(*http.Request) error
   120  }
   121  
   122  type signerContext struct{}
   123  
   124  func (c *Client) WithSigner(ctx context.Context, s Signer) context.Context {
   125  	return context.WithValue(ctx, signerContext{}, s)
   126  }
   127  
   128  type headersContext struct{}
   129  
   130  // WithHeader returns a new Context populated with the provided headers map.
   131  // Calls to a VAPI REST client with this context will populate the HTTP headers
   132  // map using the provided headers.
   133  func (c *Client) WithHeader(
   134  	ctx context.Context,
   135  	headers http.Header) context.Context {
   136  
   137  	return context.WithValue(ctx, headersContext{}, headers)
   138  }
   139  
   140  type statusError struct {
   141  	res *http.Response
   142  }
   143  
   144  func (e *statusError) Error() string {
   145  	return fmt.Sprintf("%s %s: %s", e.res.Request.Method, e.res.Request.URL, e.res.Status)
   146  }
   147  
   148  func IsStatusError(err error, code int) bool {
   149  	statusErr, ok := err.(*statusError)
   150  	if !ok || statusErr == nil || statusErr.res == nil {
   151  		return false
   152  	}
   153  	return statusErr.res.StatusCode == code
   154  }
   155  
   156  // RawResponse may be used with the Do method as the resBody argument in order
   157  // to capture the raw response data.
   158  type RawResponse struct {
   159  	bytes.Buffer
   160  }
   161  
   162  // Do sends the http.Request, decoding resBody if provided.
   163  func (c *Client) Do(ctx context.Context, req *http.Request, resBody any) error {
   164  	switch req.Method {
   165  	case http.MethodPost, http.MethodPatch, http.MethodPut:
   166  		req.Header.Set("Content-Type", "application/json")
   167  	}
   168  
   169  	req.Header.Set("Accept", "application/json")
   170  
   171  	if id := c.SessionID(); id != "" {
   172  		req.Header.Set(internal.SessionCookieName, id)
   173  	}
   174  
   175  	if s, ok := ctx.Value(signerContext{}).(Signer); ok {
   176  		if err := s.SignRequest(req); err != nil {
   177  			return err
   178  		}
   179  	}
   180  
   181  	// OperationID (see soap.Client.soapRoundTrip)
   182  	if id, ok := ctx.Value(types.ID{}).(string); ok {
   183  		req.Header.Add("X-Request-ID", id)
   184  	}
   185  
   186  	if headers, ok := ctx.Value(headersContext{}).(http.Header); ok {
   187  		for k, v := range headers {
   188  			for _, v := range v {
   189  				req.Header.Add(k, v)
   190  			}
   191  		}
   192  	}
   193  
   194  	return c.Client.Do(ctx, req, func(res *http.Response) error {
   195  		switch res.StatusCode {
   196  		case http.StatusOK:
   197  		case http.StatusCreated:
   198  		case http.StatusAccepted:
   199  		case http.StatusNoContent:
   200  		case http.StatusBadRequest:
   201  			// TODO: structured error types
   202  			detail, err := io.ReadAll(res.Body)
   203  			if err != nil {
   204  				return err
   205  			}
   206  			return fmt.Errorf("%s: %s", res.Status, bytes.TrimSpace(detail))
   207  		default:
   208  			return &statusError{res}
   209  		}
   210  
   211  		if resBody == nil {
   212  			return nil
   213  		}
   214  
   215  		switch b := resBody.(type) {
   216  		case *RawResponse:
   217  			return res.Write(b)
   218  		case io.Writer:
   219  			_, err := io.Copy(b, res.Body)
   220  			return err
   221  		default:
   222  			d := json.NewDecoder(res.Body)
   223  			if isAPI(req.URL.Path) {
   224  				// Responses from the /api endpoint are not wrapped
   225  				return d.Decode(resBody)
   226  			}
   227  			// Responses from the /rest endpoint are wrapped in this structure
   228  			val := struct {
   229  				Value any `json:"value,omitempty"`
   230  			}{
   231  				resBody,
   232  			}
   233  			return d.Decode(&val)
   234  		}
   235  	})
   236  }
   237  
   238  // authHeaders ensures the given map contains a REST auth header
   239  func (c *Client) authHeaders(h map[string]string) map[string]string {
   240  	if _, exists := h[internal.SessionCookieName]; exists {
   241  		return h
   242  	}
   243  	if h == nil {
   244  		h = make(map[string]string)
   245  	}
   246  
   247  	h[internal.SessionCookieName] = c.SessionID()
   248  
   249  	return h
   250  }
   251  
   252  // Download wraps soap.Client.Download, adding the REST authentication header
   253  func (c *Client) Download(ctx context.Context, u *url.URL, param *soap.Download) (io.ReadCloser, int64, error) {
   254  	p := *param
   255  	p.Headers = c.authHeaders(p.Headers)
   256  	return c.Client.Download(ctx, u, &p)
   257  }
   258  
   259  // DownloadFile wraps soap.Client.DownloadFile, adding the REST authentication header
   260  func (c *Client) DownloadFile(ctx context.Context, file string, u *url.URL, param *soap.Download) error {
   261  	p := *param
   262  	p.Headers = c.authHeaders(p.Headers)
   263  	return c.Client.DownloadFile(ctx, file, u, &p)
   264  }
   265  
   266  // DownloadAttachment writes the response to given filename, defaulting to Content-Disposition filename in the response.
   267  // A filename of "-" writes the response to stdout.
   268  func (c *Client) DownloadAttachment(ctx context.Context, req *http.Request, filename string) error {
   269  	return c.Client.Do(ctx, req, func(res *http.Response) error {
   270  		if filename == "" {
   271  			d := res.Header.Get("Content-Disposition")
   272  			_, params, err := mime.ParseMediaType(d)
   273  			if err == nil {
   274  				filename = params["filename"]
   275  			}
   276  		}
   277  
   278  		var w io.Writer
   279  
   280  		if filename == "-" {
   281  			w = os.Stdout
   282  		} else {
   283  			f, err := os.Create(filename)
   284  			if err != nil {
   285  				return err
   286  			}
   287  			defer f.Close()
   288  			w = f
   289  		}
   290  
   291  		_, err := io.Copy(w, res.Body)
   292  		return err
   293  	})
   294  }
   295  
   296  // Upload wraps soap.Client.Upload, adding the REST authentication header
   297  func (c *Client) Upload(ctx context.Context, f io.Reader, u *url.URL, param *soap.Upload) error {
   298  	p := *param
   299  	p.Headers = c.authHeaders(p.Headers)
   300  	return c.Client.Upload(ctx, f, u, &p)
   301  }
   302  
   303  // Login creates a new session via Basic Authentication with the given url.Userinfo.
   304  func (c *Client) Login(ctx context.Context, user *url.Userinfo) error {
   305  	req := c.Resource(internal.SessionPath).Request(http.MethodPost)
   306  
   307  	req.Header.Set(internal.UseHeaderAuthn, "true")
   308  
   309  	if user != nil {
   310  		if password, ok := user.Password(); ok {
   311  			req.SetBasicAuth(user.Username(), password)
   312  		}
   313  	}
   314  
   315  	var id string
   316  	err := c.Do(ctx, req, &id)
   317  	if err != nil {
   318  		return err
   319  	}
   320  
   321  	c.SessionID(id)
   322  
   323  	return nil
   324  }
   325  
   326  func (c *Client) LoginByToken(ctx context.Context) error {
   327  	return c.Login(ctx, nil)
   328  }
   329  
   330  // Session returns the user's current session.
   331  // Nil is returned if the session is not authenticated.
   332  func (c *Client) Session(ctx context.Context) (*Session, error) {
   333  	var s Session
   334  	req := c.Resource(internal.SessionPath).WithAction("get").Request(http.MethodPost)
   335  	err := c.Do(ctx, req, &s)
   336  	if err != nil {
   337  		if e, ok := err.(*statusError); ok {
   338  			if e.res.StatusCode == http.StatusUnauthorized {
   339  				return nil, nil
   340  			}
   341  		}
   342  		return nil, err
   343  	}
   344  	return &s, nil
   345  }
   346  
   347  // Logout deletes the current session.
   348  func (c *Client) Logout(ctx context.Context) error {
   349  	req := c.Resource(internal.SessionPath).Request(http.MethodDelete)
   350  	return c.Do(ctx, req, nil)
   351  }
   352  
   353  // Valid returns whether or not the client is valid and ready for use.
   354  // This should be called after unmarshalling the client.
   355  func (c *Client) Valid() bool {
   356  	if c == nil {
   357  		return false
   358  	}
   359  
   360  	if c.Client == nil {
   361  		return false
   362  	}
   363  
   364  	return true
   365  }
   366  
   367  // Path returns rest.Path (see cache.Client)
   368  func (c *Client) Path() string {
   369  	return Path
   370  }