More than a year ago I've written about supporting Server-Sent Events in ASP.NET Core. Since that time I've been maintaining a middleware providing such support. I wasn't doing that just as a hobby, I've been using this middleware in several applications. Somehow all of them were deployed with Kestrel on the edge (internal infrastructure etc.). Only recently I've deployed first real-life application with this middleware to Azure. After few days issues started to appear. Users were stopping receiving updates after some (indeterministic) time. There was also no correlation between users actions and SSE connection drop. I had to look for answer in App Service log. I've found it, a 502.3 coming from IIS. So this was it, but what it was? I had to perform further research to discover the root cause. Long story short, I've learned that ANCM (the module which is used to run Kestrel behind IIS) has a request timeout of 120 seconds, which is measured from last activity. In another words, if there is a long living connection and it doesn't send anything for 2 minutes, it will be killed by ANCM. I needed to mitigate that.

Keepalives to the rescue

There is a known solution for scenarios where we want to prevent a network connection from being teardown due to inactivity - keepalives. The mechanism is simple, the side which wants to uphold the connection sends a control message at predefined intervals. Many protocols have built-in support for keepalives. TCP has optional keepalive which can be enabled per connection. WebSockets provide ping and pong frames for easy on-demand keepalive. Server-Sent Events doesn't provide anything dedicated, but it can be implemented easily.

Implementing keepalives

Keepalives require a long running background task, in case of ASP.NET Core that calls for BackgroundService or IHostedService implementation. I wanted my middleware to target ASP.NET Core 2.0.0 so I went with IHostedService. One special case I needed to handle was having multiple instances of the service. The ServerSentEventsMiddleware can be registered at multiple endpoints and in such scenario it uses different implementations of ServerSentEventsService to isolate those endpoints. This means that every implementation of ServerSentEventsService requires its own instance of IHostedService and separated set of options. Generics to the rescue.

public class ServerSentEventsServiceOptions<TServerSentEventsService>
    where TServerSentEventsService : ServerSentEventsService
{
}
internal class ServerSentEventsKeepaliveService<TServerSentEventsService> : IHostedService, IDisposable
    where TServerSentEventsService : ServerSentEventsService
{
    ...

    private readonly ServerSentEventsServiceOptions<TServerSentEventsService> _options;
    private readonly TServerSentEventsService _serverSentEventsService;

    public ServerSentEventsKeepaliveService(TServerSentEventsService serverSentEventsService,
        IOptions<ServerSentEventsServiceOptions<TServerSentEventsService>> options)
    {
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _serverSentEventsService = serverSentEventsService;
    }

    ...
}

The simplest thing which can be send as keepalive is a single comment line (for example : KEEPALIVE). This is correct from protocol point of view, requires the smallest amount of data to be send and will be ignored by browser.

public class ServerSentEventsServiceOptions<TServerSentEventsService>
    where TServerSentEventsService : ServerSentEventsService
{
    public int KeepaliveInterval { get; set; }  = 30;
}
internal class ServerSentEventsKeepaliveService<TServerSentEventsService> : IHostedService, IDisposable
    where TServerSentEventsService : ServerSentEventsService
{
    ...

    private readonly static ServerSentEventBytes _keepaliveServerSentEventBytes =
        ServerSentEventsHelper.GetCommentBytes("KEEPALIVE");

    ...

    private async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await _serverSentEventsService.SendEventAsync(_keepaliveServerSentEventBytes);

            await Task.Delay(TimeSpan.FromSeconds(_options.KeepaliveInterval), stoppingToken);
        }
    }
}

The last thing left to do is tying up ServerSentEventsService registration with ServerSentEventsKeepaliveService registration.

public static IServiceCollection AddServerSentEvents<TIServerSentEventsService, TServerSentEventsService>(
    this IServiceCollection services,
    Action<ServerSentEventsServiceOptions<TServerSentEventsService>> configureOptions)
    where TIServerSentEventsService : class, IServerSentEventsService
    where TServerSentEventsService : ServerSentEventsService, TIServerSentEventsService
{
    ...

    services.AddSingleton<TServerSentEventsService>();
    services.AddSingleton<TIServerSentEventsService>(serviceProvider =>
        serviceProvider.GetService<TServerSentEventsService>());

    services.Configure(configureOptions);
    services.AddSingleton<IHostedService, ServerSentEventsKeepaliveService<TServerSentEventsService>>();

    return services;
}

Sending keepalives when behind ANCM

It's nice when libraries have useful defaults. Keepalives are needed when application runs behind ANCM, but in other scenarios not necessary. It would be great if that could be the default behaviour.

public enum ServerSentEventsKeepaliveMode
{
    Always,
    Never,
    BehindAncm
}
public class ServerSentEventsServiceOptions<TServerSentEventsService>
    where TServerSentEventsService : ServerSentEventsService
{
    public ServerSentEventsKeepaliveMode KeepaliveMode { get; set; } = ServerSentEventsKeepaliveMode.BehindAncm;

    ...
}

The only requirement is ability to detect the ANCM. I had hard time finding out how to do that, luckily David Fowler gave me a hand. This allowed me to add a check to StartAsync.

internal class ServerSentEventsKeepaliveService<TServerSentEventsService> : IHostedService, IDisposable
    where TServerSentEventsService : ServerSentEventsService
{
    ...

    public Task StartAsync(CancellationToken cancellationToken)
    {
        if ((_options.KeepaliveMode == ServerSentEventsKeepaliveMode.Always)
            || ((_options.KeepaliveMode == ServerSentEventsKeepaliveMode.BehindAncm) && IsBehindAncm()))
        {
            _executingTask = ExecuteAsync(_stoppingCts.Token);

            if (_executingTask.IsCompleted)
            {
                return _executingTask;
            }
        }

        return Task.CompletedTask;
    }

    ...

    private static bool IsBehindAncm()
    {
        return !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_PORT"))
            && !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_APPL_PATH"))
            && !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_TOKEN"));
    }
}

This gave me exactly what I wanted and hopefully made my library more useful for others.

For the past last posts I was looking at different mechanisms hiding under the hood of ASP.NET Core WebHooks. They were all part of processing which happens before a mapped action is executed. When the action is about to be executed, there is one more thing which needs to be done - binding the parameters. This will be the subject of fifth post in this series:

ASP.NET Core WebHooks are capable of binding several different parameters thanks to WebHookBindingInfoProvider application model. Let's take a look at it.

Built-in binding capabilities

The WebHookBindingInfoProvider application model has single responsibility, it sets the binding information for parameters. It iterates through all action parameters, skip those which already have binding information (for example in result of using model binding attributes) and checks if parameter meet conditions for one of the four supported:

  • If the parameter name is receiver, receiverName or webHookReceiver (case ignored) and its type is String, it will be set to bind the receiver name from path.
  • If the parameter name is id or receiverId and its type is String, it will be set to bind the id from path.
  • If the parameter name is action, actions, actionName, actionNames, event, events, eventName or eventNames and its type is String or IEnumerable<String> implementation, it will be set to bind the event from path.
  • If the parameter name is data it will be set to bind from body as it would be decorated with FromFormAttribute (if the body type in metadata has been set to WebHookBodyType.Form) or FromBodyAttribute (if the body type in metadata has been set to WebHookBodyType.Json or WebHookBodyType.Xml).

If the parameter haven't meet conditions for any of the supported ones, the WebHookBindingInfoProvider will check if given receiver has defined additional action parameters by implementing IWebHookBindingMetadata as part of its metadata. If yes, and the parameter name matches one of them, it will set binding information based on associated WebHookParameter instance. This way receiver can ensure automatic binding for parameters coming from request headers, query parameters or route values.

If all of the above fails, one final attempt to set binding information will be made. If the parameter type is compatible with IFormCollection, JToken or XElement it will be treated as data regardless of its name.

Customizing binding

There are two things regarding binding which one might want to customize: changing the way in which request body is handled and supporting more fancy additional parameters than IWebHookBindingMetadata allows.

The only way to customize the built in behaviour is to use the same extension mechanism which defines it - application model. Thankfully all the application models used by ASP.NET Core WebHooks have exposed order, so one can be placed after them.

internal class WebSubBindingInfoProvider : IApplicationModelProvider
{

    public static int Order => WebHookBindingInfoProvider.Order + 10;

    int IApplicationModelProvider.Order => Order;

    public void OnProvidersExecuted(ApplicationModelProviderContext context)
    { }

    public void OnProvidersExecuting(ApplicationModelProviderContext context)
    { }
}

In case of the WebSub receiver I'm working on, the body type is not known upfront. I wanted to remove the body type validation. To achieve that I needed to remove WebHookVerifyBodyTypeFilter added by WebHookActionModelFilterProvider for actions decorated with WebSubWebHookAttribute.

internal class WebSubBindingInfoProvider : IApplicationModelProvider
{
    public void OnProvidersExecuted(ApplicationModelProviderContext context)
    {
        ...

        for (int controllerIndex = 0; controllerIndex < context.Result.Controllers.Count;
             controllerIndex++)
        {
            ControllerModel controller = context.Result.Controllers[controllerIndex];
            for (int acionIndex = 0; acionIndex < controller.Actions.Count; acionIndex++)
            {
                ActionModel action = controller.Actions[acionIndex];

                WebSubWebHookAttribute attribute =
                    action.Attributes.OfType<WebSubWebHookAttribute>().FirstOrDefault();
                if (attribute == null)
                {
                    continue;
                }

                RemoveWebHookVerifyBodyTypeFilter(action);
            }
        }
    }

    ...

    private static void RemoveWebHookVerifyBodyTypeFilter(ActionModel action)
    {
        IList<IFilterMetadata> filters = action.Filters;

        int webHookVerifyBodyTypeFilterIndex = 0;
        for (; webHookVerifyBodyTypeFilterIndex < filters.Count; webHookVerifyBodyTypeFilterIndex++)
        {
            if (filters[webHookVerifyBodyTypeFilterIndex] is WebHookVerifyBodyTypeFilter)
            {
                break;
            }
        }

        if (webHookVerifyBodyTypeFilterIndex < filters.Count)
        {
            filters.RemoveAt(webHookVerifyBodyTypeFilterIndex);
        }
    }
}

But spinning up application model only to remove an attribute would be a waste of its power. It can be used to provide automatic support for more parameters. I wanted to automatically bind parameters which represent a subscription (WebSubSubscription) related to the request. At the time of model binding custom filters which are part of my receiver have already cached subscription in HttpContext.Items, so it would be best to get it from there. The easiest way to bind parameters from non-standard places is to use a model binder.

internal class WebSubSubscriptionModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        ...

        IDictionary<object, object> items = bindingContext.HttpContext.Items;

        if (items.ContainsKey("WebSubSubscription"))
        {
            bindingContext.Result = ModelBindingResult.Success(items["WebSubSubscription"]);
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Failed();
        }

        return Task.CompletedTask;
    }
}

Now it is enough to find parameters with desired type and set its binding source and binder type.

internal class WebSubBindingInfoProvider : IApplicationModelProvider
{
    public void OnProvidersExecuted(ApplicationModelProviderContext context)
    {
        ...

        for (int controllerIndex = 0; controllerIndex < context.Result.Controllers.Count;
             controllerIndex++)
        {
            ControllerModel controller = context.Result.Controllers[controllerIndex];
            for (int acionIndex = 0; acionIndex < controller.Actions.Count; acionIndex++)
            {
                ...
                AddParametersBindingInfos(action);
            }
        }
    }

    ...

    private void AddParametersBindingInfos(ActionModel action)
    {
        for (int parameterIndex = 0; parameterIndex < action.Parameters.Count; parameterIndex++)
        {
            ParameterModel parameter = action.Parameters[parameterIndex];

            if (typeof(WebSubSubscription) == parameter.ParameterType)
            {
                if (parameter.BindingInfo == null)
                {
                    parameter.BindingInfo = new BindingInfo();
                }

                parameter.BindingInfo.BindingSource = BindingSource.ModelBinding;
                parameter.BindingInfo.BinderType = typeof(HttpContextItemsModelBinder);
                parameter.BindingInfo.BinderModelName = "WebSubSubscription";
            }
        }
    }
}

This pattern is very flexible and allows handling anything for which model binder can be implemented.

Parsing the request body at will

There is one more thing worth mentioning. What if you don't want to use model binding for your action or you want to parse the request body in a filter prior to model binding? ASP.NET Core WebHooks got you covered by providing IWebHookRequestReader service. You can obtain its instance from the DI and call ReadAsFormDataAsync or ReadBodyAsync to get the parsed request body. Also the usage of this service makes it safe for body to be read multiple times.

Altogether ASP.NET Core WebHooks should handle any typical data out-of-the-box but can be also bend to handle more unusual scenarios.

In general WebHooks don't use any kind of authentication or authorization. The delivery is based on unique (preferably unguessable) URLs. But, in unlikely circumstances of such a URL leaking it would expose the receiver to unwanted content. This is why it has become a standard practice for WebHooks to send a signature (hash) of content in dedicated header so receiver can validate it as consistency check. In most cases the signature is a HMAC hash of payload, where a shared secret value is used as key. Validating such signature will be subject of my fourth post in Under the hood of ASP.NET Core WebHooks series:

The signature generation and verification is not officially standardized, which results in implementation differences between various WebHooks. Because of that, instead of providing 'one size fits all' solution, ASP.NET Core WebHooks approach is for every receiver to have its own implementation. This approach is supported by WebHookVerifySignatureFilter base class which gives developers a set of shared tools. With its help the signature can be verified in few, not too complicated steps.

Step 1 - Ensuring secure connection

This step is not directly connected to signature validation. It is also not required. But from privacy perspective it's strongly encouraged. In order to prevent exposing the payload to third parties, WebHooks requests should be using HTTPS. Ensuring that connection is secure is as simple as calling EnsureSecureConnection method. If check fails the method will return an IActionResult which should be used to short-circuit the execution.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public override string ReceiverName => "websub";

    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        IActionResult secureConnectionCheckResult = EnsureSecureConnection(ReceiverName,
            context.HttpContext.Request);
        if (secureConnectionCheckResult != null)
        {
            context.Result = secureConnectionCheckResult;
            return;
        }

        ...
    }
}

The behaviour of EnsureSecureConnection is configurable through WebHooks:DisableHttpsCheck configuration key (also the check is disabled when ASPNETCORE_ENVIRONMENT is set to development).

Step 2 - Retrieving signature generated by sender

Retrieving desired header value is as simple as calling GetRequestHeader method. It will even prepare erroneous response if the header is not present.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            string signatureHeader = GetRequestHeader(request, "X-Hub-Signature",
                out IActionResult erroneousResult);
            if (erroneousResult != null)
            {
                context.Result = erroneousResult;
                return;
            }

            ...
        }

        ...
    }
}

But the header value rarely contains only the signature. Usually it carries additional information, like for example identifier of used algorithm. The task of parsing the header value is left to the developers. If the value can be split into tokens based on separators and you are looking for inspiration, the TrimmingTokenizer is a good place to start. It's used by several receivers but not publicly exposed.

After extracting signature from header value there is one more thing which needs to be done. The signature must be encoded to Base64 or hexadecimal representation so it can be put into header. Decoding is covered by FromBase64 and FromHex methods (there are also CreateBadBase64EncodingResult and CreateBadHexEncodingResult methods for creating responses when decoding fails).

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            ...

            byte[] expectedSignature = FromHex(parsedSignature, "X-Hub-Signature");
            if (expectedSignature == null)
            {
                context.Result = CreateBadHexEncodingResult("X-Hub-Signature");
                return;
            }

            ...
        }

        ...
    }
}

Step 3 - Computing your own signature

The WebHookVerifySignatureFilter provides ready to use implementation of signature calculation for SHA-1 and SHA-256 algorithms. If one of those algorithms is sufficient for given receiver needs it is enough to call ComputeRequestBodySha1HashAsync or ComputeRequestBodySha1HashAsync and pass request and the secret value as parameters. If different algorithms are needed (for example SHA-384 or SHA-512) it requires a little bit more effort (which makes one wish for more generic implementation, maybe with option for providing HMAC instance through parameter). The additional effort involves managing the request body stream. The current stream position must be pointing to its beginning before and after the hashing. If the stream doesn't support seeking, it requires enabling request buffering.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            ...

            byte[] actualSignature = await ComputeRequestBodySha512HashAsync(request,
                Encoding.UTF8.GetBytes(secret));
            if (actualSignature == null)
            {
                context.Result = new BadRequestResult();
                return;
            }

            ...
        }

        ...
    }

    private static async Task<byte[]> ComputeRequestBodySha512HashAsync(HttpRequest request,
        byte[] secret)
    {
        await PrepareRequestBody(request);

        using (HMACSHA512 hasher = new HMACSHA512(secret))
        {
            try
            {
                Stream inputStream = request.Body;

                int bytesRead;
                byte[] buffer = new byte[4096];

                while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
                {
                    hasher.TransformBlock(buffer, inputOffset: 0, inputCount: bytesRead,
                        outputBuffer: null, outputOffset: 0);
                }

                hasher.TransformFinalBlock(Array.Empty(), inputOffset: 0, inputCount: 0);

                return hasher.Hash;
            }
            finally
            {
                request.Body.Seek(0L, SeekOrigin.Begin);
            }
        }
    }

    private static async Task PrepareRequestBody(HttpRequest request)
    {
        if (!request.Body.CanSeek)
        {
            request.EnableBuffering();

            await request.Body.DrainAsync(CancellationToken.None);
        }

        request.Body.Seek(0L, SeekOrigin.Begin);
    }
}

Step 4 - Comparing signatures

We have actual and expected signatures, all that is left is to compare them. It is important not to forget about the security while doing this. Naive implementation could left the receiver open for timing attacks. Here we should use SecretEqual method which provides a time consistent comparison.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            ...

            if (!SecretEqual(expectedSignature, actualSignature))
            {
                context.Result = CreateBadSignatureResult("X-Hub-Signature");
                return;
            }
        }

        ...
    }
}

Of course CreateBadSignatureResult is provided for us as well.

This is third part of my Under the hood of ASP.NET Core WebHooks series:

I was expecting this post subject (verification requests) to be plain and simple, yet it managed to surprise me.

What's verification request about

Some webhooks perform a verification request as part of creating a subscription. This request is typically a GET (while notifications are POSTs) with a dedicated query parameter which serves as challenge. The goal is to verify that the provided URL in fact supports the webhook. To confirm that, the application is expected to echo the challenge parameter value in response body. Dropbox webhook is textbook example of this flow.

What's in the box for verification request

ASP.NET Core WebHooks provides support for verification requests through WebHookGetHeadRequestFilter. The filter is "triggered" when IWebHookGetHeadRequestMetadata interface is implemented by metadata. I wanted to see if it provides what I needed for my WebSub compliant receiver so I've added it to metadata, which enforced implementation of three properties.

public class WebSubMetadata : WebHookMetadata, IWebHookGetHeadRequestMetadata, ...
{
    ...

    public bool AllowHeadRequests => false;

    public string ChallengeQueryParameterName => "hub.challenge";

    public int SecretKeyMinLength => 0;
}

The AllowHeadRequests and ChallengeQueryParameterName are self-explanatory. As I didn't care about any keys at this stage I've decided to set SecretKeyMinLength to zero. I've run the demo application, fired the prepared request from Postman and (to my surprise) received a 500 response. I've quickly looked through the logs and found this:

System.InvalidOperationException: Could not find a valid configuration for the 'websub' WebHook receiver. Configure secret keys for this receiver.

But I didn't want any secret keys...

Further examination of WebHookGetHeadRequestFilter revealed that responding to verification requests isn't its only responsibility. It also confirms that secret key is properly configured for given receiver and not having one is not an option. This is surprising as secret keys are not related to verification requests but signature verification (hopefully the subject of next post in this series). Regardless, I had to configure one.

The WebHookGetHeadRequestFilter is looking for secret key under WebHooks:{webHookReceiver}:SecretKey:{id} configuration key. If the webhook URL doesn't contain id, the value default will be used in its place. This means that scenarios where all webhooks URLs are not known upfront is becoming challenging and requires smart usage of in-memory configuration provider or implementation of custom configuration provider.

For testing purposes I've put a dummy value into configuration file and received the response I wanted.

What if that's not what you need

The secret key requirement is a pain, but for most cases it's bearable. What if you have special requirements (which ended up being the case for my WebSub receiver)? The WebHookGetHeadRequestFilter doesn't provide way for customization, so the only solution seems to be using IWebHookFilterMetadata (which was briefly described in the introduction post) and creating your own filter. The ASP.NET Core WebHooks helps a little bit here by exposing static Order property on every filter, which allows placing the equivalent in the right spot. The filter should implement IResourceFilter or IAsyncResourceFilter.

public class WebSubWebHookIntentVerificationFilter : IAsyncResourceFilter, IOrderedFilter
{
    ...

    public int Order => WebHookGetHeadRequestFilter.Order;

    ...
}

This gives full freedom in handling verification requests.

This is a second post in my series about ASP.NET Core WebHooks:

As promised it will be focused on machinery which makes it possible for a WebHook request to find matching action.

Basics of WebHooks routing

You may know from the previous post, that key component responsible for configuring routing is WebHookSelectorModelProvider. Its job is to iterate all discovered actions and for those decorated with WebHookAttribute check for conflicts, set attribute routing template and inject action constraints. The most important thing is the template. It has following value (currently there is no built-in customization mechanism available): /api/webhooks/incoming/{webHookReceiver}/{id?}. Let's split it up:

  • /api/webhooks/incoming - static part which is the same for all WebHooks receivers. It might be best to consider all paths starting with it as reserved.
  • {webHookReceiver} - route parameter which must provide the intended receiver name.
  • {id?} - route parameter which may provide unique identifier.

The fact, that all actions decorated with WebHookAttribute share the route template means that when WebHooks request comes in all registered WebHooks actions are possible candidates. How specific action is being chosen? As stated above the WebHookSelectorModelProvider injects actions constraints. The one constraint which is always injected is WebHookReceiverNameConstraint. It validates two things. First is check for receiver "completeness" (does it metadata implement IWebHookBodyTypeMetadataService) and second is comparison between webHookRecevier route parameter and receiver name from metadata. Only if both conditions are met the action will be selected.

More about the {id?}

The purpose of second parameter defined by route is to provide a way for WebHooks URLs to be unique and give them possibility of being capability URLs. Imagine a situation where your application gives users option to receive WebHooks from GitHub. In order to distinguish requests for different users it is best to have unique URLs, so the application can generate them like this: https://{host}/api/webhooks/incoming/github/user1, https://{host}/api/webhooks/incoming/github/user2. In result the action will be able to access the unique identifier through id parameter.

public class GitHubController : ControllerBase
{
    // /api/webhooks/incoming/github/{id}
    [GitHubWebHook]
    public IActionResult GitHubHandler(string id, ...)
    {
        ...
    }
}

That's not all. The unique identifier can also participate in action selection. The WebHookAttribute provides an Id property. If that property is set for specific action the WebHookSelectorModelProvider will inject WebHookIdConstraint. This constraint makes sure that value of id route parameter matches the value provided by property. Going back to the GitHub example, if the application knows that it will need to handle a specific WebHook (for example its own repository), it can create a dedicated action.

public class GitHubController : ControllerBase
{
    // /api/webhooks/incoming/github/tpeczek
    [GitHubWebHook(Id = "tpeczek")]
    public IActionResult GitHubHandlerForTPeczek(...)
    {
        ...
    }

    // /api/webhooks/incoming/github/{id}
    [GitHubWebHook]
    public IActionResult GitHubHandler(string id, ...)
    {
        ...
    }
}

Adding events to the mix

There is one more WebHooks concept which relates to routing - events. Some of the WebHooks providers use events to share information about action which has triggered the WebHook and ASP.NET Core WebHooks provides a set of building blocks for utilizing them. Adding support for event to WebHooks receiver starts by implementing IWebHookEventMetadata as part of metadata. The interface provides properties for defining source of events information. Currently one can provide a query parameter or HTTP header name. The presence of IWebHookEventMetadata will cause WebHookSelectorModelProvider to inject yet another constraint. The WebHookEventNameMapperConstraint constraint will retrieve value from request based on IWebHookEventMetadata and treat it as comma separated list of events. The list must contain at least one entry (unless default event have been specified through IWebHookEventMetadata.ConstantValue property) for constraint to accept the request. Additionally the events are being added to route values, which makes them accessible as action parameter (if multiple events are expected an array should be used as parameter type).

public class GitHubController : ControllerBase
{
    // /api/webhooks/incoming/github/tpeczek
    [GitHubWebHook(Id = "tpeczek")]
    public IActionResult GitHubHandlerForTPeczek(string @event, ...)
    {
        ...
    }

    // /api/webhooks/incoming/github/{id}
    [GitHubWebHook]
    public IActionResult GitHubHandler(string id, string @event, ...)
    {
        ...
    }
}

Events, similarly to unique identifier, can participate in action selection. To enable this the receivers WebHookAttribute must implement IWebHookEventSelectorMetadata, which will provide EventName property. As you probably already expect the WebHookSelectorModelProvider is just waiting to inject related constraint. In this case it's WebHookEventNameConstraint which validates EventName property value against previously parsed list of events, if event is present the action is selected.

public class GitHubController : ControllerBase
{
    // /api/webhooks/incoming/github/tpeczek [X-GitHub-Event --> push]
    [GitHubWebHook(Id = "tpeczek", EventName = "push")]
    public IActionResult GitHubHandlerForTPeczekPushEvent(...)
    {
        ...
    }

    // /api/webhooks/incoming/github/tpeczek
    [GitHubWebHook(Id = "tpeczek")]
    public IActionResult GitHubHandlerForTPeczek(string @event, ...)
    {
        ...
    }

    // /api/webhooks/incoming/github/{id} [X-GitHub-Event --> push]
    [GitHubWebHook(EventName = "push")]
    public IActionResult GitHubHandlerForPushEvent(string id, ...)
    {
        ...
    }

    // /api/webhooks/incoming/github/{id}
    [GitHubWebHook]
    public IActionResult GitHubHandler(string id, string @event, ...)
    {
        ...
    }
}

This covers the most important information regarding ASP.NET Core WebHooks routing. Of course I haven't described everything (for example special support for Ping event). In next post I'm planning to take a look at verification requests.

Older Posts