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 }