github.com/saucelabs/saucectl@v0.175.1/internal/http/testcomposer.go (about)

     1  package http
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"mime/multipart"
    10  	"net/http"
    11  	"net/textproto"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/saucelabs/saucectl/internal/framework"
    16  	"github.com/saucelabs/saucectl/internal/iam"
    17  )
    18  
    19  // TestComposer service
    20  type TestComposer struct {
    21  	HTTPClient  *http.Client
    22  	URL         string // e.g.) https://api.<region>.saucelabs.net
    23  	Credentials iam.Credentials
    24  }
    25  
    26  // FrameworkResponse represents the response body for framework information.
    27  type FrameworkResponse struct {
    28  	Name        string    `json:"name"`
    29  	Version     string    `json:"version"`
    30  	EOLDate     time.Time `json:"eolDate"`
    31  	RemovalDate time.Time `json:"removalDate"`
    32  	Runner      runner    `json:"runner"`
    33  	Platforms   []struct {
    34  		Name     string
    35  		Browsers []string
    36  	} `json:"platforms"`
    37  	BrowserDefaults map[string]string `json:"browserDefaults"`
    38  }
    39  
    40  // TokenResponse represents the response body for slack token.
    41  type TokenResponse struct {
    42  	Token string `json:"token"`
    43  }
    44  
    45  type runner struct {
    46  	CloudRunnerVersion string `json:"cloudRunnerVersion"`
    47  	DockerImage        string `json:"dockerImage"`
    48  	GitRelease         string `json:"gitRelease"`
    49  }
    50  
    51  func NewTestComposer(url string, creds iam.Credentials, timeout time.Duration) TestComposer {
    52  	return TestComposer{
    53  		HTTPClient: &http.Client{
    54  			Timeout:   timeout,
    55  			Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
    56  		},
    57  		URL:         url,
    58  		Credentials: creds,
    59  	}
    60  }
    61  
    62  // GetSlackToken gets slack token.
    63  func (c *TestComposer) GetSlackToken(ctx context.Context) (string, error) {
    64  	url := fmt.Sprintf("%s/v1/testcomposer/users/%s/settings/slack", c.URL, c.Credentials.Username)
    65  
    66  	req, err := NewRequestWithContext(ctx, http.MethodGet, url, nil)
    67  	if err != nil {
    68  		return "", err
    69  	}
    70  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
    71  
    72  	var resp TokenResponse
    73  	if err := c.doJSONResponse(req, 200, &resp); err != nil {
    74  		return "", err
    75  	}
    76  
    77  	return resp.Token, nil
    78  }
    79  
    80  func (c *TestComposer) doJSONResponse(req *http.Request, expectStatus int, v interface{}) error {
    81  	res, err := c.HTTPClient.Do(req)
    82  	if err != nil {
    83  		return err
    84  	}
    85  	defer res.Body.Close()
    86  
    87  	if res.StatusCode != expectStatus {
    88  		body, _ := io.ReadAll(res.Body)
    89  		return fmt.Errorf("unexpected status '%d' from test-composer: %s", res.StatusCode, body)
    90  	}
    91  
    92  	return json.NewDecoder(res.Body).Decode(v)
    93  }
    94  
    95  // UploadAsset uploads an asset to the specified jobID.
    96  func (c *TestComposer) UploadAsset(jobID string, realDevice bool, fileName string, contentType string, content []byte) error {
    97  	var b bytes.Buffer
    98  	w := multipart.NewWriter(&b)
    99  	h := make(textproto.MIMEHeader)
   100  	h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", fileName))
   101  	h.Set("Content-Type", contentType)
   102  	wr, err := w.CreatePart(h)
   103  	if err != nil {
   104  		return err
   105  	}
   106  	if _, err = wr.Write(content); err != nil {
   107  		return err
   108  	}
   109  	if err = w.Close(); err != nil {
   110  		return err
   111  	}
   112  
   113  	req, err := NewRequestWithContext(context.Background(), http.MethodPut,
   114  		fmt.Sprintf("%s/v1/testcomposer/jobs/%s/assets", c.URL, jobID), &b)
   115  	if err != nil {
   116  		return err
   117  	}
   118  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   119  	req.Header.Set("Content-Type", w.FormDataContentType())
   120  
   121  	resp, err := c.HTTPClient.Do(req)
   122  	if err != nil {
   123  		return err
   124  	}
   125  	defer resp.Body.Close()
   126  
   127  	if resp.StatusCode >= http.StatusInternalServerError {
   128  		return ErrServerError
   129  	}
   130  
   131  	if resp.StatusCode == http.StatusNotFound {
   132  		return ErrJobNotFound
   133  	}
   134  
   135  	if resp.StatusCode != http.StatusOK {
   136  		body, _ := io.ReadAll(resp.Body)
   137  		return fmt.Errorf("assets upload request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body))
   138  	}
   139  
   140  	var assetsResponse struct {
   141  		Uploaded []string `json:"uploaded"`
   142  		Errors   []string `json:"errors,omitempty"`
   143  	}
   144  	if err = json.NewDecoder(resp.Body).Decode(&assetsResponse); err != nil {
   145  		return err
   146  	}
   147  	if len(assetsResponse.Errors) > 0 {
   148  		return fmt.Errorf("upload failed: %v", strings.Join(assetsResponse.Errors, ","))
   149  	}
   150  	return nil
   151  }
   152  
   153  // Frameworks returns the list of available frameworks.
   154  func (c *TestComposer) Frameworks(ctx context.Context) ([]string, error) {
   155  	url := fmt.Sprintf("%s/v2/testcomposer/frameworks", c.URL)
   156  
   157  	req, err := NewRequestWithContext(ctx, http.MethodGet, url, nil)
   158  	if err != nil {
   159  		return []string{}, err
   160  	}
   161  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   162  
   163  	var resp []framework.Framework
   164  	if err = c.doJSONResponse(req, 200, &resp); err != nil {
   165  		return []string{}, err
   166  	}
   167  	return uniqFrameworkNameSet(resp), nil
   168  }
   169  
   170  // Versions return the list of available versions for a specific framework and region.
   171  func (c *TestComposer) Versions(ctx context.Context, frameworkName string) ([]framework.Metadata, error) {
   172  	url := fmt.Sprintf("%s/v2/testcomposer/frameworks?frameworkName=%s", c.URL, frameworkName)
   173  
   174  	req, err := NewRequestWithContext(ctx, http.MethodGet, url, nil)
   175  	if err != nil {
   176  		return []framework.Metadata{}, err
   177  	}
   178  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   179  
   180  	var resp []FrameworkResponse
   181  	if err = c.doJSONResponse(req, 200, &resp); err != nil {
   182  		return []framework.Metadata{}, err
   183  	}
   184  
   185  	var frameworks []framework.Metadata
   186  	for _, f := range resp {
   187  		var platforms []framework.Platform
   188  		for _, p := range f.Platforms {
   189  			platforms = append(platforms, framework.Platform{
   190  				PlatformName: p.Name,
   191  				BrowserNames: p.Browsers,
   192  			})
   193  		}
   194  		frameworks = append(frameworks, framework.Metadata{
   195  			FrameworkName:      f.Name,
   196  			FrameworkVersion:   f.Version,
   197  			EOLDate:            f.EOLDate,
   198  			RemovalDate:        f.RemovalDate,
   199  			DockerImage:        f.Runner.DockerImage,
   200  			GitRelease:         f.Runner.GitRelease,
   201  			Platforms:          platforms,
   202  			CloudRunnerVersion: f.Runner.CloudRunnerVersion,
   203  			BrowserDefaults:    f.BrowserDefaults,
   204  		})
   205  	}
   206  	return frameworks, nil
   207  }
   208  
   209  func uniqFrameworkNameSet(frameworks []framework.Framework) []string {
   210  	var fws []string
   211  	mp := map[string]bool{}
   212  
   213  	for _, fw := range frameworks {
   214  		_, present := mp[fw.Name]
   215  
   216  		if !present {
   217  			mp[fw.Name] = true
   218  			fws = append(fws, fw.Name)
   219  		}
   220  	}
   221  	return fws
   222  }