1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 package githubapp
16
17 import (
18 "context"
19 "fmt"
20 "net/http"
21
22 "github.com/google/go-github/v47/github"
23 "github.com/pkg/errors"
24 "github.com/rcrowley/go-metrics"
25 "github.com/rs/zerolog"
26 )
27
28 const (
29 DefaultWebhookRoute string = "/api/github/hook"
30 )
31
32 type EventHandler interface {
33
34
35 Handles() []string
36
37
38
39
40
41
42
43 Handle(ctx context.Context, eventType, deliveryID string, payload []byte) error
44 }
45
46
47
48 type ErrorCallback func(w http.ResponseWriter, r *http.Request, err error)
49
50
51
52
53 type ResponseCallback func(w http.ResponseWriter, r *http.Request, event string, handled bool)
54
55
56 type DispatcherOption func(*eventDispatcher)
57
58
59 func WithErrorCallback(onError ErrorCallback) DispatcherOption {
60 return func(d *eventDispatcher) {
61 if onError != nil {
62 d.onError = onError
63 }
64 }
65 }
66
67
68 func WithResponseCallback(onResponse ResponseCallback) DispatcherOption {
69 return func(d *eventDispatcher) {
70 if onResponse != nil {
71 d.onResponse = onResponse
72 }
73 }
74 }
75
76
77
78
79
80
81
82 func WithScheduler(s Scheduler) DispatcherOption {
83 return func(d *eventDispatcher) {
84 if s != nil {
85 d.scheduler = s
86 }
87 }
88 }
89
90
91
92 type ValidationError struct {
93 EventType string
94 DeliveryID string
95 Cause error
96 }
97
98 func (ve ValidationError) Error() string {
99 return fmt.Sprintf("invalid event: %v", ve.Cause)
100 }
101
102 type eventDispatcher struct {
103 handlerMap map[string]EventHandler
104 secret string
105
106 scheduler Scheduler
107 onError ErrorCallback
108 onResponse ResponseCallback
109 }
110
111
112
113
114 func NewDefaultEventDispatcher(c Config, handlers ...EventHandler) http.Handler {
115 return NewEventDispatcher(handlers, c.App.WebhookSecret)
116 }
117
118
119
120
121
122
123
124 func NewEventDispatcher(handlers []EventHandler, secret string, opts ...DispatcherOption) http.Handler {
125 handlerMap := make(map[string]EventHandler)
126
127
128 for i := len(handlers) - 1; i >= 0; i-- {
129 for _, event := range handlers[i].Handles() {
130 handlerMap[event] = handlers[i]
131 }
132 }
133
134 d := &eventDispatcher{
135 handlerMap: handlerMap,
136 secret: secret,
137 scheduler: DefaultScheduler(),
138 onError: DefaultErrorCallback,
139 onResponse: DefaultResponseCallback,
140 }
141
142 for _, opt := range opts {
143 opt(d)
144 }
145
146 return d
147 }
148
149
150 func (d *eventDispatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
151 ctx := r.Context()
152
153
154 ctx = InitializeResponder(ctx)
155 r = r.WithContext(ctx)
156
157 eventType := r.Header.Get("X-GitHub-Event")
158 deliveryID := r.Header.Get("X-GitHub-Delivery")
159
160 if eventType == "" {
161 d.onError(w, r, ValidationError{
162 EventType: eventType,
163 DeliveryID: deliveryID,
164 Cause: errors.New("missing event type"),
165 })
166 return
167 }
168
169 logger := zerolog.Ctx(ctx).With().
170 Str(LogKeyEventType, eventType).
171 Str(LogKeyDeliveryID, deliveryID).
172 Logger()
173
174
175 ctx = logger.WithContext(ctx)
176 r = r.WithContext(ctx)
177
178 payloadBytes, err := github.ValidatePayload(r, []byte(d.secret))
179 if err != nil {
180 d.onError(w, r, ValidationError{
181 EventType: eventType,
182 DeliveryID: deliveryID,
183 Cause: err,
184 })
185 return
186 }
187
188 logger.Info().Msgf("Received webhook event")
189
190 handler, ok := d.handlerMap[eventType]
191 if ok {
192 if err := d.scheduler.Schedule(ctx, Dispatch{
193 Handler: handler,
194 EventType: eventType,
195 DeliveryID: deliveryID,
196 Payload: payloadBytes,
197 }); err != nil {
198 d.onError(w, r, err)
199 return
200 }
201 }
202 d.onResponse(w, r, eventType, ok)
203 }
204
205
206 func DefaultErrorCallback(w http.ResponseWriter, r *http.Request, err error) {
207 defaultErrorCallback(w, r, err)
208 }
209
210 var defaultErrorCallback = MetricsErrorCallback(nil)
211
212
213
214 func MetricsErrorCallback(reg metrics.Registry) ErrorCallback {
215 return func(w http.ResponseWriter, r *http.Request, err error) {
216 logger := zerolog.Ctx(r.Context())
217
218 var ve ValidationError
219 if errors.As(err, &ve) {
220 logger.Warn().Err(ve.Cause).Msgf("Received invalid webhook headers or payload")
221 http.Error(w, "Invalid webhook headers or payload", http.StatusBadRequest)
222 return
223 }
224 if errors.Is(err, ErrCapacityExceeded) {
225 logger.Warn().Msg("Dropping webhook event due to over-capacity scheduler")
226 http.Error(w, "No capacity available to processes this event", http.StatusServiceUnavailable)
227 return
228 }
229
230 logger.Error().Err(err).Msg("Unexpected error handling webhook")
231 errorCounter(reg, r.Header.Get("X-Github-Event")).Inc(1)
232
233 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
234 }
235 }
236
237
238
239
240
241 func DefaultResponseCallback(w http.ResponseWriter, r *http.Request, event string, handled bool) {
242 if !handled && event != "ping" {
243 w.WriteHeader(http.StatusAccepted)
244 return
245 }
246
247 if res := GetResponder(r.Context()); res != nil {
248 res(w, r)
249 } else {
250 w.WriteHeader(http.StatusOK)
251 }
252 }
253
254 type responderKey struct{}
255
256
257
258
259 func InitializeResponder(ctx context.Context) context.Context {
260 var responder func(http.ResponseWriter, *http.Request)
261 return context.WithValue(ctx, responderKey{}, &responder)
262 }
263
264
265
266
267
268
269
270
271 func SetResponder(ctx context.Context, responder func(http.ResponseWriter, *http.Request)) {
272 r, ok := ctx.Value(responderKey{}).(*func(http.ResponseWriter, *http.Request))
273 if !ok || r == nil {
274 panic("SetResponder() must be called with an initialized context, such as one from the event dispatcher")
275 }
276 *r = responder
277 }
278
279
280
281
282 func GetResponder(ctx context.Context) func(http.ResponseWriter, *http.Request) {
283 r, ok := ctx.Value(responderKey{}).(*func(http.ResponseWriter, *http.Request))
284 if !ok || r == nil {
285 return nil
286 }
287 return *r
288 }
289
View as plain text