-
Notifications
You must be signed in to change notification settings - Fork 60
/
Copy pathclient_creator.go
438 lines (374 loc) · 15 KB
/
client_creator.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
// Copyright 2018 Palantir Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package githubapp
import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v70/github"
"github.com/gregjones/httpcache"
"github.com/pkg/errors"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
)
type ClientCreator interface {
// NewAppClient returns a new github.Client that performs app authentication for
// the GitHub app with a specific integration ID and private key. The returned
// client makes all calls using the application's authorization token. The
// client gets that token by creating and signing a JWT for the application and
// requesting a token using it. The token is cached by the client and is
// refreshed as needed if it expires.
//
// Used for performing app-level operations that are not associated with a
// specific installation.
//
// See the following for more information:
// * https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
//
// Authenticating as a GitHub App lets you do a couple of things:
// * You can retrieve high-level management information about your GitHub App.
// * You can request access tokens for an installation of the app.
//
// Tips for determining the arguments for this function:
// * the integration ID is listed as "ID" in the "About" section of the app's page
// * the key bytes must be a PEM-encoded PKCS1 or PKCS8 private key for the application
NewAppClient() (*github.Client, error)
// NewAppV4Client returns an app-authenticated v4 API client, similar to NewAppClient.
NewAppV4Client() (*githubv4.Client, error)
// NewInstallationClient returns a new github.Client that performs app
// authentication for the GitHub app with the a specific integration ID, private
// key, and the given installation ID. The returned client makes all calls using
// the application's authorization token. The client gets that token by creating
// and signing a JWT for the application and requesting a token using it. The
// token is cached by the client and is refreshed as needed if it expires.
//
// See the following for more information:
// * https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-an-installation
//
// Authenticating as an installation of a Github App lets you perform the following:
// * https://developer.github.com/v3/apps/available-endpoints/
//
// Tips for determining the arguments for this function:
// * the integration ID is listed as "ID" in the "About" section of the app's page
// * the installation ID is the ID that is shown in the URL of https://{githubURL}/settings/installations/{#}
// (navigate to the "installations" page without the # and go to the app's page to see the number)
// * the key bytes must be a PEM-encoded PKCS1 or PKCS8 private key for the application
NewInstallationClient(installationID int64) (*github.Client, error)
// NewInstallationV4Client returns an installation-authenticated v4 API client, similar to NewInstallationClient.
NewInstallationV4Client(installationID int64) (*githubv4.Client, error)
// NewTokenSourceClient returns a *github.Client that uses the passed in OAuth token source for authentication.
NewTokenSourceClient(ts oauth2.TokenSource) (*github.Client, error)
// NewTokenSourceClient returns a *githubv4.Client that uses the passed in OAuth token source for authentication.
NewTokenSourceV4Client(ts oauth2.TokenSource) (*githubv4.Client, error)
// NewTokenClient returns a *github.Client that uses the passed in OAuth token for authentication.
NewTokenClient(token string) (*github.Client, error)
// NewTokenV4Client returns a *githubv4.Client that uses the passed in OAuth token for authentication.
NewTokenV4Client(token string) (*githubv4.Client, error)
}
var (
maxAgeRegex = regexp.MustCompile(`max-age=\d+`)
)
type key string
const installationKey = key("installationID")
// NewClientCreator returns a ClientCreator that creates a GitHub client for
// installations of the app specified by the provided arguments.
func NewClientCreator(v3BaseURL, v4BaseURL string, integrationID int64, privKeyBytes []byte, opts ...ClientOption) ClientCreator {
cc := &clientCreator{
v3BaseURL: v3BaseURL,
v4BaseURL: v4BaseURL,
integrationID: integrationID,
privKeyBytes: privKeyBytes,
}
for _, opt := range opts {
opt(cc)
}
if !strings.HasSuffix(cc.v3BaseURL, "/") {
cc.v3BaseURL += "/"
}
// graphql URL should not end in trailing slash
cc.v4BaseURL = strings.TrimSuffix(cc.v4BaseURL, "/")
return cc
}
type clientCreator struct {
v3BaseURL string
v4BaseURL string
integrationID int64
privKeyBytes []byte
userAgent string
middleware []ClientMiddleware
cacheFunc func() httpcache.Cache
alwaysValidate bool
timeout time.Duration
transport http.RoundTripper
}
var _ ClientCreator = &clientCreator{}
type ClientOption func(c *clientCreator)
// ClientMiddleware modifies the transport of a GitHub client to add common
// functionality, like logging or metrics collection.
type ClientMiddleware func(http.RoundTripper) http.RoundTripper
// WithClientUserAgent sets the base user agent for all created clients.
func WithClientUserAgent(agent string) ClientOption {
return func(c *clientCreator) {
c.userAgent = agent
}
}
// WithClientCaching sets an HTTP cache for all created clients
// using the provided cache implementation
// If alwaysValidate is true, the cache validates all saved responses before returning them.
// Otherwise, it respects the caching headers returned by GitHub.
// https://developer.github.com/v3/#conditional-requests
func WithClientCaching(alwaysValidate bool, cache func() httpcache.Cache) ClientOption {
return func(c *clientCreator) {
c.cacheFunc = cache
c.alwaysValidate = alwaysValidate
}
}
// WithClientTimeout sets a request timeout for requests made by all created clients.
func WithClientTimeout(timeout time.Duration) ClientOption {
return func(c *clientCreator) {
c.timeout = timeout
}
}
// WithClientMiddleware adds middleware that is applied to all created clients.
func WithClientMiddleware(middleware ...ClientMiddleware) ClientOption {
return func(c *clientCreator) {
c.middleware = middleware
}
}
// WithTransport sets the http.RoundTripper used to make requests. Clients can
// provide an http.Transport instance to modify TLS, proxy, or timeout options.
// By default, clients use http.DefaultTransport.
func WithTransport(transport http.RoundTripper) ClientOption {
return func(c *clientCreator) {
c.transport = transport
}
}
func (c *clientCreator) NewAppClient() (*github.Client, error) {
base := c.newHTTPClient()
installation, transportError := newAppInstallation(c.integrationID, c.privKeyBytes, c.v3BaseURL)
middleware := []ClientMiddleware{installation}
if c.cacheFunc != nil {
middleware = append(middleware, cache(c.cacheFunc), cacheControl(c.alwaysValidate))
}
client, err := c.newClient(base, middleware, "application", 0)
if err != nil {
return nil, err
}
if *transportError != nil {
return nil, *transportError
}
return client, nil
}
func (c *clientCreator) NewAppV4Client() (*githubv4.Client, error) {
base := c.newHTTPClient()
installation, transportError := newAppInstallation(c.integrationID, c.privKeyBytes, c.v3BaseURL)
// The v4 API primarily uses POST requests (except for introspection queries)
// which we cannot cache, so don't add the cache middleware
middleware := []ClientMiddleware{installation}
client, err := c.newV4Client(base, middleware, "application")
if err != nil {
return nil, err
}
if *transportError != nil {
return nil, *transportError
}
return client, nil
}
func (c *clientCreator) NewInstallationClient(installationID int64) (*github.Client, error) {
base := c.newHTTPClient()
installation, transportError := newInstallation(c.integrationID, installationID, c.privKeyBytes, c.v3BaseURL)
middleware := []ClientMiddleware{installation}
if c.cacheFunc != nil {
middleware = append(middleware, cache(c.cacheFunc), cacheControl(c.alwaysValidate))
}
client, err := c.newClient(base, middleware, fmt.Sprintf("installation: %d", installationID), installationID)
if err != nil {
return nil, err
}
if *transportError != nil {
return nil, *transportError
}
return client, nil
}
func (c *clientCreator) NewInstallationV4Client(installationID int64) (*githubv4.Client, error) {
base := c.newHTTPClient()
installation, transportError := newInstallation(c.integrationID, installationID, c.privKeyBytes, c.v3BaseURL)
// The v4 API primarily uses POST requests (except for introspection queries)
// which we cannot cache, so don't construct the middleware
middleware := []ClientMiddleware{installation}
client, err := c.newV4Client(base, middleware, fmt.Sprintf("installation: %d", installationID))
if err != nil {
return nil, err
}
if *transportError != nil {
return nil, *transportError
}
return client, nil
}
func (c *clientCreator) NewTokenClient(token string) (*github.Client, error) {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
return c.NewTokenSourceClient(ts)
}
func (c *clientCreator) NewTokenSourceClient(ts oauth2.TokenSource) (*github.Client, error) {
tc := oauth2.NewClient(context.Background(), ts)
middleware := []ClientMiddleware{}
if c.cacheFunc != nil {
middleware = append(middleware, cache(c.cacheFunc), cacheControl(c.alwaysValidate))
}
return c.newClient(tc, middleware, "oauth token", 0)
}
func (c *clientCreator) NewTokenV4Client(token string) (*githubv4.Client, error) {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
return c.NewTokenSourceV4Client(ts)
}
func (c *clientCreator) NewTokenSourceV4Client(ts oauth2.TokenSource) (*githubv4.Client, error) {
tc := oauth2.NewClient(context.Background(), ts)
// The v4 API primarily uses POST requests (except for introspection queries)
// which we cannot cache, so don't construct the middleware
return c.newV4Client(tc, nil, "oauth token")
}
func (c *clientCreator) newHTTPClient() *http.Client {
transport := c.transport
if transport == nil {
// While http.Client will use the default when given a a nil transport,
// we assume a non-nil transport when applying middleware
transport = http.DefaultTransport
}
return &http.Client{
Transport: transport,
Timeout: c.timeout,
}
}
func (c *clientCreator) newClient(base *http.Client, middleware []ClientMiddleware, details string, installID int64) (*github.Client, error) {
applyMiddleware(base, [][]ClientMiddleware{
{setInstallationID(installID)},
c.middleware,
middleware,
})
baseURL, err := url.Parse(c.v3BaseURL)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse base URL: %q", c.v3BaseURL)
}
client := github.NewClient(base)
client.BaseURL = baseURL
client.UserAgent = makeUserAgent(c.userAgent, details)
return client, nil
}
func (c *clientCreator) newV4Client(base *http.Client, middleware []ClientMiddleware, details string) (*githubv4.Client, error) {
applyMiddleware(base, [][]ClientMiddleware{
{setUserAgentHeader(makeUserAgent(c.userAgent, details))},
c.middleware,
middleware,
})
v4BaseURL, err := url.Parse(c.v4BaseURL)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse base URL: %q", c.v4BaseURL)
}
client := githubv4.NewEnterpriseClient(v4BaseURL.String(), base)
return client, nil
}
// applyMiddleware behaves as if it concatenates all middleware slices in the
// order given and then composes the middleware so that the first element is
// the outermost function and the last element is the innermost function.
func applyMiddleware(base *http.Client, middleware [][]ClientMiddleware) {
for i := len(middleware) - 1; i >= 0; i-- {
for j := len(middleware[i]) - 1; j >= 0; j-- {
base.Transport = middleware[i][j](base.Transport)
}
}
}
func newAppInstallation(integrationID int64, privKeyBytes []byte, v3BaseURL string) (ClientMiddleware, *error) {
var transportError error
installation := func(next http.RoundTripper) http.RoundTripper {
itr, err := ghinstallation.NewAppsTransport(next, integrationID, privKeyBytes)
if err != nil {
transportError = err
return next
}
// leaving the v3 URL since this is used to refresh the token, not make queries
itr.BaseURL = strings.TrimSuffix(v3BaseURL, "/")
return itr
}
return installation, &transportError
}
func newInstallation(integrationID, installationID int64, privKeyBytes []byte, v3BaseURL string) (ClientMiddleware, *error) {
var transportError error
installation := func(next http.RoundTripper) http.RoundTripper {
itr, err := ghinstallation.New(next, integrationID, installationID, privKeyBytes)
if err != nil {
transportError = err
return next
}
// leaving the v3 URL since this is used to refresh the token, not make queries
itr.BaseURL = strings.TrimSuffix(v3BaseURL, "/")
return itr
}
return installation, &transportError
}
func cache(cacheFunc func() httpcache.Cache) ClientMiddleware {
return func(next http.RoundTripper) http.RoundTripper {
return &httpcache.Transport{
Transport: next,
Cache: cacheFunc(),
MarkCachedResponses: true,
}
}
}
func cacheControl(alwaysValidate bool) ClientMiddleware {
return func(next http.RoundTripper) http.RoundTripper {
if !alwaysValidate {
return next
}
// Force validation to occur when the cache is disabled by setting
// max-age=0 so that cached results will always appear to be stale
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp, err := next.RoundTrip(r)
if resp != nil {
cacheControl := resp.Header.Get("Cache-Control")
if cacheControl != "" {
newCacheControl := maxAgeRegex.ReplaceAllString(cacheControl, "max-age=0")
resp.Header.Set("Cache-Control", newCacheControl)
}
}
return resp, err
})
}
}
func makeUserAgent(base, details string) string {
if base == "" {
base = "github-base-app/undefined"
}
return fmt.Sprintf("%s (%s)", base, details)
}
func setInstallationID(installationID int64) ClientMiddleware {
return func(next http.RoundTripper) http.RoundTripper {
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
r = r.WithContext(context.WithValue(r.Context(), installationKey, installationID))
return next.RoundTrip(r)
})
}
}
func setUserAgentHeader(agent string) ClientMiddleware {
return func(next http.RoundTripper) http.RoundTripper {
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
r.Header.Set("User-Agent", agent)
return next.RoundTrip(r)
})
}
}