Skip to content

Commit 98335f1

Browse files
authored
Support custom responses from event handlers (#21)
This introduces two new mechanisms for customizing responses: * Clients can configure a response callback to send custom responses for all events. * Individual handlers may call SetResponder() to send custom responses in specific situations. While there should be little need to customize responses, I believe some combination of these two options will satisfy most use cases. Note that introducing the new callback changes the signature of NewEventDispatcher and some other types; most applications are not using these directly. On the other hand, I chose to introduce SetResponder() to avoid breaking the EventHandler interface, even though it is more idiomatic to either provide the handler with access to the request and response writer or allow it to return more information.
1 parent 1113332 commit 98335f1

File tree

3 files changed

+345
-30
lines changed

3 files changed

+345
-30
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,25 @@ secure `StateStore` implementation. `oauth2.SessionStateStore` is a good choice
251251
that uses [alexedwards/scs](https://github.com/alexedwards/scs) to store the
252252
state in a session.
253253

254+
## Customizing Webhook Responses
255+
256+
For most applications, the default responses should be sufficient: they use
257+
correct status codes and include enough information to match up GitHub delivery
258+
records with request logs. If your application has additional requirements for
259+
responses, two methods are provided for customization:
260+
261+
- Error responses can be modified with a custom error callback. Use the
262+
`WithErrorCallback` option when creating an event dispatcher.
263+
264+
- Non-error responses can be modified with a custom response callback. Use the
265+
`WithResponseCallback` option when creating an event dispatcher.
266+
267+
- Individual hook responses can be modified by calling the `SetResponder`
268+
function before the handler returns. Note that if you register a custom
269+
response handler as described above, you must make it aware of handler-level
270+
responders if you want to keep using `SetResponder`. See the default response
271+
callback for an example of how to implement this.
272+
254273
## Stability and Versioning Guarantees
255274

256275
While we've used this library to build multiple applications internally,

githubapp/dispatcher.go

Lines changed: 108 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,47 @@ type EventHandler interface {
4141
Handle(ctx context.Context, eventType, deliveryID string, payload []byte) error
4242
}
4343

44-
type ErrorHandler func(http.ResponseWriter, *http.Request, error)
44+
// ErrorCallback is called when an event handler returns an error. The error
45+
// from the handler is passed directly as the final argument.
46+
type ErrorCallback func(w http.ResponseWriter, r *http.Request, err error)
47+
48+
// ResponseCallback is called to send a response to GitHub after an event is
49+
// handled. It is passed the event type and a flag indicating if an event
50+
// handler was called for the event.
51+
type ResponseCallback func(w http.ResponseWriter, r *http.Request, event string, handled bool)
52+
53+
// DispatcherOption configures properties of an event dispatcher.
54+
type DispatcherOption func(*eventDispatcher)
55+
56+
// WithErrorCallback sets the error callback for an event dispatcher.
57+
func WithErrorCallback(onError ErrorCallback) DispatcherOption {
58+
return func(d *eventDispatcher) {
59+
if onError != nil {
60+
d.onError = onError
61+
}
62+
}
63+
}
64+
65+
// WithResponseCallback sets the response callback for an event dispatcher.
66+
func WithResponseCallback(onResponse ResponseCallback) DispatcherOption {
67+
return func(d *eventDispatcher) {
68+
if onResponse != nil {
69+
d.onResponse = onResponse
70+
}
71+
}
72+
}
4573

4674
type eventDispatcher struct {
4775
handlerMap map[string]EventHandler
4876
secret string
49-
onError ErrorHandler
77+
78+
onError ErrorCallback
79+
onResponse ResponseCallback
5080
}
5181

52-
// NewDefaultEventDispatcher is a convenience method to create an
53-
// EventDispatcher from configuration using the default error handler.
82+
// NewDefaultEventDispatcher is a convenience method to create an event
83+
// dispatcher from configuration using the default error and response
84+
// callbacks.
5485
func NewDefaultEventDispatcher(c Config, handlers ...EventHandler) http.Handler {
5586
return NewEventDispatcher(handlers, c.App.WebhookSecret, nil)
5687
}
@@ -59,10 +90,9 @@ func NewDefaultEventDispatcher(c Config, handlers ...EventHandler) http.Handler
5990
// requests to the appropriate event handlers. It validates payload integrity
6091
// using the given secret value.
6192
//
62-
// If an error occurs during handling, the error handler is called with the
63-
// error and should write an appropriate response. If the error handler is nil,
64-
// a default handler is used.
65-
func NewEventDispatcher(handlers []EventHandler, secret string, onError ErrorHandler) http.Handler {
93+
// Responses are controlled by optional error and response callbacks. If these
94+
// options are not provided, default callbacks are used.
95+
func NewEventDispatcher(handlers []EventHandler, secret string, opts ...DispatcherOption) http.Handler {
6696
handlerMap := make(map[string]EventHandler)
6797

6898
// Iterate in reverse so the first entries in the slice have priority
@@ -72,35 +102,46 @@ func NewEventDispatcher(handlers []EventHandler, secret string, onError ErrorHan
72102
}
73103
}
74104

75-
if onError == nil {
76-
onError = DefaultErrorHandler
77-
}
78-
79-
return &eventDispatcher{
105+
d := &eventDispatcher{
80106
handlerMap: handlerMap,
81107
secret: secret,
82-
onError: onError,
108+
onError: DefaultErrorCallback,
109+
onResponse: DefaultResponseCallback,
83110
}
111+
112+
for _, opt := range opts {
113+
opt(d)
114+
}
115+
116+
return d
84117
}
85118

86-
// ServeHTTP to implement http.Handler
119+
// ServeHTTP processes a webhook request from GitHub.
87120
func (d *eventDispatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
88121
ctx := r.Context()
89122

123+
// initialize context for SetResponder/GetResponder
124+
// we store a pointer in the context so that functions deeper in the call
125+
// tree can modify the value without creating a new context
126+
var responder func(http.ResponseWriter, *http.Request)
127+
ctx = context.WithValue(ctx, responderKey{}, &responder)
128+
r = r.WithContext(ctx)
129+
90130
eventType := r.Header.Get("X-GitHub-Event")
131+
deliveryID := r.Header.Get("X-GitHub-Delivery")
132+
91133
if eventType == "" {
92134
// ACK payload that was received but won't be processed
93135
w.WriteHeader(http.StatusAccepted)
94136
return
95137
}
96-
deliveryID := r.Header.Get("X-GitHub-Delivery")
97138

98139
logger := zerolog.Ctx(ctx).With().
99140
Str(LogKeyEventType, eventType).
100141
Str(LogKeyDeliveryID, deliveryID).
101142
Logger()
102143

103-
// update context and request to contain new log fields
144+
// initialize context with event logger
104145
ctx = logger.WithContext(ctx)
105146
r = r.WithContext(ctx)
106147

@@ -111,28 +152,65 @@ func (d *eventDispatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
111152
}
112153

113154
logger.Info().Msgf("Received webhook event")
114-
handler, ok := d.handlerMap[eventType]
115155

116-
switch {
117-
case ok:
156+
handler, ok := d.handlerMap[eventType]
157+
if ok {
118158
if err := handler.Handle(ctx, eventType, deliveryID, payloadBytes); err != nil {
119-
// pass error directly so handler can inspect types if needed
120159
d.onError(w, r, err)
121160
return
122161
}
123-
w.WriteHeader(http.StatusOK)
124-
case eventType == "ping":
125-
w.WriteHeader(http.StatusOK)
126-
default:
127-
w.WriteHeader(http.StatusAccepted)
128162
}
163+
d.onResponse(w, r, eventType, ok)
129164
}
130165

131-
// DefaultErrorHandler logs errors and responds with a 500 status code.
132-
func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
166+
// DefaultErrorCallback logs errors and responds with a 500 status code.
167+
func DefaultErrorCallback(w http.ResponseWriter, r *http.Request, err error) {
133168
logger := zerolog.Ctx(r.Context())
134169
logger.Error().Err(err).Msg("Unexpected error handling webhook request")
170+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
171+
}
135172

136-
msg := http.StatusText(http.StatusInternalServerError)
137-
http.Error(w, msg, http.StatusInternalServerError)
173+
// DefaultResponseCallback responds with a 200 OK for handled events and a 202
174+
// Accepted status for all other events. By default, responses are empty.
175+
// Event handlers may send custom responses by calling the SetResponder
176+
// function before returning.
177+
func DefaultResponseCallback(w http.ResponseWriter, r *http.Request, event string, handled bool) {
178+
if !handled && event != "ping" {
179+
w.WriteHeader(http.StatusAccepted)
180+
return
181+
}
182+
183+
if res := GetResponder(r.Context()); res != nil {
184+
res(w, r)
185+
} else {
186+
w.WriteHeader(http.StatusOK)
187+
}
188+
}
189+
190+
type responderKey struct{}
191+
192+
// SetResponder sets a function that sends a response to GitHub after event
193+
// processing completes. This function may only be called from event handler
194+
// functions invoked by the event dispatcher.
195+
//
196+
// Customizing individual handler responses should be rare. Applications that
197+
// want to modify the standard responses should consider registering a response
198+
// callback before using this function.
199+
func SetResponder(ctx context.Context, responder func(http.ResponseWriter, *http.Request)) {
200+
r, ok := ctx.Value(responderKey{}).(*func(http.ResponseWriter, *http.Request))
201+
if !ok || r == nil {
202+
panic("SetResponder() must be called from an event handler invoked by the go-githubapp event dispatcher")
203+
}
204+
*r = responder
205+
}
206+
207+
// GetResponder returns the response function that was set by an event handler.
208+
// If no response function exists, it returns nil. There is usually no reason
209+
// to call this outside of a response callback implementation.
210+
func GetResponder(ctx context.Context) func(http.ResponseWriter, *http.Request) {
211+
r, ok := ctx.Value(responderKey{}).(*func(http.ResponseWriter, *http.Request))
212+
if !ok || r == nil {
213+
return nil
214+
}
215+
return *r
138216
}

0 commit comments

Comments
 (0)