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