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 }