github.com/discordapp/buildkite-agent@v2.6.6+incompatible/api/buildkite.go (about)

     1  package api
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"mime/multipart"
    10  	"net/http"
    11  	"net/http/httputil"
    12  	"net/textproto"
    13  	"net/url"
    14  	"reflect"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/buildkite/agent/logger"
    19  	"github.com/google/go-querystring/query"
    20  )
    21  
    22  const (
    23  	defaultBaseURL   = "https://agent.buildkite.com/"
    24  	defaultUserAgent = "buildkite-agent/api"
    25  )
    26  
    27  // A Client manages communication with the Buildkite Agent API.
    28  type Client struct {
    29  	// HTTP client used to communicate with the API.
    30  	client *http.Client
    31  
    32  	// Base URL for API requests. Defaults to the public Buildkite Agent API.
    33  	// The URL should always be specified with a trailing slash.
    34  	BaseURL *url.URL
    35  
    36  	// User agent used when communicating with the Buildkite Agent API.
    37  	UserAgent string
    38  
    39  	// If true, requests and responses will be dumped and set to the logger
    40  	DebugHTTP bool
    41  
    42  	// Services used for talking to different parts of the Buildkite Agent API.
    43  	Agents      *AgentsService
    44  	Pings       *PingsService
    45  	Jobs        *JobsService
    46  	Chunks      *ChunksService
    47  	MetaData    *MetaDataService
    48  	HeaderTimes *HeaderTimesService
    49  	Artifacts   *ArtifactsService
    50  	Pipelines   *PipelinesService
    51  	Heartbeats  *HeartbeatsService
    52  }
    53  
    54  // NewClient returns a new Buildkite Agent API Client.
    55  func NewClient(httpClient *http.Client) *Client {
    56  	baseURL, _ := url.Parse(defaultBaseURL)
    57  
    58  	c := &Client{
    59  		client:    httpClient,
    60  		BaseURL:   baseURL,
    61  		UserAgent: defaultUserAgent,
    62  	}
    63  
    64  	c.Agents = &AgentsService{c}
    65  	c.Pings = &PingsService{c}
    66  	c.Jobs = &JobsService{c}
    67  	c.Chunks = &ChunksService{c}
    68  	c.MetaData = &MetaDataService{c}
    69  	c.HeaderTimes = &HeaderTimesService{c}
    70  	c.Artifacts = &ArtifactsService{c}
    71  	c.Pipelines = &PipelinesService{c}
    72  	c.Heartbeats = &HeartbeatsService{c}
    73  
    74  	return c
    75  }
    76  
    77  // NewRequest creates an API request. A relative URL can be provided in urlStr,
    78  // in which case it is resolved relative to the BaseURL of the Client.
    79  // Relative URLs should always be specified without a preceding slash. If
    80  // specified, the value pointed to by body is JSON encoded and included as the
    81  // request body.
    82  func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) {
    83  	u := joinURL(c.BaseURL.String(), urlStr)
    84  
    85  	buf := new(bytes.Buffer)
    86  	if body != nil {
    87  		err := json.NewEncoder(buf).Encode(body)
    88  		if err != nil {
    89  			return nil, err
    90  		}
    91  	}
    92  
    93  	req, err := http.NewRequest(method, u, buf)
    94  	if err != nil {
    95  		return nil, err
    96  	}
    97  
    98  	req.Header.Add("User-Agent", c.UserAgent)
    99  
   100  	if body != nil {
   101  		req.Header.Add("Content-Type", "application/json")
   102  	}
   103  
   104  	return req, nil
   105  }
   106  
   107  // NewFormRequest creates an mutli-part form request. A relative URL can be
   108  // provided in urlStr, in which case it is resolved relative to the UploadURL
   109  // of the Client. Relative URLs should always be specified without a preceding
   110  // slash.
   111  func (c *Client) NewFormRequest(method, urlStr string, body *bytes.Buffer) (*http.Request, error) {
   112  	u := joinURL(c.BaseURL.String(), urlStr)
   113  
   114  	req, err := http.NewRequest(method, u, body)
   115  	if err != nil {
   116  		return nil, err
   117  	}
   118  
   119  	if c.UserAgent != "" {
   120  		req.Header.Add("User-Agent", c.UserAgent)
   121  	}
   122  
   123  	return req, nil
   124  }
   125  
   126  // Response is a Buildkite Agent API response. This wraps the standard
   127  // http.Response.
   128  type Response struct {
   129  	*http.Response
   130  }
   131  
   132  // newResponse creates a new Response for the provided http.Response.
   133  func newResponse(r *http.Response) *Response {
   134  	response := &Response{Response: r}
   135  	return response
   136  }
   137  
   138  // Do sends an API request and returns the API response. The API response is
   139  // JSON decoded and stored in the value pointed to by v, or returned as an
   140  // error if an API error has occurred.  If v implements the io.Writer
   141  // interface, the raw response body will be written to v, without attempting to
   142  // first decode it.
   143  func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) {
   144  	var err error
   145  
   146  	if c.DebugHTTP {
   147  		// If the request is a multi-part form, then it's probably a
   148  		// file upload, in which case we don't want to spewing out the
   149  		// file contents into the debug log (especially if it's been
   150  		// gzipped)
   151  		var requestDump []byte
   152  		if strings.Contains(req.Header.Get("Content-Type"), "multipart/form-data") {
   153  			requestDump, err = httputil.DumpRequestOut(req, false)
   154  		} else {
   155  			requestDump, err = httputil.DumpRequestOut(req, true)
   156  		}
   157  
   158  		logger.Debug("ERR: %s\n%s", err, string(requestDump))
   159  	}
   160  
   161  	ts := time.Now()
   162  
   163  	logger.Debug("%s %s", req.Method, req.URL)
   164  
   165  	resp, err := c.client.Do(req)
   166  	if err != nil {
   167  		return nil, err
   168  	}
   169  
   170  	logger.Debug("↳ %s %s (%s %s)", req.Method, req.URL, resp.Status, time.Now().Sub(ts))
   171  
   172  	defer resp.Body.Close()
   173  	defer io.Copy(ioutil.Discard, resp.Body)
   174  
   175  	response := newResponse(resp)
   176  
   177  	if c.DebugHTTP {
   178  		responseDump, err := httputil.DumpResponse(resp, true)
   179  		logger.Debug("\nERR: %s\n%s", err, string(responseDump))
   180  	}
   181  
   182  	err = checkResponse(resp)
   183  	if err != nil {
   184  		// even though there was an error, we still return the response
   185  		// in case the caller wants to inspect it further
   186  		return response, err
   187  	}
   188  
   189  	if v != nil {
   190  		if w, ok := v.(io.Writer); ok {
   191  			io.Copy(w, resp.Body)
   192  		} else {
   193  			err = json.NewDecoder(resp.Body).Decode(v)
   194  		}
   195  	}
   196  
   197  	return response, err
   198  }
   199  
   200  // ErrorResponse provides a message.
   201  type ErrorResponse struct {
   202  	Response *http.Response // HTTP response that caused this error
   203  	Message  string         `json:"message"` // error message
   204  }
   205  
   206  func (r *ErrorResponse) Error() string {
   207  	s := fmt.Sprintf("%v %v: %d",
   208  		r.Response.Request.Method, r.Response.Request.URL,
   209  		r.Response.StatusCode)
   210  
   211  	if r.Message != "" {
   212  		s = fmt.Sprintf("%s %v", s, r.Message)
   213  	}
   214  
   215  	return s
   216  }
   217  
   218  func checkResponse(r *http.Response) error {
   219  	if c := r.StatusCode; 200 <= c && c <= 299 {
   220  		return nil
   221  	}
   222  
   223  	errorResponse := &ErrorResponse{Response: r}
   224  	data, err := ioutil.ReadAll(r.Body)
   225  	if err == nil && data != nil {
   226  		json.Unmarshal(data, errorResponse)
   227  	}
   228  
   229  	return errorResponse
   230  }
   231  
   232  // addOptions adds the parameters in opt as URL query parameters to s. opt must
   233  // be a struct whose fields may contain "url" tags.
   234  func addOptions(s string, opt interface{}) (string, error) {
   235  	v := reflect.ValueOf(opt)
   236  	if v.Kind() == reflect.Ptr && v.IsNil() {
   237  		return s, nil
   238  	}
   239  
   240  	u, err := url.Parse(s)
   241  	if err != nil {
   242  		return s, err
   243  	}
   244  
   245  	qs, err := query.Values(opt)
   246  	if err != nil {
   247  		return s, err
   248  	}
   249  
   250  	u.RawQuery = qs.Encode()
   251  	return u.String(), nil
   252  }
   253  
   254  // Copied from http://golang.org/src/mime/multipart/writer.go
   255  var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
   256  
   257  func escapeQuotes(s string) string {
   258  	return quoteEscaper.Replace(s)
   259  }
   260  
   261  // createFormFileWithContentType is a copy of the CreateFormFile method, except
   262  // you can change the content type it uses (by default you can't)
   263  func createFormFileWithContentType(w *multipart.Writer, fieldname, filename, contentType string) (io.Writer, error) {
   264  	h := make(textproto.MIMEHeader)
   265  	h.Set("Content-Disposition",
   266  		fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
   267  			escapeQuotes(fieldname), escapeQuotes(filename)))
   268  	h.Set("Content-Type", contentType)
   269  	return w.CreatePart(h)
   270  }
   271  
   272  func joinURL(endpoint string, path string) string {
   273  	return strings.TrimRight(endpoint, "/") + "/" + strings.TrimLeft(path, "/")
   274  }