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 }