...

Source file src/github.com/palantir/go-githubapp/githubapp/dispatcher.go

Documentation: github.com/palantir/go-githubapp/githubapp

     1  // Copyright 2018 Palantir Technologies, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    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  	// Handles returns a list of GitHub events that this handler handles
    34  	// See https://developer.github.com/v3/activity/events/types/
    35  	Handles() []string
    36  
    37  	// Handle processes the GitHub event "eventType" with the given delivery ID
    38  	// and payload. The EventDispatcher guarantees that the Handle method will
    39  	// only be called for the events returned by Handles().
    40  	//
    41  	// If Handle returns an error, processing stops and the error is passed
    42  	// directly to the configured error handler.
    43  	Handle(ctx context.Context, eventType, deliveryID string, payload []byte) error
    44  }
    45  
    46  // ErrorCallback is called when an event handler returns an error. The error
    47  // from the handler is passed directly as the final argument.
    48  type ErrorCallback func(w http.ResponseWriter, r *http.Request, err error)
    49  
    50  // ResponseCallback is called to send a response to GitHub after an event is
    51  // handled. It is passed the event type and a flag indicating if an event
    52  // handler was called for the event.
    53  type ResponseCallback func(w http.ResponseWriter, r *http.Request, event string, handled bool)
    54  
    55  // DispatcherOption configures properties of an event dispatcher.
    56  type DispatcherOption func(*eventDispatcher)
    57  
    58  // WithErrorCallback sets the error callback for a dispatcher.
    59  func WithErrorCallback(onError ErrorCallback) DispatcherOption {
    60  	return func(d *eventDispatcher) {
    61  		if onError != nil {
    62  			d.onError = onError
    63  		}
    64  	}
    65  }
    66  
    67  // WithResponseCallback sets the response callback for an event dispatcher.
    68  func WithResponseCallback(onResponse ResponseCallback) DispatcherOption {
    69  	return func(d *eventDispatcher) {
    70  		if onResponse != nil {
    71  			d.onResponse = onResponse
    72  		}
    73  	}
    74  }
    75  
    76  // WithScheduler sets the scheduler used to process events. Setting a
    77  // non-default scheduler can enable asynchronous processing. When a scheduler
    78  // is asynchronous, the dispatcher validatates event payloads, queues valid
    79  // events for handling, and then responds to GitHub without waiting for the
    80  // handler to complete.  This is useful when handlers may take longer than
    81  // GitHub's timeout for webhook deliveries.
    82  func WithScheduler(s Scheduler) DispatcherOption {
    83  	return func(d *eventDispatcher) {
    84  		if s != nil {
    85  			d.scheduler = s
    86  		}
    87  	}
    88  }
    89  
    90  // ValidationError is passed to error callbacks when the webhook payload fails
    91  // validation.
    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  // NewDefaultEventDispatcher is a convenience method to create an event
   112  // dispatcher from configuration using the default error and response
   113  // callbacks.
   114  func NewDefaultEventDispatcher(c Config, handlers ...EventHandler) http.Handler {
   115  	return NewEventDispatcher(handlers, c.App.WebhookSecret)
   116  }
   117  
   118  // NewEventDispatcher creates an http.Handler that dispatches GitHub webhook
   119  // requests to the appropriate event handlers. It validates payload integrity
   120  // using the given secret value.
   121  //
   122  // Responses are controlled by optional error and response callbacks. If these
   123  // options are not provided, default callbacks are used.
   124  func NewEventDispatcher(handlers []EventHandler, secret string, opts ...DispatcherOption) http.Handler {
   125  	handlerMap := make(map[string]EventHandler)
   126  
   127  	// Iterate in reverse so the first entries in the slice have priority
   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  // ServeHTTP processes a webhook request from GitHub.
   150  func (d *eventDispatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   151  	ctx := r.Context()
   152  
   153  	// initialize context for SetResponder/GetResponder
   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  	// initialize context with event logger
   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  // DefaultErrorCallback logs errors and responds with an appropriate status code.
   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  // MetricsErrorCallback logs errors, increments an error counter, and responds
   213  // with an appropriate status code.
   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  // DefaultResponseCallback responds with a 200 OK for handled events and a 202
   238  // Accepted status for all other events. By default, responses are empty.
   239  // Event handlers may send custom responses by calling the SetResponder
   240  // function before returning.
   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  // InitializeResponder prepares the context to work with SetResponder and
   257  // GetResponder. It is used to test handlers that call SetResponder or to
   258  // implement custom event dispatchers that support responders.
   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  // SetResponder sets a function that sends a response to GitHub after event
   265  // processing completes. The context must be initialized by InitializeResponder.
   266  // The event dispatcher does this automatically before calling a handler.
   267  //
   268  // Customizing individual handler responses should be rare. Applications that
   269  // want to modify the standard responses should consider registering a response
   270  // callback before using this function.
   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  // GetResponder returns the response function that was set by an event handler.
   280  // If no response function exists, it returns nil. There is usually no reason
   281  // to call this outside of a response callback implementation.
   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