-
Notifications
You must be signed in to change notification settings - Fork 60
/
Copy pathdispatcher.go
288 lines (245 loc) · 9.22 KB
/
dispatcher.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
// 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"
"github.com/google/go-github/v70/github"
"github.com/pkg/errors"
"github.com/rcrowley/go-metrics"
"github.com/rs/zerolog"
)
const (
DefaultWebhookRoute string = "/api/github/hook"
)
type EventHandler interface {
// Handles returns a list of GitHub events that this handler handles
// See https://developer.github.com/v3/activity/events/types/
Handles() []string
// Handle processes the GitHub event "eventType" with the given delivery ID
// and payload. The EventDispatcher guarantees that the Handle method will
// only be called for the events returned by Handles().
//
// If Handle returns an error, processing stops and the error is passed
// directly to the configured error handler.
Handle(ctx context.Context, eventType, deliveryID string, payload []byte) error
}
// ErrorCallback is called when an event handler returns an error. The error
// from the handler is passed directly as the final argument.
type ErrorCallback func(w http.ResponseWriter, r *http.Request, err error)
// ResponseCallback is called to send a response to GitHub after an event is
// handled. It is passed the event type and a flag indicating if an event
// handler was called for the event.
type ResponseCallback func(w http.ResponseWriter, r *http.Request, event string, handled bool)
// DispatcherOption configures properties of an event dispatcher.
type DispatcherOption func(*eventDispatcher)
// WithErrorCallback sets the error callback for a dispatcher.
func WithErrorCallback(onError ErrorCallback) DispatcherOption {
return func(d *eventDispatcher) {
if onError != nil {
d.onError = onError
}
}
}
// WithResponseCallback sets the response callback for an event dispatcher.
func WithResponseCallback(onResponse ResponseCallback) DispatcherOption {
return func(d *eventDispatcher) {
if onResponse != nil {
d.onResponse = onResponse
}
}
}
// WithScheduler sets the scheduler used to process events. Setting a
// non-default scheduler can enable asynchronous processing. When a scheduler
// is asynchronous, the dispatcher validatates event payloads, queues valid
// events for handling, and then responds to GitHub without waiting for the
// handler to complete. This is useful when handlers may take longer than
// GitHub's timeout for webhook deliveries.
func WithScheduler(s Scheduler) DispatcherOption {
return func(d *eventDispatcher) {
if s != nil {
d.scheduler = s
}
}
}
// ValidationError is passed to error callbacks when the webhook payload fails
// validation.
type ValidationError struct {
EventType string
DeliveryID string
Cause error
}
func (ve ValidationError) Error() string {
return fmt.Sprintf("invalid event: %v", ve.Cause)
}
type eventDispatcher struct {
handlerMap map[string]EventHandler
secret string
scheduler Scheduler
onError ErrorCallback
onResponse ResponseCallback
}
// NewDefaultEventDispatcher is a convenience method to create an event
// dispatcher from configuration using the default error and response
// callbacks.
func NewDefaultEventDispatcher(c Config, handlers ...EventHandler) http.Handler {
return NewEventDispatcher(handlers, c.App.WebhookSecret)
}
// NewEventDispatcher creates an http.Handler that dispatches GitHub webhook
// requests to the appropriate event handlers. It validates payload integrity
// using the given secret value.
//
// Responses are controlled by optional error and response callbacks. If these
// options are not provided, default callbacks are used.
func NewEventDispatcher(handlers []EventHandler, secret string, opts ...DispatcherOption) http.Handler {
handlerMap := make(map[string]EventHandler)
// Iterate in reverse so the first entries in the slice have priority
for i := len(handlers) - 1; i >= 0; i-- {
for _, event := range handlers[i].Handles() {
handlerMap[event] = handlers[i]
}
}
d := &eventDispatcher{
handlerMap: handlerMap,
secret: secret,
scheduler: DefaultScheduler(),
onError: DefaultErrorCallback,
onResponse: DefaultResponseCallback,
}
for _, opt := range opts {
opt(d)
}
return d
}
// ServeHTTP processes a webhook request from GitHub.
func (d *eventDispatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// initialize context for SetResponder/GetResponder
ctx = InitializeResponder(ctx)
r = r.WithContext(ctx)
eventType := r.Header.Get("X-GitHub-Event")
deliveryID := r.Header.Get("X-GitHub-Delivery")
if eventType == "" {
d.onError(w, r, ValidationError{
EventType: eventType,
DeliveryID: deliveryID,
Cause: errors.New("missing event type"),
})
return
}
logger := zerolog.Ctx(ctx).With().
Str(LogKeyEventType, eventType).
Str(LogKeyDeliveryID, deliveryID).
Logger()
// initialize context with event logger
ctx = logger.WithContext(ctx)
r = r.WithContext(ctx)
payloadBytes, err := github.ValidatePayload(r, []byte(d.secret))
if err != nil {
d.onError(w, r, ValidationError{
EventType: eventType,
DeliveryID: deliveryID,
Cause: err,
})
return
}
logger.Debug().Msgf("Received webhook event")
handler, ok := d.handlerMap[eventType]
if ok {
if err := d.scheduler.Schedule(ctx, Dispatch{
Handler: handler,
EventType: eventType,
DeliveryID: deliveryID,
Payload: payloadBytes,
}); err != nil {
d.onError(w, r, err)
return
}
}
d.onResponse(w, r, eventType, ok)
}
// DefaultErrorCallback logs errors and responds with an appropriate status code.
func DefaultErrorCallback(w http.ResponseWriter, r *http.Request, err error) {
defaultErrorCallback(w, r, err)
}
var defaultErrorCallback = MetricsErrorCallback(nil)
// MetricsErrorCallback logs errors, increments an error counter, and responds
// with an appropriate status code.
func MetricsErrorCallback(reg metrics.Registry) ErrorCallback {
return func(w http.ResponseWriter, r *http.Request, err error) {
logger := zerolog.Ctx(r.Context())
var ve ValidationError
if errors.As(err, &ve) {
logger.Warn().Err(ve.Cause).Msgf("Received invalid webhook headers or payload")
http.Error(w, "Invalid webhook headers or payload", http.StatusBadRequest)
return
}
if errors.Is(err, ErrCapacityExceeded) {
logger.Warn().Msg("Dropping webhook event due to over-capacity scheduler")
http.Error(w, "No capacity available to processes this event", http.StatusServiceUnavailable)
return
}
logger.Error().Err(err).Msg("Unexpected error handling webhook")
errorCounter(reg, r.Header.Get("X-Github-Event")).Inc(1)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
// DefaultResponseCallback responds with a 200 OK for handled events and a 202
// Accepted status for all other events. By default, responses are empty.
// Event handlers may send custom responses by calling the SetResponder
// function before returning.
func DefaultResponseCallback(w http.ResponseWriter, r *http.Request, event string, handled bool) {
if !handled && event != "ping" {
w.WriteHeader(http.StatusAccepted)
return
}
if res := GetResponder(r.Context()); res != nil {
res(w, r)
} else {
w.WriteHeader(http.StatusOK)
}
}
type responderKey struct{}
// InitializeResponder prepares the context to work with SetResponder and
// GetResponder. It is used to test handlers that call SetResponder or to
// implement custom event dispatchers that support responders.
func InitializeResponder(ctx context.Context) context.Context {
var responder func(http.ResponseWriter, *http.Request)
return context.WithValue(ctx, responderKey{}, &responder)
}
// SetResponder sets a function that sends a response to GitHub after event
// processing completes. The context must be initialized by InitializeResponder.
// The event dispatcher does this automatically before calling a handler.
//
// Customizing individual handler responses should be rare. Applications that
// want to modify the standard responses should consider registering a response
// callback before using this function.
func SetResponder(ctx context.Context, responder func(http.ResponseWriter, *http.Request)) {
r, ok := ctx.Value(responderKey{}).(*func(http.ResponseWriter, *http.Request))
if !ok || r == nil {
panic("SetResponder() must be called with an initialized context, such as one from the event dispatcher")
}
*r = responder
}
// GetResponder returns the response function that was set by an event handler.
// If no response function exists, it returns nil. There is usually no reason
// to call this outside of a response callback implementation.
func GetResponder(ctx context.Context) func(http.ResponseWriter, *http.Request) {
r, ok := ctx.Value(responderKey{}).(*func(http.ResponseWriter, *http.Request))
if !ok || r == nil {
return nil
}
return *r
}