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

     1  package http
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"reflect"
    11  	"strconv"
    12  	"time"
    13  
    14  	"github.com/saucelabs/saucectl/internal/insights"
    15  	"github.com/saucelabs/saucectl/internal/job"
    16  
    17  	"github.com/saucelabs/saucectl/internal/iam"
    18  )
    19  
    20  // archivesJobList represents list job response structure
    21  type archivesJobList struct {
    22  	Jobs  []archivesJob `json:"jobs"`
    23  	Total int           `json:"total"`
    24  }
    25  
    26  // archivesJob represents job response structure
    27  type archivesJob struct {
    28  	ID          string `json:"id"`
    29  	Name        string `json:"name"`
    30  	Status      string `json:"status"`
    31  	Error       string `json:"error"`
    32  	Framework   string `json:"automation_backend"`
    33  	Device      string `json:"device"`
    34  	BrowserName string `json:"browser_name"`
    35  	OS          string `json:"os"`
    36  	OSVersion   string `json:"os_version"`
    37  	Source      string `json:"source"`
    38  }
    39  
    40  // AutomaticRunMode indicates the job is automated
    41  const AutomaticRunMode = "automatic"
    42  
    43  type InsightsService struct {
    44  	HTTPClient  *http.Client
    45  	URL         string
    46  	Credentials iam.Credentials
    47  }
    48  
    49  func NewInsightsService(url string, creds iam.Credentials, timeout time.Duration) InsightsService {
    50  	return InsightsService{
    51  		HTTPClient: &http.Client{
    52  			Timeout:   timeout,
    53  			Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
    54  		},
    55  		URL:         url,
    56  		Credentials: creds,
    57  	}
    58  }
    59  
    60  func (c *InsightsService) GetHistory(ctx context.Context, user iam.User, sortBy string) (insights.JobHistory, error) {
    61  	start := time.Now().AddDate(0, 0, -7).Unix()
    62  	now := time.Now().Unix()
    63  
    64  	var jobHistory insights.JobHistory
    65  	url := fmt.Sprintf("%s/insights/v2/test-cases", c.URL)
    66  	req, err := NewRequestWithContext(ctx, http.MethodGet, url, nil)
    67  	if err != nil {
    68  		return jobHistory, err
    69  	}
    70  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
    71  
    72  	q := req.URL.Query()
    73  	queries := map[string]string{
    74  		"user_id": user.ID,
    75  		"org_id":  user.Organization.ID,
    76  		"start":   strconv.FormatInt(start, 10),
    77  		"since":   strconv.FormatInt(start, 10),
    78  		"end":     strconv.FormatInt(now, 10),
    79  		"until":   strconv.FormatInt(now, 10),
    80  		"limit":   "200",
    81  		"offset":  "0",
    82  		"sort_by": sortBy,
    83  	}
    84  	for k, v := range queries {
    85  		q.Add(k, v)
    86  	}
    87  	req.URL.RawQuery = q.Encode()
    88  
    89  	resp, err := c.HTTPClient.Do(req)
    90  	if err != nil {
    91  		return jobHistory, err
    92  	}
    93  	defer resp.Body.Close()
    94  
    95  	if resp.StatusCode != http.StatusOK {
    96  		return jobHistory, fmt.Errorf("unexpected status: %s", resp.Status)
    97  	}
    98  
    99  	return jobHistory, json.NewDecoder(resp.Body).Decode(&jobHistory)
   100  }
   101  
   102  type testRunsInput struct {
   103  	TestRuns []insights.TestRun `json:"test_runs,omitempty"`
   104  }
   105  
   106  type testRunError struct {
   107  	Loc  []interface{} `json:"loc,omitempty"`
   108  	Msg  string        `json:"msg,omitempty"`
   109  	Type string        `json:"type,omitempty"`
   110  }
   111  
   112  type testRunErrorResponse struct {
   113  	Detail []testRunError `json:"detail,omitempty"`
   114  }
   115  
   116  func concatenateLocation(loc []interface{}) string {
   117  	out := ""
   118  	for idx, item := range loc {
   119  		if idx > 0 {
   120  			out += "."
   121  		}
   122  		if reflect.TypeOf(item).String() == "string" {
   123  			out = fmt.Sprintf("%s%s", out, item)
   124  		}
   125  		if reflect.TypeOf(item).String() == "int" {
   126  			out = fmt.Sprintf("%s%d", out, item)
   127  		}
   128  	}
   129  	return out
   130  }
   131  
   132  // PostTestRun publish test-run results to insights API.
   133  func (c *InsightsService) PostTestRun(ctx context.Context, runs []insights.TestRun) error {
   134  	url := fmt.Sprintf("%s/test-runs/v1/", c.URL)
   135  
   136  	input := testRunsInput{
   137  		TestRuns: runs,
   138  	}
   139  	payload, err := json.Marshal(input)
   140  	if err != nil {
   141  		return err
   142  	}
   143  	payloadReader := bytes.NewReader(payload)
   144  	req, err := NewRequestWithContext(ctx, http.MethodPost, url, payloadReader)
   145  	if err != nil {
   146  		return err
   147  	}
   148  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   149  	resp, err := c.HTTPClient.Do(req)
   150  	if err != nil {
   151  		return err
   152  	}
   153  
   154  	// API Replies 204, doc says 200. Supporting both for now.
   155  	if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
   156  		return nil
   157  	}
   158  
   159  	if resp.StatusCode == http.StatusUnprocessableEntity {
   160  		body, err := io.ReadAll(resp.Body)
   161  		if err != nil {
   162  			return err
   163  		}
   164  		var res testRunErrorResponse
   165  		if err = json.Unmarshal(body, &res); err != nil {
   166  			return fmt.Errorf("unable to unmarshal response from API: %s", err)
   167  		}
   168  		return fmt.Errorf("%s: %s", concatenateLocation(res.Detail[0].Loc), res.Detail[0].Type)
   169  	}
   170  	return fmt.Errorf("unexpected status code from API: %d", resp.StatusCode)
   171  }
   172  
   173  // ListJobs returns job list
   174  func (c *InsightsService) ListJobs(ctx context.Context, opts insights.ListJobsOptions) ([]job.Job, error) {
   175  	url := fmt.Sprintf("%s/v2/archives/jobs", c.URL)
   176  	req, err := NewRequestWithContext(ctx, http.MethodGet, url, nil)
   177  	if err != nil {
   178  		return nil, err
   179  	}
   180  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   181  
   182  	q := req.URL.Query()
   183  	queries := map[string]string{
   184  		"ts":       strconv.FormatInt(time.Now().UTC().UnixMilli(), 10),
   185  		"page":     strconv.Itoa(opts.Page),
   186  		"size":     strconv.Itoa(opts.Size),
   187  		"status":   opts.Status,
   188  		"owner_id": opts.UserID,
   189  		"run_mode": AutomaticRunMode,
   190  		"source":   string(opts.Source),
   191  	}
   192  	for k, v := range queries {
   193  		if v != "" {
   194  			q.Add(k, v)
   195  		}
   196  	}
   197  	req.URL.RawQuery = q.Encode()
   198  
   199  	resp, err := c.HTTPClient.Do(req)
   200  	if err != nil {
   201  		return nil, err
   202  	}
   203  	defer resp.Body.Close()
   204  
   205  	if resp.StatusCode != http.StatusOK {
   206  		return nil, fmt.Errorf("unexpected status: %s", resp.Status)
   207  	}
   208  
   209  	return c.parseJobs(resp.Body)
   210  }
   211  
   212  func (c *InsightsService) ReadJob(ctx context.Context, id string) (job.Job, error) {
   213  	url := fmt.Sprintf("%s/v2/archives/jobs/%s", c.URL, id)
   214  
   215  	req, err := NewRequestWithContext(ctx, http.MethodGet, url, nil)
   216  	if err != nil {
   217  		return job.Job{}, err
   218  	}
   219  
   220  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   221  	resp, err := c.HTTPClient.Do(req)
   222  	if err != nil {
   223  		return job.Job{}, err
   224  	}
   225  	defer resp.Body.Close()
   226  
   227  	if resp.StatusCode != http.StatusOK {
   228  		return job.Job{}, fmt.Errorf("unexpected status: %s", resp.Status)
   229  	}
   230  
   231  	return c.parseJob(resp.Body)
   232  }
   233  
   234  // parseJob parses the body into archivesJob and converts it to job.Job.
   235  func (c *InsightsService) parseJob(body io.ReadCloser) (job.Job, error) {
   236  	var j archivesJob
   237  
   238  	if err := json.NewDecoder(body).Decode(&j); err != nil {
   239  		return job.Job{}, err
   240  	}
   241  
   242  	return c.convertJob(j), nil
   243  }
   244  
   245  // parseJob parses the body into archivesJobList and converts it to []job.Job.
   246  func (c *InsightsService) parseJobs(body io.ReadCloser) ([]job.Job, error) {
   247  	var l archivesJobList
   248  
   249  	if err := json.NewDecoder(body).Decode(&l); err != nil {
   250  		return nil, err
   251  	}
   252  
   253  	jobs := make([]job.Job, len(l.Jobs))
   254  	for i, j := range l.Jobs {
   255  		jobs[i] = c.convertJob(j)
   256  	}
   257  
   258  	return jobs, nil
   259  }
   260  
   261  // parseJob converts archivesJob to job.Job.
   262  func (c *InsightsService) convertJob(j archivesJob) job.Job {
   263  	return job.Job{
   264  		ID:          j.ID,
   265  		Name:        j.Name,
   266  		Status:      j.Status,
   267  		Error:       j.Error,
   268  		OS:          j.OS,
   269  		OSVersion:   j.OSVersion,
   270  		Framework:   j.Framework,
   271  		DeviceName:  j.Device,
   272  		BrowserName: j.BrowserName,
   273  	}
   274  }