package bitbucket import ( "bytes" "encoding/json" "fmt" "io" "io/ioutil" "log" "mime/multipart" "net/http" "net/url" "os" "strconv" "strings" "golang.org/x/net/context" "golang.org/x/oauth2" "golang.org/x/oauth2/bitbucket" "golang.org/x/oauth2/clientcredentials" ) const DEFAULT_PAGE_LENGTH = 10 const DEFAULT_LIMIT_PAGES = 0 const DEFAULT_MAX_DEPTH = 1 const DEFAULT_BITBUCKET_API_BASE_URL = "https://api.bitbucket.org/2.0" func apiBaseUrl() (*url.URL, error) { ev := os.Getenv("BITBUCKET_API_BASE_URL") if ev == "" { ev = DEFAULT_BITBUCKET_API_BASE_URL } return url.Parse(ev) } type Client struct { Auth *auth Users *Users User user Teams teams Repositories *Repositories Workspaces *Workspace Pagelen int MaxDepth int // LimitPages limits the number of pages for a request // default value as 0 -- disable limits LimitPages int // DisableAutoPaging allows you to disable the default behavior of automatically requesting // all the pages for a paginated response. DisableAutoPaging bool apiBaseURL *url.URL HttpClient *http.Client } type auth struct { appID, secret string user, password string token oauth2.Token bearerToken string } type Response struct { *http.Response `json:"-"` Size int `json:"size"` Page int `json:"page"` Pagelen int `json:"pagelen"` Next string `json:"next"` Previous string `json:"previous"` Values []interface{} `json:"values"` } // Uses the Client Credentials Grant oauth2 flow to authenticate to Bitbucket func NewOAuthClientCredentials(i, s string) *Client { a := &auth{appID: i, secret: s} ctx := context.Background() conf := &clientcredentials.Config{ ClientID: i, ClientSecret: s, TokenURL: bitbucket.Endpoint.TokenURL, } tok, err := conf.Token(ctx) if err != nil { log.Fatal(err) } a.token = *tok return injectClient(a) } func NewOAuth(i, s string) *Client { a := &auth{appID: i, secret: s} ctx := context.Background() conf := &oauth2.Config{ ClientID: i, ClientSecret: s, Endpoint: bitbucket.Endpoint, } // Redirect user to consent page to ask for permission // for the scopes specified above. url := conf.AuthCodeURL("state", oauth2.AccessTypeOffline) fmt.Printf("Visit the URL for the auth dialog:\n%v", url) // Use the authorization code that is pushed to the redirect // URL. Exchange will do the handshake to retrieve the // initial access token. The HTTP Client returned by // conf.Client will refresh the token as necessary. var code string fmt.Printf("Enter the code in the return URL: ") if _, err := fmt.Scan(&code); err != nil { log.Fatal(err) } tok, err := conf.Exchange(ctx, code) if err != nil { log.Fatal(err) } a.token = *tok return injectClient(a) } // NewOAuthWithCode finishes the OAuth handshake with a given code // and returns a *Client func NewOAuthWithCode(i, s, c string) (*Client, string) { a := &auth{appID: i, secret: s} ctx := context.Background() conf := &oauth2.Config{ ClientID: i, ClientSecret: s, Endpoint: bitbucket.Endpoint, } tok, err := conf.Exchange(ctx, c) if err != nil { log.Fatal(err) } a.token = *tok return injectClient(a), tok.AccessToken } // NewOAuthWithRefreshToken obtains a new access token with a given refresh token // and returns a *Client func NewOAuthWithRefreshToken(i, s, rt string) (*Client, string) { a := &auth{appID: i, secret: s} ctx := context.Background() conf := &oauth2.Config{ ClientID: i, ClientSecret: s, Endpoint: bitbucket.Endpoint, } tokenSource := conf.TokenSource(ctx, &oauth2.Token{ RefreshToken: rt, }) tok, err := tokenSource.Token() if err != nil { log.Fatal(err) } a.token = *tok return injectClient(a), tok.AccessToken } func NewOAuthbearerToken(t string) *Client { a := &auth{bearerToken: t} return injectClient(a) } func NewBasicAuth(u, p string) *Client { a := &auth{user: u, password: p} return injectClient(a) } func injectClient(a *auth) *Client { bitbucketUrl, err := apiBaseUrl() if err != nil { log.Fatalf("invalid bitbucket url") } c := &Client{Auth: a, Pagelen: DEFAULT_PAGE_LENGTH, MaxDepth: DEFAULT_MAX_DEPTH, apiBaseURL: bitbucketUrl, LimitPages: DEFAULT_LIMIT_PAGES} c.Repositories = &Repositories{ c: c, PullRequests: &PullRequests{c: c}, Pipelines: &Pipelines{c: c}, Repository: &Repository{c: c}, Issues: &Issues{c: c}, Commits: &Commits{c: c}, Diff: &Diff{c: c}, BranchRestrictions: &BranchRestrictions{c: c}, Webhooks: &Webhooks{c: c}, Downloads: &Downloads{c: c}, DeployKeys: &DeployKeys{c: c}, } c.Users = &Users{ c: c, SSHKeys: &SSHKeys{c: c}, } c.User = &User{c: c} c.Teams = &Teams{c: c} c.Workspaces = &Workspace{c: c, Repositories: c.Repositories, Permissions: &Permission{c: c}} c.HttpClient = new(http.Client) return c } func (c *Client) GetOAuthToken() oauth2.Token { return c.Auth.token } func (c *Client) GetApiBaseURL() string { return fmt.Sprintf("%s%s", c.GetApiHostnameURL(), c.apiBaseURL.Path) } func (c *Client) GetApiHostnameURL() string { return fmt.Sprintf("%s://%s", c.apiBaseURL.Scheme, c.apiBaseURL.Host) } func (c *Client) SetApiBaseURL(urlStr url.URL) { c.apiBaseURL = &urlStr } func (c *Client) executeRaw(method string, urlStr string, text string) (io.ReadCloser, error) { body := strings.NewReader(text) req, err := http.NewRequest(method, urlStr, body) if err != nil { return nil, err } if text != "" { req.Header.Set("Content-Type", "application/json") } c.authenticateRequest(req) return c.doRawRequest(req, false) } func (c *Client) execute(method string, urlStr string, text string) (interface{}, error) { return c.executeWithContext(method, urlStr, text, context.Background()) } func (c *Client) executeWithContext(method string, urlStr string, text string, ctx context.Context) (interface{}, error) { body := strings.NewReader(text) req, err := http.NewRequest(method, urlStr, body) if err != nil { return nil, err } if text != "" { req.Header.Set("Content-Type", "application/json") } if ctx != nil { req.WithContext(ctx) } c.authenticateRequest(req) result, err := c.doRequest(req, false) if err != nil { return nil, err } return result, nil } func (c *Client) executePaginated(method string, urlStr string, text string, page *int) (interface{}, error) { if c.Pagelen != DEFAULT_PAGE_LENGTH { urlObj, err := url.Parse(urlStr) if err != nil { return nil, err } q := urlObj.Query() q.Set("pagelen", strconv.Itoa(c.Pagelen)) urlObj.RawQuery = q.Encode() urlStr = urlObj.String() } body := strings.NewReader(text) req, err := http.NewRequest(method, urlStr, body) if err != nil { return nil, err } if text != "" { req.Header.Set("Content-Type", "application/json") } c.authenticateRequest(req) result, err := c.doPaginatedRequest(req, page, false) if err != nil { return nil, err } return result, nil } func (c *Client) executeFileUpload(method string, urlStr string, files []File, filesToDelete []string, params map[string]string, ctx context.Context) (interface{}, error) { // Prepare a form that you will submit to that URL. var b bytes.Buffer w := multipart.NewWriter(&b) var fw io.Writer for _, file := range files { fileReader, err := os.Open(file.Path) if err != nil { return nil, err } defer fileReader.Close() if fw, err = w.CreateFormFile(file.Name, file.Name); err != nil { return nil, err } if _, err = io.Copy(fw, fileReader); err != nil { return nil, err } } for key, value := range params { err := w.WriteField(key, value) if err != nil { return nil, err } } for _, filename := range filesToDelete { err := w.WriteField("files", filename) if err != nil { return nil, err } } // Don't forget to close the multipart writer. // If you don't close it, your request will be missing the terminating boundary. w.Close() // Now that you have a form, you can submit it to your handler. req, err := http.NewRequest(method, urlStr, &b) if err != nil { return nil, err } // Don't forget to set the content type, this will contain the boundary. req.Header.Set("Content-Type", w.FormDataContentType()) if ctx != nil { req.WithContext(ctx) } c.authenticateRequest(req) return c.doRequest(req, true) } func (c *Client) authenticateRequest(req *http.Request) { if c.Auth.bearerToken != "" { req.Header.Set("Authorization", "Bearer "+c.Auth.bearerToken) } if c.Auth.user != "" && c.Auth.password != "" { req.SetBasicAuth(c.Auth.user, c.Auth.password) } else if c.Auth.token.Valid() { c.Auth.token.SetAuthHeader(req) } } func (c *Client) doRequest(req *http.Request, emptyResponse bool) (interface{}, error) { resBody, err := c.doRawRequest(req, emptyResponse) if err != nil { return nil, err } if emptyResponse || resBody == nil { return nil, nil } defer resBody.Close() responseBytes, err := ioutil.ReadAll(resBody) if err != nil { return resBody, err } var result interface{} if err := json.Unmarshal(responseBytes, &result); err != nil { return responseBytes, err } return result, nil } func (c *Client) doPaginatedRequest(req *http.Request, page *int, emptyResponse bool) (interface{}, error) { disableAutoPaging := c.DisableAutoPaging curPage := 1 if page != nil { disableAutoPaging = true curPage = *page q := req.URL.Query() q.Set("page", strconv.Itoa(curPage)) req.URL.RawQuery = q.Encode() } // q.Encode() does not encode "~". req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, "~", "%7E") resBody, err := c.doRawRequest(req, emptyResponse) if err != nil { return nil, err } if emptyResponse || resBody == nil { return nil, nil } defer resBody.Close() responseBytes, err := ioutil.ReadAll(resBody) if err != nil { return resBody, err } responsePaginated := &Response{} err = json.Unmarshal(responseBytes, responsePaginated) if err == nil && len(responsePaginated.Values) > 0 { values := responsePaginated.Values for { if disableAutoPaging || responsePaginated.Next == "" || (curPage >= c.LimitPages && c.LimitPages != 0) { break } curPage++ newReq, err := http.NewRequest(req.Method, responsePaginated.Next, nil) if err != nil { return resBody, err } c.authenticateRequest(newReq) resp, err := c.doRawRequest(newReq, false) if err != nil { return resBody, err } responsePaginated = &Response{} json.NewDecoder(resp).Decode(responsePaginated) values = append(values, responsePaginated.Values...) } responsePaginated.Values = values responseBytes, err = json.Marshal(responsePaginated) if err != nil { return resBody, err } } var result interface{} if err := json.Unmarshal(responseBytes, &result); err != nil { return resBody, err } return result, nil } func (c *Client) doRawRequest(req *http.Request, emptyResponse bool) (io.ReadCloser, error) { resp, err := c.HttpClient.Do(req) if err != nil { return nil, err } if unexpectedHttpStatusCode(resp.StatusCode) { defer resp.Body.Close() out := &UnexpectedResponseStatusError{Status: resp.Status} body, err := io.ReadAll(resp.Body) if err != nil { out.Body = []byte(fmt.Sprintf("could not read the response body: %v", err)) } else { out.Body = body } return nil, out } if emptyResponse || resp.StatusCode == http.StatusNoContent { resp.Body.Close() return nil, nil } if resp.Body == nil { return nil, fmt.Errorf("response body is nil") } return resp.Body, nil } func unexpectedHttpStatusCode(statusCode int) bool { switch statusCode { case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusAccepted: return false default: return true } } func (c *Client) requestUrl(template string, args ...interface{}) string { if len(args) == 1 && args[0] == "" { return c.GetApiBaseURL() + template } return c.GetApiBaseURL() + fmt.Sprintf(template, args...) } func (c *Client) addMaxDepthParam(params *url.Values, customMaxDepth *int) { maxDepth := c.MaxDepth if customMaxDepth != nil && *customMaxDepth > 0 { maxDepth = *customMaxDepth } if maxDepth != DEFAULT_MAX_DEPTH { params.Set("max_depth", strconv.Itoa(maxDepth)) } }