...

Source file src/github.com/bradleyfalzon/ghinstallation/v2/transport.go

Documentation: github.com/bradleyfalzon/ghinstallation/v2

     1  package ghinstallation
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"io/ioutil"
    10  	"net/http"
    11  	"strings"
    12  	"sync"
    13  	"time"
    14  
    15  	"github.com/google/go-github/v45/github"
    16  )
    17  
    18  const (
    19  	acceptHeader = "application/vnd.github.v3+json"
    20  	apiBaseURL   = "https://api.github.com"
    21  )
    22  
    23  // Transport provides a http.RoundTripper by wrapping an existing
    24  // http.RoundTripper and provides GitHub Apps authentication as an
    25  // installation.
    26  //
    27  // Client can also be overwritten, and is useful to change to one which
    28  // provides retry logic if you do experience retryable errors.
    29  //
    30  // See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/
    31  type Transport struct {
    32  	BaseURL                  string                           // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com
    33  	Client                   Client                           // Client to use to refresh tokens, defaults to http.Client with provided transport
    34  	tr                       http.RoundTripper                // tr is the underlying roundtripper being wrapped
    35  	appID                    int64                            // appID is the GitHub App's ID
    36  	installationID           int64                            // installationID is the GitHub App Installation ID
    37  	InstallationTokenOptions *github.InstallationTokenOptions // parameters restrict a token's access
    38  	appsTransport            *AppsTransport
    39  
    40  	mu    *sync.Mutex  // mu protects token
    41  	token *accessToken // token is the installation's access token
    42  }
    43  
    44  // accessToken is an installation access token response from GitHub
    45  type accessToken struct {
    46  	Token        string                         `json:"token"`
    47  	ExpiresAt    time.Time                      `json:"expires_at"`
    48  	Permissions  github.InstallationPermissions `json:"permissions,omitempty"`
    49  	Repositories []github.Repository            `json:"repositories,omitempty"`
    50  }
    51  
    52  // HTTPError represents a custom error for failing HTTP operations.
    53  // Example in our usecase: refresh access token operation.
    54  // It enables the caller to inspect the root cause and response.
    55  type HTTPError struct {
    56  	Message        string
    57  	RootCause      error
    58  	InstallationID int64
    59  	Response       *http.Response
    60  }
    61  
    62  func (e *HTTPError) Error() string {
    63  	return e.Message
    64  }
    65  
    66  var _ http.RoundTripper = &Transport{}
    67  
    68  // NewKeyFromFile returns a Transport using a private key from file.
    69  func NewKeyFromFile(tr http.RoundTripper, appID, installationID int64, privateKeyFile string) (*Transport, error) {
    70  	privateKey, err := ioutil.ReadFile(privateKeyFile)
    71  	if err != nil {
    72  		return nil, fmt.Errorf("could not read private key: %s", err)
    73  	}
    74  	return New(tr, appID, installationID, privateKey)
    75  }
    76  
    77  // Client is a HTTP client which sends a http.Request and returns a http.Response
    78  // or an error.
    79  type Client interface {
    80  	Do(*http.Request) (*http.Response, error)
    81  }
    82  
    83  // New returns an Transport using private key. The key is parsed
    84  // and if any errors occur the error is non-nil.
    85  //
    86  // The provided tr http.RoundTripper should be shared between multiple
    87  // installations to ensure reuse of underlying TCP connections.
    88  //
    89  // The returned Transport's RoundTrip method is safe to be used concurrently.
    90  func New(tr http.RoundTripper, appID, installationID int64, privateKey []byte) (*Transport, error) {
    91  	atr, err := NewAppsTransport(tr, appID, privateKey)
    92  	if err != nil {
    93  		return nil, err
    94  	}
    95  
    96  	return NewFromAppsTransport(atr, installationID), nil
    97  }
    98  
    99  // NewFromAppsTransport returns a Transport using an existing *AppsTransport.
   100  func NewFromAppsTransport(atr *AppsTransport, installationID int64) *Transport {
   101  	return &Transport{
   102  		BaseURL:        atr.BaseURL,
   103  		Client:         &http.Client{Transport: atr.tr},
   104  		tr:             atr.tr,
   105  		appID:          atr.appID,
   106  		installationID: installationID,
   107  		appsTransport:  atr,
   108  		mu:             &sync.Mutex{},
   109  	}
   110  }
   111  
   112  // RoundTrip implements http.RoundTripper interface.
   113  func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
   114  	reqBodyClosed := false
   115  	if req.Body != nil {
   116  		defer func() {
   117  			if !reqBodyClosed {
   118  				req.Body.Close()
   119  			}
   120  		}()
   121  	}
   122  	
   123  	token, err := t.Token(req.Context())
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	creq := cloneRequest(req) // per RoundTripper contract
   129  	creq.Header.Set("Authorization", "token "+token)
   130  	creq.Header.Add("Accept", acceptHeader) // We add to "Accept" header to avoid overwriting existing req headers.
   131  	reqBodyClosed = true // req.Body is assumed to be closed by the tr RoundTripper.
   132  	resp, err := t.tr.RoundTrip(creq)
   133  	return resp, err
   134  }
   135  
   136  // Token checks the active token expiration and renews if necessary. Token returns
   137  // a valid access token. If renewal fails an error is returned.
   138  func (t *Transport) Token(ctx context.Context) (string, error) {
   139  	t.mu.Lock()
   140  	defer t.mu.Unlock()
   141  	if t.token == nil || t.token.ExpiresAt.Add(-time.Minute).Before(time.Now()) {
   142  		// Token is not set or expired/nearly expired, so refresh
   143  		if err := t.refreshToken(ctx); err != nil {
   144  			return "", fmt.Errorf("could not refresh installation id %v's token: %w", t.installationID, err)
   145  		}
   146  	}
   147  
   148  	return t.token.Token, nil
   149  }
   150  
   151  // Permissions returns a transport token's GitHub installation permissions.
   152  func (t *Transport) Permissions() (github.InstallationPermissions, error) {
   153  	if t.token == nil {
   154  		return github.InstallationPermissions{}, fmt.Errorf("Permissions() = nil, err: nil token")
   155  	}
   156  	return t.token.Permissions, nil
   157  }
   158  
   159  // Repositories returns a transport token's GitHub repositories.
   160  func (t *Transport) Repositories() ([]github.Repository, error) {
   161  	if t.token == nil {
   162  		return nil, fmt.Errorf("Repositories() = nil, err: nil token")
   163  	}
   164  	return t.token.Repositories, nil
   165  }
   166  
   167  func (t *Transport) refreshToken(ctx context.Context) error {
   168  	// Convert InstallationTokenOptions into a ReadWriter to pass as an argument to http.NewRequest.
   169  	body, err := GetReadWriter(t.InstallationTokenOptions)
   170  	if err != nil {
   171  		return fmt.Errorf("could not convert installation token parameters into json: %s", err)
   172  	}
   173  
   174  	requestURL := fmt.Sprintf("%s/app/installations/%v/access_tokens", strings.TrimRight(t.BaseURL, "/"), t.installationID)
   175  	req, err := http.NewRequest("POST", requestURL, body)
   176  	if err != nil {
   177  		return fmt.Errorf("could not create request: %s", err)
   178  	}
   179  
   180  	// Set Content and Accept headers.
   181  	if body != nil {
   182  		req.Header.Set("Content-Type", "application/json")
   183  	}
   184  	req.Header.Set("Accept", acceptHeader)
   185  
   186  	if ctx != nil {
   187  		req = req.WithContext(ctx)
   188  	}
   189  
   190  	t.appsTransport.BaseURL = t.BaseURL
   191  	t.appsTransport.Client = t.Client
   192  	resp, err := t.appsTransport.RoundTrip(req)
   193  	e := &HTTPError{
   194  		RootCause:      err,
   195  		InstallationID: t.installationID,
   196  		Response:       resp,
   197  	}
   198  	if err != nil {
   199  		e.Message = fmt.Sprintf("could not get access_tokens from GitHub API for installation ID %v: %v", t.installationID, err)
   200  		return e
   201  	}
   202  
   203  	if resp.StatusCode/100 != 2 {
   204  		e.Message = fmt.Sprintf("received non 2xx response status %q when fetching %v", resp.Status, req.URL)
   205  		return e
   206  	}
   207  	// Closing body late, to provide caller a chance to inspect body in an error / non-200 response status situation
   208  	defer resp.Body.Close()
   209  
   210  	return json.NewDecoder(resp.Body).Decode(&t.token)
   211  }
   212  
   213  // GetReadWriter converts a body interface into an io.ReadWriter object.
   214  func GetReadWriter(i interface{}) (io.ReadWriter, error) {
   215  	var buf io.ReadWriter
   216  	if i != nil {
   217  		buf = new(bytes.Buffer)
   218  		enc := json.NewEncoder(buf)
   219  		err := enc.Encode(i)
   220  		if err != nil {
   221  			return nil, err
   222  		}
   223  	}
   224  	return buf, nil
   225  }
   226  
   227  // cloneRequest returns a clone of the provided *http.Request.
   228  // The clone is a shallow copy of the struct and its Header map.
   229  func cloneRequest(r *http.Request) *http.Request {
   230  	// shallow copy of the struct
   231  	r2 := new(http.Request)
   232  	*r2 = *r
   233  	// deep copy of the Header
   234  	r2.Header = make(http.Header, len(r.Header))
   235  	for k, s := range r.Header {
   236  		r2.Header[k] = append([]string(nil), s...)
   237  	}
   238  	return r2
   239  }
   240  

View as plain text