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  }