2018-06-02 00:37:01 +00:00
package jira
import (
"bytes"
2020-09-14 18:31:56 +00:00
"crypto/sha256"
"encoding/hex"
2018-06-02 00:37:01 +00:00
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
2020-09-14 18:31:56 +00:00
"sort"
"strings"
2018-06-02 00:37:01 +00:00
"time"
2020-09-14 18:31:56 +00:00
"github.com/dgrijalva/jwt-go"
2018-06-02 00:37:01 +00:00
"github.com/google/go-querystring/query"
"github.com/pkg/errors"
)
2020-09-14 18:31:56 +00:00
// httpClient defines an interface for an http.Client implementation so that alternative
// http Clients can be passed in for making requests
type httpClient interface {
Do ( request * http . Request ) ( response * http . Response , err error )
}
2018-06-02 00:37:01 +00:00
// A Client manages communication with the JIRA API.
type Client struct {
// HTTP client used to communicate with the API.
2020-09-14 18:31:56 +00:00
client httpClient
2018-06-02 00:37:01 +00:00
// Base URL for API requests.
baseURL * url . URL
2020-09-14 18:31:56 +00:00
// Session storage if the user authenticates with a Session cookie
2018-06-02 00:37:01 +00:00
session * Session
// Services used for talking to different parts of the JIRA API.
2020-09-14 18:31:56 +00:00
Authentication * AuthenticationService
Issue * IssueService
Project * ProjectService
Board * BoardService
Sprint * SprintService
User * UserService
Group * GroupService
Version * VersionService
Priority * PriorityService
Field * FieldService
Component * ComponentService
Resolution * ResolutionService
StatusCategory * StatusCategoryService
Filter * FilterService
Role * RoleService
PermissionScheme * PermissionSchemeService
Status * StatusService
IssueLinkType * IssueLinkTypeService
2018-06-02 00:37:01 +00:00
}
// NewClient returns a new JIRA API client.
// If a nil httpClient is provided, http.DefaultClient will be used.
// To use API methods which require authentication you can follow the preferred solution and
// provide an http.Client that will perform the authentication for you with OAuth and HTTP Basic (such as that provided by the golang.org/x/oauth2 library).
// As an alternative you can use Session Cookie based authentication provided by this package as well.
// See https://docs.atlassian.com/jira/REST/latest/#authentication
// baseURL is the HTTP endpoint of your JIRA instance and should always be specified with a trailing slash.
2020-09-14 18:31:56 +00:00
func NewClient ( httpClient httpClient , baseURL string ) ( * Client , error ) {
2018-06-02 00:37:01 +00:00
if httpClient == nil {
httpClient = http . DefaultClient
}
2020-09-14 18:31:56 +00:00
// ensure the baseURL contains a trailing slash so that all paths are preserved in later calls
if ! strings . HasSuffix ( baseURL , "/" ) {
baseURL += "/"
}
2018-06-02 00:37:01 +00:00
parsedBaseURL , err := url . Parse ( baseURL )
if err != nil {
return nil , err
}
c := & Client {
client : httpClient ,
baseURL : parsedBaseURL ,
}
c . Authentication = & AuthenticationService { client : c }
c . Issue = & IssueService { client : c }
c . Project = & ProjectService { client : c }
c . Board = & BoardService { client : c }
c . Sprint = & SprintService { client : c }
c . User = & UserService { client : c }
c . Group = & GroupService { client : c }
c . Version = & VersionService { client : c }
2020-09-14 18:31:56 +00:00
c . Priority = & PriorityService { client : c }
c . Field = & FieldService { client : c }
c . Component = & ComponentService { client : c }
c . Resolution = & ResolutionService { client : c }
c . StatusCategory = & StatusCategoryService { client : c }
c . Filter = & FilterService { client : c }
c . Role = & RoleService { client : c }
c . PermissionScheme = & PermissionSchemeService { client : c }
c . Status = & StatusService { client : c }
c . IssueLinkType = & IssueLinkTypeService { client : c }
2018-06-02 00:37:01 +00:00
return c , nil
}
// NewRawRequest creates an API request.
// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client.
// Allows using an optional native io.Reader for sourcing the request body.
func ( c * Client ) NewRawRequest ( method , urlStr string , body io . Reader ) ( * http . Request , error ) {
rel , err := url . Parse ( urlStr )
if err != nil {
return nil , err
}
2020-09-14 18:31:56 +00:00
// Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash
rel . Path = strings . TrimLeft ( rel . Path , "/" )
2018-06-02 00:37:01 +00:00
u := c . baseURL . ResolveReference ( rel )
req , err := http . NewRequest ( method , u . String ( ) , body )
if err != nil {
return nil , err
}
req . Header . Set ( "Content-Type" , "application/json" )
// Set authentication information
if c . Authentication . authType == authTypeSession {
// Set session cookie if there is one
if c . session != nil {
for _ , cookie := range c . session . Cookies {
req . AddCookie ( cookie )
}
}
} else if c . Authentication . authType == authTypeBasic {
// Set basic auth information
if c . Authentication . username != "" {
req . SetBasicAuth ( c . Authentication . username , c . Authentication . password )
}
}
return req , nil
}
// NewRequest creates an API request.
// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client.
// If specified, the value pointed to by body is JSON encoded and included as the request body.
func ( c * Client ) NewRequest ( method , urlStr string , body interface { } ) ( * http . Request , error ) {
rel , err := url . Parse ( urlStr )
if err != nil {
return nil , err
}
2020-09-14 18:31:56 +00:00
// Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash
rel . Path = strings . TrimLeft ( rel . Path , "/" )
2018-06-02 00:37:01 +00:00
u := c . baseURL . ResolveReference ( rel )
var buf io . ReadWriter
if body != nil {
buf = new ( bytes . Buffer )
err = json . NewEncoder ( buf ) . Encode ( body )
if err != nil {
return nil , err
}
}
req , err := http . NewRequest ( method , u . String ( ) , buf )
if err != nil {
return nil , err
}
req . Header . Set ( "Content-Type" , "application/json" )
// Set authentication information
if c . Authentication . authType == authTypeSession {
// Set session cookie if there is one
if c . session != nil {
for _ , cookie := range c . session . Cookies {
req . AddCookie ( cookie )
}
}
} else if c . Authentication . authType == authTypeBasic {
// Set basic auth information
if c . Authentication . username != "" {
req . SetBasicAuth ( c . Authentication . username , c . Authentication . password )
}
}
return req , nil
}
// addOptions adds the parameters in opt as URL query parameters to s. opt
// must be a struct whose fields may contain "url" tags.
func addOptions ( s string , opt interface { } ) ( string , error ) {
v := reflect . ValueOf ( opt )
if v . Kind ( ) == reflect . Ptr && v . IsNil ( ) {
return s , nil
}
u , err := url . Parse ( s )
if err != nil {
return s , err
}
qs , err := query . Values ( opt )
if err != nil {
return s , err
}
u . RawQuery = qs . Encode ( )
return u . String ( ) , nil
}
// NewMultiPartRequest creates an API request including a multi-part file.
// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client.
// If specified, the value pointed to by buf is a multipart form.
func ( c * Client ) NewMultiPartRequest ( method , urlStr string , buf * bytes . Buffer ) ( * http . Request , error ) {
rel , err := url . Parse ( urlStr )
if err != nil {
return nil , err
}
2020-09-14 18:31:56 +00:00
// Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash
rel . Path = strings . TrimLeft ( rel . Path , "/" )
2018-06-02 00:37:01 +00:00
u := c . baseURL . ResolveReference ( rel )
req , err := http . NewRequest ( method , u . String ( ) , buf )
if err != nil {
return nil , err
}
// Set required headers
req . Header . Set ( "X-Atlassian-Token" , "nocheck" )
// Set authentication information
if c . Authentication . authType == authTypeSession {
// Set session cookie if there is one
if c . session != nil {
for _ , cookie := range c . session . Cookies {
req . AddCookie ( cookie )
}
}
} else if c . Authentication . authType == authTypeBasic {
// Set basic auth information
if c . Authentication . username != "" {
req . SetBasicAuth ( c . Authentication . username , c . Authentication . password )
}
}
return req , nil
}
// Do sends an API request and returns the API response.
// The API response is JSON decoded and stored in the value pointed to by v, or returned as an error if an API error has occurred.
func ( c * Client ) Do ( req * http . Request , v interface { } ) ( * Response , error ) {
httpResp , err := c . client . Do ( req )
if err != nil {
return nil , err
}
err = CheckResponse ( httpResp )
if err != nil {
// Even though there was an error, we still return the response
// in case the caller wants to inspect it further
return newResponse ( httpResp , nil ) , err
}
if v != nil {
// Open a NewDecoder and defer closing the reader only if there is a provided interface to decode to
defer httpResp . Body . Close ( )
err = json . NewDecoder ( httpResp . Body ) . Decode ( v )
}
resp := newResponse ( httpResp , v )
return resp , err
}
// CheckResponse checks the API response for errors, and returns them if present.
// A response is considered an error if it has a status code outside the 200 range.
// The caller is responsible to analyze the response body.
// The body can contain JSON (if the error is intended) or xml (sometimes JIRA just failes).
func CheckResponse ( r * http . Response ) error {
if c := r . StatusCode ; 200 <= c && c <= 299 {
return nil
}
err := fmt . Errorf ( "Request failed. Please analyze the request body for more details. Status code: %d" , r . StatusCode )
return err
}
// GetBaseURL will return you the Base URL.
// This is the same URL as in the NewClient constructor
func ( c * Client ) GetBaseURL ( ) url . URL {
return * c . baseURL
}
// Response represents JIRA API response. It wraps http.Response returned from
// API and provides information about paging.
type Response struct {
* http . Response
StartAt int
MaxResults int
Total int
}
func newResponse ( r * http . Response , v interface { } ) * Response {
resp := & Response { Response : r }
resp . populatePageValues ( v )
return resp
}
// Sets paging values if response json was parsed to searchResult type
// (can be extended with other types if they also need paging info)
func ( r * Response ) populatePageValues ( v interface { } ) {
switch value := v . ( type ) {
case * searchResult :
r . StartAt = value . StartAt
r . MaxResults = value . MaxResults
r . Total = value . Total
case * groupMembersResult :
r . StartAt = value . StartAt
r . MaxResults = value . MaxResults
r . Total = value . Total
}
return
}
// BasicAuthTransport is an http.RoundTripper that authenticates all requests
// using HTTP Basic Authentication with the provided username and password.
type BasicAuthTransport struct {
Username string
Password string
// Transport is the underlying HTTP transport to use when making requests.
// It will default to http.DefaultTransport if nil.
Transport http . RoundTripper
}
// RoundTrip implements the RoundTripper interface. We just add the
// basic auth and return the RoundTripper for this transport type.
func ( t * BasicAuthTransport ) RoundTrip ( req * http . Request ) ( * http . Response , error ) {
req2 := cloneRequest ( req ) // per RoundTripper contract
req2 . SetBasicAuth ( t . Username , t . Password )
return t . transport ( ) . RoundTrip ( req2 )
}
// Client returns an *http.Client that makes requests that are authenticated
// using HTTP Basic Authentication. This is a nice little bit of sugar
// so we can just get the client instead of creating the client in the calling code.
// If it's necessary to send more information on client init, the calling code can
// always skip this and set the transport itself.
func ( t * BasicAuthTransport ) Client ( ) * http . Client {
return & http . Client { Transport : t }
}
func ( t * BasicAuthTransport ) transport ( ) http . RoundTripper {
if t . Transport != nil {
return t . Transport
}
return http . DefaultTransport
}
// CookieAuthTransport is an http.RoundTripper that authenticates all requests
// using Jira's cookie-based authentication.
//
2020-09-14 18:31:56 +00:00
// Note that it is generally preferable to use HTTP BASIC authentication with the REST API.
2018-06-02 00:37:01 +00:00
// However, this resource may be used to mimic the behaviour of JIRA's log-in page (e.g. to display log-in errors to a user).
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session
type CookieAuthTransport struct {
Username string
Password string
AuthURL string
// SessionObject is the authenticated cookie string.s
// It's passed in each call to prove the client is authenticated.
SessionObject [ ] * http . Cookie
// Transport is the underlying HTTP transport to use when making requests.
// It will default to http.DefaultTransport if nil.
Transport http . RoundTripper
}
// RoundTrip adds the session object to the request.
func ( t * CookieAuthTransport ) RoundTrip ( req * http . Request ) ( * http . Response , error ) {
if t . SessionObject == nil {
err := t . setSessionObject ( )
if err != nil {
return nil , errors . Wrap ( err , "cookieauth: no session object has been set" )
}
}
req2 := cloneRequest ( req ) // per RoundTripper contract
for _ , cookie := range t . SessionObject {
2020-09-14 18:31:56 +00:00
// Don't add an empty value cookie to the request
if cookie . Value != "" {
req2 . AddCookie ( cookie )
}
2018-06-02 00:37:01 +00:00
}
return t . transport ( ) . RoundTrip ( req2 )
}
// Client returns an *http.Client that makes requests that are authenticated
// using cookie authentication
func ( t * CookieAuthTransport ) Client ( ) * http . Client {
return & http . Client { Transport : t }
}
// setSessionObject attempts to authenticate the user and set
// the session object (e.g. cookie)
func ( t * CookieAuthTransport ) setSessionObject ( ) error {
req , err := t . buildAuthRequest ( )
if err != nil {
return err
}
var authClient = & http . Client {
Timeout : time . Second * 60 ,
}
resp , err := authClient . Do ( req )
if err != nil {
return err
}
t . SessionObject = resp . Cookies ( )
return nil
}
// getAuthRequest assembles the request to get the authenticated cookie
func ( t * CookieAuthTransport ) buildAuthRequest ( ) ( * http . Request , error ) {
body := struct {
Username string ` json:"username" `
Password string ` json:"password" `
} {
t . Username ,
t . Password ,
}
b := new ( bytes . Buffer )
json . NewEncoder ( b ) . Encode ( body )
req , err := http . NewRequest ( "POST" , t . AuthURL , b )
if err != nil {
return nil , err
}
req . Header . Set ( "Content-Type" , "application/json" )
return req , nil
}
func ( t * CookieAuthTransport ) transport ( ) http . RoundTripper {
if t . Transport != nil {
return t . Transport
}
return http . DefaultTransport
}
2020-09-14 18:31:56 +00:00
// JWTAuthTransport is an http.RoundTripper that authenticates all requests
// using Jira's JWT based authentication.
//
// NOTE: this form of auth should be used by add-ons installed from the Atlassian marketplace.
//
// JIRA docs: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt
// Examples in other languages:
// https://bitbucket.org/atlassian/atlassian-jwt-ruby/src/d44a8e7a4649e4f23edaa784402655fda7c816ea/lib/atlassian/jwt.rb
// https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/url_utils.py
type JWTAuthTransport struct {
Secret [ ] byte
Issuer string
// Transport is the underlying HTTP transport to use when making requests.
// It will default to http.DefaultTransport if nil.
Transport http . RoundTripper
}
func ( t * JWTAuthTransport ) Client ( ) * http . Client {
return & http . Client { Transport : t }
}
func ( t * JWTAuthTransport ) transport ( ) http . RoundTripper {
if t . Transport != nil {
return t . Transport
}
return http . DefaultTransport
}
// RoundTrip adds the session object to the request.
func ( t * JWTAuthTransport ) RoundTrip ( req * http . Request ) ( * http . Response , error ) {
req2 := cloneRequest ( req ) // per RoundTripper contract
exp := time . Duration ( 59 ) * time . Second
qsh := t . createQueryStringHash ( req . Method , req2 . URL )
token := jwt . NewWithClaims ( jwt . SigningMethodHS256 , jwt . MapClaims {
"iss" : t . Issuer ,
"iat" : time . Now ( ) . Unix ( ) ,
"exp" : time . Now ( ) . Add ( exp ) . Unix ( ) ,
"qsh" : qsh ,
} )
jwtStr , err := token . SignedString ( t . Secret )
if err != nil {
return nil , errors . Wrap ( err , "jwtAuth: error signing JWT" )
}
req2 . Header . Set ( "Authorization" , fmt . Sprintf ( "JWT %s" , jwtStr ) )
return t . transport ( ) . RoundTrip ( req2 )
}
func ( t * JWTAuthTransport ) createQueryStringHash ( httpMethod string , jiraURL * url . URL ) string {
canonicalRequest := t . canonicalizeRequest ( httpMethod , jiraURL )
h := sha256 . Sum256 ( [ ] byte ( canonicalRequest ) )
return hex . EncodeToString ( h [ : ] )
}
func ( t * JWTAuthTransport ) canonicalizeRequest ( httpMethod string , jiraURL * url . URL ) string {
path := "/" + strings . Replace ( strings . Trim ( jiraURL . Path , "/" ) , "&" , "%26" , - 1 )
var canonicalQueryString [ ] string
for k , v := range jiraURL . Query ( ) {
if k == "jwt" {
continue
}
param := url . QueryEscape ( k )
value := url . QueryEscape ( strings . Join ( v , "" ) )
canonicalQueryString = append ( canonicalQueryString , strings . Replace ( strings . Join ( [ ] string { param , value } , "=" ) , "+" , "%20" , - 1 ) )
}
sort . Strings ( canonicalQueryString )
return fmt . Sprintf ( "%s&%s&%s" , strings . ToUpper ( httpMethod ) , path , strings . Join ( canonicalQueryString , "&" ) )
}
2018-06-02 00:37:01 +00:00
// cloneRequest returns a clone of the provided *http.Request.
// The clone is a shallow copy of the struct and its Header map.
func cloneRequest ( r * http . Request ) * http . Request {
// shallow copy of the struct
r2 := new ( http . Request )
* r2 = * r
// deep copy of the Header
r2 . Header = make ( http . Header , len ( r . Header ) )
for k , s := range r . Header {
r2 . Header [ k ] = append ( [ ] string ( nil ) , s ... )
}
return r2
}