github.com/saucelabs/saucectl@v0.175.1/internal/http/appstore.go (about) 1 package http 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/http" 9 "net/url" 10 "strconv" 11 "time" 12 13 "github.com/hashicorp/go-retryablehttp" 14 "github.com/saucelabs/saucectl/internal/multipartext" 15 "github.com/saucelabs/saucectl/internal/storage" 16 ) 17 18 // UploadResponse represents the response as is returned by the app store. 19 type UploadResponse struct { 20 Item Item `json:"item"` 21 } 22 23 // ListResponse represents the response as is returned by the app store. 24 type ListResponse struct { 25 Items []Item `json:"items"` 26 Links Links `json:"links"` 27 Page int `json:"page"` 28 PerPage int `json:"per_page"` 29 TotalItems int `json:"total_items"` 30 } 31 32 // Links represents the pagination information returned by the app store. 33 type Links struct { 34 Self string `json:"self"` 35 Prev string `json:"prev"` 36 Next string `json:"next"` 37 } 38 39 // Item represents the metadata about the uploaded file. 40 type Item struct { 41 ID string `json:"id"` 42 Name string `json:"name"` 43 Size int `json:"size"` 44 UploadTimestamp int64 `json:"upload_timestamp"` 45 } 46 47 // AppStore implements a remote file storage for storage.AppService. 48 // See https://wiki.saucelabs.com/display/DOCS/Application+Storage for more details. 49 type AppStore struct { 50 HTTPClient *retryablehttp.Client 51 URL string 52 Username string 53 AccessKey string 54 } 55 56 // NewAppStore returns an implementation for AppStore 57 func NewAppStore(url, username, accessKey string, timeout time.Duration) *AppStore { 58 return &AppStore{ 59 HTTPClient: NewRetryableClient(timeout), 60 URL: url, 61 Username: username, 62 AccessKey: accessKey, 63 } 64 } 65 66 // Download downloads a file with the given id. It's the caller's responsibility to close the reader. 67 func (s *AppStore) Download(id string) (io.ReadCloser, int64, error) { 68 req, err := retryablehttp.NewRequest(http.MethodGet, fmt.Sprintf("%s/v1/storage/download/%s", s.URL, id), nil) 69 if err != nil { 70 return nil, 0, err 71 } 72 73 req.SetBasicAuth(s.Username, s.AccessKey) 74 75 resp, err := s.HTTPClient.Do(req) 76 if err != nil { 77 return nil, 0, err 78 } 79 80 switch resp.StatusCode { 81 case 200: 82 return resp.Body, resp.ContentLength, nil 83 case 401, 403: 84 return nil, 0, storage.ErrAccessDenied 85 case 404: 86 return nil, 0, storage.ErrFileNotFound 87 case 429: 88 return nil, 0, storage.ErrTooManyRequest 89 default: 90 return nil, 0, s.newServerError(resp) 91 } 92 } 93 94 // DownloadURL downloads a file from the url. It's the caller's responsibility to close the reader. 95 func (s *AppStore) DownloadURL(url string) (io.ReadCloser, int64, error) { 96 req, err := retryablehttp.NewRequest(http.MethodGet, url, nil) 97 if err != nil { 98 return nil, 0, err 99 } 100 101 resp, err := s.HTTPClient.Do(req) 102 if err != nil { 103 return nil, 0, err 104 } 105 106 switch resp.StatusCode { 107 case 200: 108 return resp.Body, resp.ContentLength, nil 109 default: 110 b, _ := io.ReadAll(resp.Body) 111 return nil, 0, fmt.Errorf("unexpected server response (%d): %s", resp.StatusCode, b) 112 } 113 } 114 115 // UploadStream uploads the contents of reader and stores them under the given filename. 116 func (s *AppStore) UploadStream(filename, description string, reader io.Reader) (storage.Item, error) { 117 multipartReader, contentType, err := multipartext.NewMultipartReader("payload", filename, description, reader) 118 if err != nil { 119 return storage.Item{}, err 120 } 121 122 req, err := retryablehttp.NewRequest(http.MethodPost, fmt.Sprintf("%s/v1/storage/upload", s.URL), multipartReader) 123 if err != nil { 124 return storage.Item{}, err 125 } 126 127 req.Header.Set("Content-Type", contentType) 128 req.SetBasicAuth(s.Username, s.AccessKey) 129 130 resp, err := s.HTTPClient.Do(req) 131 if err != nil { 132 return storage.Item{}, err 133 } 134 135 switch resp.StatusCode { 136 case 200, 201: 137 var ur UploadResponse 138 if err := json.NewDecoder(resp.Body).Decode(&ur); err != nil { 139 return storage.Item{}, err 140 } 141 142 return storage.Item{ 143 ID: ur.Item.ID, 144 Name: ur.Item.Name, 145 Uploaded: time.Unix(ur.Item.UploadTimestamp, 0), 146 Size: ur.Item.Size, 147 }, err 148 case 401, 403: 149 return storage.Item{}, storage.ErrAccessDenied 150 case 429: 151 return storage.Item{}, storage.ErrTooManyRequest 152 default: 153 return storage.Item{}, s.newServerError(resp) 154 } 155 } 156 157 // List returns a list of items stored in the Sauce app storage that match the search criteria specified by opts. 158 func (s *AppStore) List(opts storage.ListOptions) (storage.List, error) { 159 uri, _ := url.Parse(s.URL) 160 uri.Path = "/v1/storage/files" 161 162 // Default MaxResults if not set or out of range. 163 if opts.MaxResults < 1 || opts.MaxResults > 100 { 164 opts.MaxResults = 100 165 } 166 167 query := uri.Query() 168 query.Set("per_page", strconv.Itoa(opts.MaxResults)) 169 if opts.MaxResults == 1 { 170 query.Set("paginate", "no") 171 } 172 if opts.Q != "" { 173 query.Set("q", opts.Q) 174 } 175 if opts.Name != "" { 176 query.Set("name", opts.Name) 177 } 178 if opts.SHA256 != "" { 179 query.Set("sha256", opts.SHA256) 180 } 181 182 uri.RawQuery = query.Encode() 183 184 req, err := retryablehttp.NewRequest(http.MethodGet, uri.String(), nil) 185 if err != nil { 186 return storage.List{}, err 187 } 188 req.SetBasicAuth(s.Username, s.AccessKey) 189 190 resp, err := s.HTTPClient.Do(req) 191 if err != nil { 192 return storage.List{}, err 193 } 194 defer resp.Body.Close() 195 196 switch resp.StatusCode { 197 case 200: 198 var listResp ListResponse 199 if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 200 return storage.List{}, err 201 } 202 203 var items []storage.Item 204 for _, v := range listResp.Items { 205 items = append(items, storage.Item{ 206 ID: v.ID, 207 Name: v.Name, 208 Size: v.Size, 209 Uploaded: time.Unix(v.UploadTimestamp, 0), 210 }) 211 } 212 213 return storage.List{ 214 Items: items, 215 Truncated: listResp.TotalItems > len(items), 216 }, nil 217 case 401, 403: 218 return storage.List{}, storage.ErrAccessDenied 219 case 429: 220 return storage.List{}, storage.ErrTooManyRequest 221 default: 222 return storage.List{}, s.newServerError(resp) 223 } 224 } 225 226 func (s *AppStore) Delete(id string) error { 227 if id == "" { 228 return fmt.Errorf("no id specified") 229 } 230 231 req, err := retryablehttp.NewRequest(http.MethodDelete, fmt.Sprintf("%s/v1/storage/files/%s", s.URL, id), nil) 232 if err != nil { 233 return err 234 } 235 236 req.SetBasicAuth(s.Username, s.AccessKey) 237 238 resp, err := s.HTTPClient.Do(req) 239 if err != nil { 240 return err 241 } 242 243 switch resp.StatusCode { 244 case 200: 245 return nil 246 case 401, 403: 247 return storage.ErrAccessDenied 248 case 404: 249 return storage.ErrFileNotFound 250 case 429: 251 return storage.ErrTooManyRequest 252 default: 253 return s.newServerError(resp) 254 } 255 } 256 257 // newServerError inspects server error responses, trying to gather as much information as possible, especially if the body 258 // conforms to the errorResponse format, and returns a storage.ServerError. 259 func (s *AppStore) newServerError(resp *http.Response) *storage.ServerError { 260 var errResp struct { 261 Code int `json:"code"` 262 Title string `json:"title"` 263 Detail string `json:"detail"` 264 } 265 body, _ := io.ReadAll(resp.Body) 266 defer resp.Body.Close() 267 268 reader := bytes.NewReader(body) 269 err := json.NewDecoder(reader).Decode(&errResp) 270 if err != nil { 271 return &storage.ServerError{ 272 Code: resp.StatusCode, 273 Title: resp.Status, 274 Msg: string(body), 275 } 276 } 277 278 return &storage.ServerError{ 279 Code: errResp.Code, 280 Title: errResp.Title, 281 Msg: errResp.Detail, 282 } 283 }