github.com/saucelabs/saucectl@v0.175.1/internal/http/apitester.go (about) 1 package http 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "net/http" 11 "net/url" 12 "time" 13 14 "github.com/hashicorp/go-retryablehttp" 15 "github.com/saucelabs/saucectl/internal/apitest" 16 "github.com/saucelabs/saucectl/internal/multipartext" 17 "golang.org/x/time/rate" 18 19 "github.com/saucelabs/saucectl/internal/config" 20 "github.com/saucelabs/saucectl/internal/msg" 21 ) 22 23 // Query rate is queryRequestRate per second. 24 var queryRequestRate = 1 25 var rateLimitTokenBucket = 10 26 27 // APITester describes an interface to the api-testing rest endpoints. 28 type APITester struct { 29 HTTPClient *retryablehttp.Client 30 URL string 31 Username string 32 AccessKey string 33 RequestRateLimiter *rate.Limiter 34 } 35 36 // PublishedTest describes a published test. 37 type PublishedTest struct { 38 Published apitest.Test 39 } 40 41 // VaultErrResponse describes the response when a malformed Vault is unable to be parsed 42 type VaultErrResponse struct { 43 Message struct { 44 Errors []vaultErr `json:"errors,omitempty"` 45 } `json:"message,omitempty"` 46 Status string `json:"status,omitempty"` 47 } 48 49 // DriveErrResponse describes the response when drive API returns an error. 50 type DriveErrResponse struct { 51 Error string `json:"error"` 52 Message string `json:"message"` 53 } 54 55 type vaultErr struct { 56 Field string `json:"field,omitempty"` 57 Message string `json:"message,omitempty"` 58 Object string `json:"object,omitempty"` 59 RejectedValue []apitest.VaultVariable `json:"rejected-value,omitempty"` 60 } 61 62 type vaultFileDeletion struct { 63 FileNames []string `json:"fileNames"` 64 } 65 66 // NewAPITester a new instance of APITester. 67 func NewAPITester(url string, username string, accessKey string, timeout time.Duration) APITester { 68 return APITester{ 69 HTTPClient: NewRetryableClient(timeout), 70 URL: url, 71 Username: username, 72 AccessKey: accessKey, 73 RequestRateLimiter: rate.NewLimiter(rate.Every(time.Duration(1/queryRequestRate)*time.Second), rateLimitTokenBucket), 74 } 75 } 76 77 // GetProject returns Project metadata for a given hookID. 78 func (c *APITester) GetProject(ctx context.Context, hookID string) (apitest.ProjectMeta, error) { 79 url := fmt.Sprintf("%s/api-testing/rest/v4/%s", c.URL, hookID) 80 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) 81 if err != nil { 82 return apitest.ProjectMeta{}, err 83 } 84 85 req.SetBasicAuth(c.Username, c.AccessKey) 86 resp, err := c.HTTPClient.Do(req) 87 if err != nil { 88 return apitest.ProjectMeta{}, err 89 } 90 defer resp.Body.Close() 91 92 if resp.StatusCode >= http.StatusInternalServerError { 93 return apitest.ProjectMeta{}, errors.New(msg.InternalServerError) 94 } 95 96 if resp.StatusCode != http.StatusOK { 97 body, _ := io.ReadAll(resp.Body) 98 return apitest.ProjectMeta{}, fmt.Errorf("request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body)) 99 } 100 101 var project apitest.ProjectMeta 102 if err := json.NewDecoder(resp.Body).Decode(&project); err != nil { 103 return project, err 104 } 105 return project, nil 106 } 107 108 func (c *APITester) GetEventResult(ctx context.Context, hookID string, eventID string) (apitest.TestResult, error) { 109 if err := c.RequestRateLimiter.Wait(ctx); err != nil { 110 return apitest.TestResult{}, err 111 } 112 113 url := fmt.Sprintf("%s/api-testing/rest/v4/%s/insights/events/%s", c.URL, hookID, eventID) 114 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) 115 if err != nil { 116 return apitest.TestResult{}, err 117 } 118 req.SetBasicAuth(c.Username, c.AccessKey) 119 resp, err := c.HTTPClient.Do(req) 120 if err != nil { 121 return apitest.TestResult{}, err 122 } 123 if resp.StatusCode >= http.StatusInternalServerError { 124 return apitest.TestResult{}, errors.New(msg.InternalServerError) 125 } 126 // 404 needs to be treated differently to ensure calling parent is aware of the specific error. 127 // API replies 404 until the event is fully processed. 128 if resp.StatusCode == http.StatusNotFound { 129 return apitest.TestResult{}, apitest.ErrEventNotFound 130 } 131 if resp.StatusCode != http.StatusOK { 132 body, _ := io.ReadAll(resp.Body) 133 return apitest.TestResult{}, fmt.Errorf("request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body)) 134 } 135 var testResult apitest.TestResult 136 if err := json.NewDecoder(resp.Body).Decode(&testResult); err != nil { 137 return testResult, err 138 } 139 return testResult, nil 140 } 141 142 func (c *APITester) GetTest(ctx context.Context, hookID string, testID string) (apitest.Test, error) { 143 url := fmt.Sprintf("%s/api-testing/rest/v4/%s/tests/%s", c.URL, hookID, testID) 144 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) 145 if err != nil { 146 return apitest.Test{}, err 147 } 148 149 req.SetBasicAuth(c.Username, c.AccessKey) 150 resp, err := c.HTTPClient.Do(req) 151 if err != nil { 152 return apitest.Test{}, err 153 } 154 defer resp.Body.Close() 155 156 if resp.StatusCode >= http.StatusInternalServerError { 157 return apitest.Test{}, errors.New(msg.InternalServerError) 158 } 159 160 if resp.StatusCode != http.StatusOK { 161 body, _ := io.ReadAll(resp.Body) 162 return apitest.Test{}, fmt.Errorf("request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body)) 163 } 164 165 var test PublishedTest 166 if err := json.NewDecoder(resp.Body).Decode(&test); err != nil { 167 return test.Published, err 168 } 169 return test.Published, nil 170 } 171 172 func (c *APITester) composeURL(path string, buildID string, format string, tunnel config.Tunnel, taskID string) string { 173 // NOTE: API url is not user provided so skip error check 174 url, _ := url.Parse(c.URL) 175 url.Path = path 176 177 query := url.Query() 178 if buildID != "" { 179 query.Set("buildId", buildID) 180 } 181 if format != "" { 182 query.Set("format", format) 183 } 184 185 if tunnel.Name != "" { 186 var t string 187 if tunnel.Owner != "" { 188 t = fmt.Sprintf("%s:%s", tunnel.Owner, tunnel.Name) 189 } else { 190 t = fmt.Sprintf("%s:%s", c.Username, tunnel.Name) 191 } 192 193 query.Set("tunnelId", t) 194 } 195 196 if taskID != "" { 197 query.Set("taskId", taskID) 198 } 199 200 url.RawQuery = query.Encode() 201 202 return url.String() 203 } 204 205 // GetProjects returns the list of Project available. 206 func (c *APITester) GetProjects(ctx context.Context) ([]apitest.ProjectMeta, error) { 207 url := fmt.Sprintf("%s/api-testing/api/project", c.URL) 208 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) 209 if err != nil { 210 return []apitest.ProjectMeta{}, err 211 } 212 213 req.SetBasicAuth(c.Username, c.AccessKey) 214 resp, err := c.HTTPClient.Do(req) 215 if err != nil { 216 return []apitest.ProjectMeta{}, err 217 } 218 defer resp.Body.Close() 219 220 if resp.StatusCode >= http.StatusInternalServerError { 221 return []apitest.ProjectMeta{}, errors.New(msg.InternalServerError) 222 } 223 224 if resp.StatusCode != http.StatusOK { 225 body, _ := io.ReadAll(resp.Body) 226 return []apitest.ProjectMeta{}, fmt.Errorf("request failed; unexpected response code:'%d', msg:'%s'", resp.StatusCode, body) 227 } 228 229 var projects []apitest.ProjectMeta 230 if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil { 231 return projects, err 232 } 233 return projects, nil 234 } 235 236 // GetHooks returns the list of hooks available. 237 func (c *APITester) GetHooks(ctx context.Context, projectID string) ([]apitest.Hook, error) { 238 url := fmt.Sprintf("%s/api-testing/api/project/%s/hook", c.URL, projectID) 239 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) 240 if err != nil { 241 return []apitest.Hook{}, err 242 } 243 244 req.SetBasicAuth(c.Username, c.AccessKey) 245 resp, err := c.HTTPClient.Do(req) 246 if err != nil { 247 return []apitest.Hook{}, err 248 } 249 defer resp.Body.Close() 250 251 if resp.StatusCode >= http.StatusInternalServerError { 252 return []apitest.Hook{}, errors.New(msg.InternalServerError) 253 } 254 255 if resp.StatusCode != http.StatusOK { 256 body, _ := io.ReadAll(resp.Body) 257 return []apitest.Hook{}, fmt.Errorf("request failed; unexpected response code:'%d', msg:'%s'", resp.StatusCode, body) 258 } 259 260 var hooks []apitest.Hook 261 if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil { 262 return hooks, err 263 } 264 return hooks, nil 265 } 266 267 // RunAllAsync runs all the tests for the project described by hookID and returns without waiting for their results. 268 func (c *APITester) RunAllAsync(ctx context.Context, hookID string, buildID string, tunnel config.Tunnel, test apitest.TestRequest) (apitest.AsyncResponse, error) { 269 url := c.composeURL(fmt.Sprintf("/api-testing/rest/v4/%s/tests/_run-all", hookID), buildID, "", tunnel, "") 270 271 payload, err := json.Marshal(test) 272 if err != nil { 273 return apitest.AsyncResponse{}, err 274 } 275 payloadReader := bytes.NewReader(payload) 276 277 req, err := NewRetryableRequestWithContext(ctx, http.MethodPost, url, payloadReader) 278 if err != nil { 279 return apitest.AsyncResponse{}, err 280 } 281 282 req.SetBasicAuth(c.Username, c.AccessKey) 283 284 resp, err := c.doAsyncRun(c.HTTPClient, req) 285 if err != nil { 286 return apitest.AsyncResponse{}, err 287 } 288 return resp, nil 289 } 290 291 // RunEphemeralAsync runs the tests for the project described by hookID and returns without waiting for their results. 292 func (c *APITester) RunEphemeralAsync(ctx context.Context, hookID string, buildID string, tunnel config.Tunnel, taskID string, test apitest.TestRequest) (apitest.AsyncResponse, error) { 293 url := c.composeURL(fmt.Sprintf("/api-testing/rest/v4/%s/tests/_exec", hookID), buildID, "", tunnel, "") 294 295 payload, err := json.Marshal(test) 296 if err != nil { 297 return apitest.AsyncResponse{}, err 298 } 299 payloadReader := bytes.NewReader(payload) 300 301 req, err := NewRetryableRequestWithContext(ctx, http.MethodPost, url, payloadReader) 302 if err != nil { 303 return apitest.AsyncResponse{}, err 304 } 305 306 req.SetBasicAuth(c.Username, c.AccessKey) 307 308 resp, err := c.doAsyncRun(c.HTTPClient, req) 309 if err != nil { 310 return apitest.AsyncResponse{}, err 311 } 312 return resp, nil 313 } 314 315 // RunTestAsync runs a single test described by testID for the project described by hookID and returns without waiting for results. 316 func (c *APITester) RunTestAsync(ctx context.Context, hookID string, testID string, buildID string, tunnel config.Tunnel, test apitest.TestRequest) (apitest.AsyncResponse, error) { 317 url := c.composeURL(fmt.Sprintf("/api-testing/rest/v4/%s/tests/%s/_run", hookID, testID), buildID, "", tunnel, "") 318 319 payload, err := json.Marshal(test) 320 if err != nil { 321 return apitest.AsyncResponse{}, err 322 } 323 payloadReader := bytes.NewReader(payload) 324 325 req, err := NewRetryableRequestWithContext(ctx, http.MethodPost, url, payloadReader) 326 if err != nil { 327 return apitest.AsyncResponse{}, err 328 } 329 330 req.SetBasicAuth(c.Username, c.AccessKey) 331 332 resp, err := c.doAsyncRun(c.HTTPClient, req) 333 if err != nil { 334 return apitest.AsyncResponse{}, err 335 } 336 337 return resp, nil 338 } 339 340 // RunTagAsync runs all the tests for a testTag for a project described by hookID and returns without waiting for results. 341 func (c *APITester) RunTagAsync(ctx context.Context, hookID string, testTag string, buildID string, tunnel config.Tunnel, test apitest.TestRequest) (apitest.AsyncResponse, error) { 342 url := c.composeURL(fmt.Sprintf("/api-testing/rest/v4/%s/tests/_tag/%s/_run", hookID, testTag), buildID, "", tunnel, "") 343 344 payload, err := json.Marshal(test) 345 if err != nil { 346 return apitest.AsyncResponse{}, err 347 } 348 payloadReader := bytes.NewReader(payload) 349 350 req, err := NewRetryableRequestWithContext(ctx, http.MethodPost, url, payloadReader) 351 if err != nil { 352 return apitest.AsyncResponse{}, err 353 } 354 355 req.SetBasicAuth(c.Username, c.AccessKey) 356 357 resp, err := c.doAsyncRun(c.HTTPClient, req) 358 if err != nil { 359 return apitest.AsyncResponse{}, err 360 } 361 return resp, nil 362 } 363 364 func (c *APITester) doAsyncRun(client *retryablehttp.Client, request *retryablehttp.Request) (apitest.AsyncResponse, error) { 365 request.Header.Set("Content-Type", "application/json") 366 367 resp, err := client.Do(request) 368 if err != nil { 369 return apitest.AsyncResponse{}, err 370 } 371 defer resp.Body.Close() 372 373 if resp.StatusCode >= http.StatusInternalServerError { 374 return apitest.AsyncResponse{}, errors.New(msg.InternalServerError) 375 } 376 377 if resp.StatusCode != http.StatusOK { 378 body, _ := io.ReadAll(resp.Body) 379 return apitest.AsyncResponse{}, fmt.Errorf("test execution failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body)) 380 } 381 382 var asyncResponse apitest.AsyncResponse 383 if err := json.NewDecoder(resp.Body).Decode(&asyncResponse); err != nil { 384 return apitest.AsyncResponse{}, err 385 } 386 387 return asyncResponse, nil 388 } 389 390 // GetVault returns the vault for the project identified by hookID 391 func (c *APITester) GetVault(ctx context.Context, hookID string) (apitest.Vault, error) { 392 url := fmt.Sprintf("%s/api-testing/rest/v4/%s/vault", c.URL, hookID) 393 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) 394 if err != nil { 395 return apitest.Vault{}, err 396 } 397 398 req.SetBasicAuth(c.Username, c.AccessKey) 399 resp, err := c.HTTPClient.Do(req) 400 if err != nil { 401 return apitest.Vault{}, err 402 } 403 defer resp.Body.Close() 404 405 if resp.StatusCode >= http.StatusInternalServerError { 406 return apitest.Vault{}, ErrServerError 407 } 408 409 if resp.StatusCode != http.StatusOK { 410 body, _ := io.ReadAll(resp.Body) 411 return apitest.Vault{}, fmt.Errorf("request failed; unexpected response code:'%d', msg:'%s'", resp.StatusCode, body) 412 } 413 414 var vaultResponse apitest.Vault 415 if err := json.NewDecoder(resp.Body).Decode(&vaultResponse); err != nil { 416 return apitest.Vault{}, err 417 } 418 419 return vaultResponse, nil 420 } 421 422 func (c *APITester) PutVault(ctx context.Context, hookID string, vault apitest.Vault) error { 423 url := fmt.Sprintf("%s/api-testing/rest/v4/%s/vault", c.URL, hookID) 424 425 var b bytes.Buffer 426 err := json.NewEncoder(&b).Encode(vault) 427 if err != nil { 428 return err 429 } 430 431 req, err := NewRetryableRequestWithContext(ctx, http.MethodPut, url, &b) 432 if err != nil { 433 return err 434 } 435 436 req.Header.Set("Content-Type", "application/json") 437 req.SetBasicAuth(c.Username, c.AccessKey) 438 439 resp, err := c.HTTPClient.Do(req) 440 if err != nil { 441 return err 442 } 443 defer resp.Body.Close() 444 445 if resp.StatusCode >= http.StatusInternalServerError { 446 return ErrServerError 447 } 448 449 if resp.StatusCode != http.StatusOK { 450 body, _ := io.ReadAll(resp.Body) 451 var errResp VaultErrResponse 452 if err = json.Unmarshal(body, &errResp); err != nil { 453 return fmt.Errorf("request failed; unexpected response code:'%d'; body: %q", resp.StatusCode, body) 454 } 455 456 return fmt.Errorf("request failed; unexpected response code: '%d'; err: '%v'", resp.StatusCode, errResp) 457 } 458 459 return nil 460 } 461 462 // ListVaultFiles returns the list of files in the vault for the project identified by projectID 463 func (c *APITester) ListVaultFiles(ctx context.Context, projectID string) ([]apitest.VaultFile, error) { 464 filesURL := fmt.Sprintf("%s/api-testing/api/project/%s/drive/files", c.URL, projectID) 465 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, filesURL, nil) 466 if err != nil { 467 return []apitest.VaultFile{}, err 468 } 469 470 req.SetBasicAuth(c.Username, c.AccessKey) 471 resp, err := c.HTTPClient.Do(req) 472 if err != nil { 473 return []apitest.VaultFile{}, err 474 } 475 defer resp.Body.Close() 476 477 if resp.StatusCode >= http.StatusInternalServerError { 478 return []apitest.VaultFile{}, ErrServerError 479 } 480 481 if resp.StatusCode != http.StatusOK { 482 return []apitest.VaultFile{}, createError(resp.StatusCode, resp.Body) 483 } 484 485 var vaultResponse []apitest.VaultFile 486 if err := json.NewDecoder(resp.Body).Decode(&vaultResponse); err != nil { 487 return []apitest.VaultFile{}, err 488 } 489 490 return vaultResponse, nil 491 } 492 493 // GetVaultFileContent returns the content of a file in the vault for the project identified by projectID 494 func (c *APITester) GetVaultFileContent(ctx context.Context, projectID string, fileID string) (io.ReadCloser, error) { 495 filesURL := fmt.Sprintf("%s/api-testing/api/project/%s/drive/files/%s", c.URL, projectID, fileID) 496 req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, filesURL, nil) 497 if err != nil { 498 return nil, err 499 } 500 501 req.SetBasicAuth(c.Username, c.AccessKey) 502 resp, err := c.HTTPClient.Do(req) 503 if err != nil { 504 return nil, err 505 } 506 507 if resp.StatusCode >= http.StatusInternalServerError { 508 return nil, ErrServerError 509 } 510 511 if resp.StatusCode != http.StatusOK { 512 return nil, createError(resp.StatusCode, resp.Body) 513 } 514 return resp.Body, nil 515 } 516 517 // PutVaultFile stores the content of a file in the vault for the project identified by projectID 518 func (c *APITester) PutVaultFile(ctx context.Context, projectID string, fileName string, fileBody io.ReadCloser) (apitest.VaultFile, error) { 519 multipartReader, contentType, err := multipartext.NewMultipartReader("file", fileName, "", fileBody) 520 if err != nil { 521 return apitest.VaultFile{}, nil 522 } 523 524 filesURL := fmt.Sprintf("%s/api-testing/api/project/%s/drive/files", c.URL, projectID) 525 req, err := NewRetryableRequestWithContext(ctx, http.MethodPost, filesURL, multipartReader) 526 if err != nil { 527 return apitest.VaultFile{}, err 528 } 529 530 req.Header.Set("Content-Type", contentType) 531 req.SetBasicAuth(c.Username, c.AccessKey) 532 resp, err := c.HTTPClient.Do(req) 533 if err != nil { 534 return apitest.VaultFile{}, err 535 } 536 537 if resp.StatusCode >= http.StatusInternalServerError { 538 return apitest.VaultFile{}, ErrServerError 539 } 540 541 if resp.StatusCode != http.StatusOK { 542 return apitest.VaultFile{}, createError(resp.StatusCode, resp.Body) 543 } 544 545 var vaultResponse apitest.VaultFile 546 if err := json.NewDecoder(resp.Body).Decode(&vaultResponse); err != nil { 547 return apitest.VaultFile{}, err 548 } 549 550 return vaultResponse, nil 551 } 552 553 // DeleteVaultFile delete the files in the vault for the project identified by projectID 554 func (c *APITester) DeleteVaultFile(ctx context.Context, projectID string, fileNames []string) error { 555 filesURL := fmt.Sprintf("%s/api-testing/api/project/%s/drive/files/_delete", c.URL, projectID) 556 557 payload, err := json.Marshal(vaultFileDeletion{ 558 FileNames: fileNames, 559 }) 560 if err != nil { 561 return err 562 } 563 564 req, err := NewRetryableRequestWithContext(ctx, http.MethodPost, filesURL, bytes.NewReader(payload)) 565 if err != nil { 566 return err 567 } 568 req.Header.Set("Content-Type", "application/json") 569 req.SetBasicAuth(c.Username, c.AccessKey) 570 resp, err := c.HTTPClient.Do(req) 571 if err != nil { 572 return err 573 } 574 575 if resp.StatusCode >= http.StatusInternalServerError { 576 return ErrServerError 577 } 578 579 if resp.StatusCode != http.StatusOK { 580 return createError(resp.StatusCode, resp.Body) 581 } 582 return nil 583 } 584 585 func createError(statusCode int, body io.Reader) error { 586 content, _ := io.ReadAll(body) 587 588 var errorDetails DriveErrResponse 589 if err := json.Unmarshal(content, &errorDetails); err != nil || errorDetails.Message == "" { 590 return fmt.Errorf("request failed; unexpected response code:'%d', body:'%s'", statusCode, content) 591 } 592 return fmt.Errorf("request failed; unexpected response code:'%d', msg:'%s'", statusCode, errorDetails.Message) 593 }