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.

I was thinking about digging into ASP.NET Core WebHooks for quite some time, but I had other things to do and I was telling myself it will be best to do it when it reaches RTM. Those other things are done now, but ASP.NET Core WebHooks has been moved out of ASP.NET Core 2.1. I could either keep waiting for RTM and postpone further or admit that it was only excuse and go for it. After briefly looking at code one more time I've decided I'm curious about the internals and I want to do it, especially that I had a project idea which will be a good context for it - implementing a WebSub compliant subscriber. WebSub is a specification of common mechanism for communication between publishers and subscribers of Web content. The content distribution requires subscriber to expose a web hook. This web hook is very specific so I'm not sure if I will be able to deliver it, but it certainly will be interesting.

With this post I'm aiming at starting a series which for me will be a log of what I have learned and for you (hopefully) a source of knowledge. I don't have full roadmap for this series. I'm planning on touching following subjects:

I'll keep this list updated so it could serve as TOC. Please also remember that I'm starting writing this based on 1.0.0-preview3-final version so things might change.

Let's start with general overview.

What happens when you "Add" WebHooks

An adventure with every WebHook starts with AddXXXWebHooks() extension method. In fact there are always two methods. One is extending IMvcCoreBuilder while the other is extending IMvcBuilder. The reason is that WebHooks doesn't require "full" ASP.NET Core MVC. If we are not setting them together with MVC based frontend or Web API following will be enough for working application.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvcCore()
            .AddWebSubWebHooks();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.UseMvc();
    }
}

Are both method the same? Usually not. The method which extends IMvcCoreBuilder must register required formatters even if it's JSON. Skipping this difference, the flow is the same.

public static IMvcCoreBuilder AddWebSubWebHooks(this IMvcCoreBuilder builder)
{
    if (builder == null)
    {
        throw new ArgumentNullException(nameof(builder));
    }

    WebHookMetadata.Register<WebSubMetadata>(builder.Services);

    // Registration of required formatters

    return builder.AddWebHooks();
}

Two things are happening here: metadata describing the receiver are being registered and the core part of WebHooks is being added. What's happening there?

The heart of ASP.NET Core WebHooks - Application Models

The components of an ASP.NET Core MVC application are represented by application model. Most of the time developers don't have to be aware of it. The default implementation registers filters, discovers actions, configures routing and provides conventions which are used to interact with it. But for special cases there is an option for modifying application model by providing implementations of IApplicationModelProvider. This is how WebHooks integrate themselves with routing and create dedicated filters pipeline. As part of AddWebHooks() four implementations are being registered (listed in order of execution):

  1. WebHookActionModelPropertyProvider - responsible for adding receiver metadata to ActionModel.Properties of related actions (later implementations depend on it).
  2. WebHookSelectorModelProvider - responsible for adding routing template and constraints.
  3. WebHookActionModelFilterProvider - responsible for adding common and receiver specific filters to related actions.
  4. WebHookBindingInfoProvider - responsible for adding model binding information.

The future posts in this series will look closer at some of them.

Describing a receiver with metadata

The subject of receiver metadata will be probably coming back in every post in this series. Most of the aspects of receiver behaviour are described by one of interfaces which can be used to compose metadata (those interfaces can be found in Microsoft.AspNetCore.WebHooks.Metadata namespace). The bare minimum which every metadata needs to implement is IWebHookBodyTypeMetadataService. This is enforced by WebHookMetadata base class which also takes care of most of the implementation.

public class WebSubMetadata : WebHookMetadata
{
    public WebSubMetadata()
        : base(WebSubConstants.ReceiverName)
    { }

    public override WebHookBodyType BodyType => WebHookBodyType.Xml;
}

This makes two information obligatory. First is receiver name and second its request body type. The body type is potentially problematic one. It can have one of three values: Form, Json or Xml. The WebHookActionModelPropertyProvider validates if one of those values is set before adding metadata to action and later WebHookVerifyBodyTypeFilter (added by WebHookActionModelFilterProvider) validates if Content-Type of incoming request is in line with the provided value. For WebSub the web hook should support multiple body types so this will be one of challenging parts.

There is one more requirement for the metadata, enforced by WebHookReceiverExistsFilter. The metadata must implement IWebHookVerifyCodeMetadata or provide filter which implements IWebHookReceiver (the only out-of-the-box implementations is WebHookVerifySignatureFilter). The goal here seems to be security - enforcing some kind of request validation. For cases where such validation is not (yet) needed one can implement IWebHookFilterMetadata for metadata to inject some simple implementation of IWebHookReceiver.

public class WebSubMetadata : WebHookMetadata, IWebHookFilterMetadata
{
    private class WebSubWebHookReceiverFilter : IFilterMetadata, IWebHookReceiver
    {
        public string ReceiverName => WebSubConstants.ReceiverName;

        public bool IsApplicable(string receiverName)
        {
            if (receiverName == null)
            {
                throw new ArgumentNullException(nameof(receiverName));
            }

            return String.Equals(ReceiverName, receiverName, StringComparison.OrdinalIgnoreCase);
        }
    }

    private WebSubWebHookReceiverFilter _webHookReceiverFilter = new WebSubWebHookReceiverFilter();

    ...

    public void AddFilters(WebHookFilterMetadataContext context)
    {
        context.Results.Add(_webHookReceiverFilter);
    }
}

Associating action with WebHooks endpoint

There is one more piece missing - a way to associate an action with WebHooks endpoint. This is achieved by decorating action with attribute derived from WebHookAttribute.

public class WebSubWebHookAttribute : WebHookAttribute
{
    public WebSubWebHookAttribute()
        : base(WebSubConstants.ReceiverName)
    { }
}

The receiver name must match the one provided in metadata.

This is enough to create a "working" receiver. Its capabilities are strongly limited, but it is capable of routing matching requests to an action. What that exactly means will be the focus of next post in this series.

Recently I've been looking into using some "special tasks" databases with ASP.NET Core. One of them was RethinkDB. In general RethinkDB is a NoSQL database, which stores schemaless JSON documents. What's special about it, is its real-time capabilities - changefeeds. Changefeeds provide an option of continuously pushing updated query results to applications in real-time. The data can come to RethinkDB from various sources and be perceived as stream of events. I wanted to expose that stream through the application to the clients.

Setting up RethinkDB connection

There is a .NET Standard 2.0 driver for RethinkDB available as open source project (free to use if SSL/TLS encryption is not required). For interaction with the driver a globally provided singleton RethinkDb.Driver.RethinkDB.R should be used. This singleton together with open connection allows for using the database (the driver supports connections pooling with couple possible strategies, but I'm not going to use it here). Establishing connection requires hostname or IP and optionally port and timeout.

internal class RethinkDbOptions
{
    public string HostnameOrIp { get; set; }

    public int? DriverPort { get; set; }

    public int? Timeout { get; set; }
}

The connection object is expensive to create and it is advised to you use only one in the application. To achieve that the connection object can be encapsulated (together with the global singleton) in dedicated service.

internal class RethinkDbSingletonProvider : IRethinkDbSingletonProvider
{
    public RethinkDb.Driver.RethinkDB RethinkDbSingleton { get; }

    public RethinkDb.Driver.Net.Connection RethinkDbConnection { get; }

    public RethinkDbSingletonProvider(IOptions<RethinkDbOptions> options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        if (String.IsNullOrWhiteSpace(options.Value.HostnameOrIp))
        {
            throw new ArgumentNullException(nameof(RethinkDbOptions.HostnameOrIp));
        }

        var rethinkDbSingleton = RethinkDb.Driver.RethinkDB.R;

        var rethinkDbConnection = rethinkDbSingleton.Connection()
            .Hostname(options.Value.HostnameOrIp);

        if (options.Value.DriverPort.HasValue)
        {
            rethinkDbConnection.Port(options.Value.DriverPort.Value);
        }

        if (options.Value.Timeout.HasValue)
        {
            rethinkDbConnection.Timeout(options.Value.Timeout.Value);
        }

        RethinkDbConnection = rethinkDbConnection.Connect();

        RethinkDbSingleton = rethinkDbSingleton;
    }
}

To ensure a single instance the service should be registered as singleton.

internal static class ServiceCollectionExtensions
{
    public static IServiceCollection AddRethinkDb(this IServiceCollection services,
        Action<RethinkDbOptions> configureOptions)
    {
        if (services == null)
        {
            throw new ArgumentNullException(nameof(services));
        }
        if (configureOptions == null)
        {
            throw new ArgumentNullException(nameof(configureOptions));
        }

        services.Configure(configureOptions);
        services.TryAddSingleton<IRethinkDbSingletonProvider, RethinkDbSingletonProvider>();
        services.TryAddTransient<IRethinkDbService, RethinkDbService>();

        return services;
    }
}

The second service (RethinkDbService) will handle the RethinkDB specific logic.

Creating a data source

The changefeeds capability is great for solving specific use cases. But for demo purposes it's better to have something simple for brevity. Good enough example can be an IHostedService for gathering ThreadPool statistics.

internal class ThreadStats
{
    public int WorkerThreads { get; set; }

    public int MinThreads { get; set; }

    public int MaxThreads { get; set; }

    public override string ToString()
    {
        return $"Available: {WorkerThreads}, Active: {MaxThreads - WorkerThreads}, Min: {MinThreads}, Max: {MaxThreads}";
    }
}

internal class ThreadStatsService : IHostedService
{
    private readonly IRethinkDbService _rethinkDbService;

    ...

    private async Task GatherThreadStatsAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            ThreadPool.GetAvailableThreads(out var workerThreads, out var _);
            ThreadPool.GetMinThreads(out var minThreads, out var _);
            ThreadPool.GetMaxThreads(out var maxThreads, out var _);

            _rethinkDbService.InsertThreadStats(new ThreadStats
            {
                WorkerThreads = workerThreads,
                MinThreads = minThreads,
                MaxThreads = maxThreads
            });

            await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
        }
    }
}

If this code looks familiar it is because its based on one of David Fowler demos from NDC London 2018. Adding controller from that demo to the application provides an easy way to use up some threads.

The programming model of RethinkDB driver is a little bit specific. In general one builds the query and at the end runs it providing the connection. This makes inserting the stats look like below.

internal class RethinkDbService: IRethinkDbService
{
    private const string DATABASE_NAME = "Demo_AspNetCore_RethinkDB";
    private const string THREAD_STATS_TABLE_NAME = "ThreadStats";

    private readonly RethinkDb.Driver.RethinkDB _rethinkDbSingleton;
    private readonly Connection _rethinkDbConnection;

    public RethinkDbService(IRethinkDbSingletonProvider rethinkDbSingletonProvider)
    {
        if (rethinkDbSingletonProvider == null)
        {
            throw new ArgumentNullException(nameof(rethinkDbSingletonProvider));
        }

        _rethinkDbSingleton = rethinkDbSingletonProvider.RethinkDbSingleton;
        _rethinkDbConnection = rethinkDbSingletonProvider.RethinkDbConnection;
    }

    public void InsertThreadStats(ThreadStats threadStats)
    {
        _rethinkDbSingleton.Db(DATABASE_NAME).Table(THREAD_STATS_TABLE_NAME)
            .Insert(threadStats).Run(_rethinkDbConnection);
    }
}

With the data source in place, the changefeed can be exposed.

Exposing changefeed with Server-Sent Events

Server-Sent Events feels like natural choice here. This is exactly the scenario this technology has been designed for. Also with ready to use library the implementation should be straightforward. First the required service and middleware needs to be registered.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRethinkDb(options =>
        {
            options.HostnameOrIp = "127.0.0.1";
        });

        services.AddServerSentEvents();

        services.AddSingleton<IHostedService, ThreadStatsService>();

        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
    }

    public void Configure(IApplicationBuilder app)
    {
        app.MapServerSentEvents("/thread-stats-changefeed");

        app.UseStaticFiles();

        app.UseMvc();

        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("-- Demo.AspNetCore.RethinkDB --");
        });
    }
}

In order to acquire the changefeed from RethinkDB one needs to build the query, call Changes() on it and then run it by calling RunChanges()/RunChangesAsync(). This returns a cursor which can be iterated later.

internal class RethinkDbService: IRethinkDbService
{
    ...

    public Task<Cursor<Change<ThreadStats>>> GetThreadStatsChangefeedAsync(CancellationToken cancellationToken)
    {
        return _rethinkDbSingleton.Db(DATABASE_NAME).Table(THREAD_STATS_TABLE_NAME)
            .Changes().RunChangesAsync<ThreadStats>(_rethinkDbConnection, cancellationToken);
    }
}

With all the pieces in place the application can start sending events. Again IHostedService comes with help here by providing a nice way of utilizing the changefeed in background.

internal class ThreadStatsChangefeedService : IHostedService
{
    private readonly IRethinkDbService _rethinkDbService;
    private readonly IServerSentEventsService _serverSentEventsService;

    ...

    private async Task ExposeThreadStatsChangefeedAsync(CancellationToken cancellationToken)
    {
        var threadStatsChangefeed = await _rethinkDbService
            .GetThreadStatsChangefeedAsync(cancellationToken);

        while (!cancellationToken.IsCancellationRequested
               && (await threadStatsChangefeed.MoveNextAsync(cancellationToken)))
        {
            string newThreadStats = threadStatsChangefeed.Current.NewValue.ToString();
            await _serverSentEventsService.SendEventAsync(newThreadStats);
        }
    }
}

This is it. All that is left is registering the service and adding some UI. The complete demo can be found here.

WebSockets fallback

The sad truth is that not all major browsers support Server-Sent Events. It might not always be needed (in the context mentioned at the beginning the target clients were known to use browsers with support), but it might be nice to add a fallback for that 11% (at the moment of writing this). Such fallback can be implemented based on WebSockets. I've been writing about using WebSockets in ASP.NET Core several times before and I have a demo project up on GitHub, so there is no need to repeat anything of that here. For completeness the demo for this post contains the fallback, which is a simple WebSockets implementation capable only of broadcasting text messages.

Why I didn't use SignalR

This question will most likely come up. The honest answer is that I didn't need it. The Server-Sent Events fit the use case perfectly and the implementation was even shorter than it would be with SignalR (it's no longer true with WebSockets fallback, but even in that case the code is still pretty simple). Also the usage of SignalR wouldn't be typical here. It would either have to handle the changefeed in Hub or attempt to access clients in IHostedService. In my opinion this approach is cleaner.

I would say that the best way for hosting static files is CDN. But sometimes there are valid reasons not to use CDN and serve static files directly from the application. In such scenario it's important to think about performance. I'm not going to write about caching and cache busting, for those subjects I suggest this post by Andrew Lock. What I want to focus on is eliminating unnecessary request bytes and processing on the server side.

Let's imagine an ASP.NET Core MVC application which uses cookie based authentication. The Startup may look more or less like below.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie();

        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        ...

        app.UseStaticFiles();

        app.UseAuthentication();

        app.UseMvc();
    }
}

After login this configuration will result in about 600 bytes of state coming from cookies (it can easily be more). Those are data which are send to the server with every request and server must parse them and store in memory. In case of publicly available static files this is waste of resources. The web development knows a generic solution for this problem - using a cookie-free origin.

Setting up a subdomain to act as a cookie-free origin doesn't require anything ASP.NET Core specific. All that is needed is subdomain configured to the same IP address as the main domain of the application, for example example.com and static.example.com (in development you can set this up by editing hosts file). In the result the cookies set by example.com will not be send to static.example.com. But just configuring the subdomain is not enough. If left like that it would expose the whole application under static.example.com. Certainly this is not desired, we want to branch the pipeline.

Subdomain based pipeline branching

ASP.NET Core provides couple of ways to branch the pipeline. The domain information is available to the application through Host header, so the way which allows for examining it is predicate based MapWhen() method. A simple extension could look like this.

public static class MapSubdomainExtensions
{
    public static IApplicationBuilder MapSubdomain(this IApplicationBuilder app,
        string subdomain, Action<IApplicationBuilder> configuration)
    {
        // Parameters validation removed for brevity.
        ...

        return app.MapWhen(GetSubdomainPredicate(subdomain), configuration);
    }

    private static Func<HttpContext, bool> GetSubdomainPredicate(string subdomain)
    {
        return (HttpContext context) =>
        {
            string hostSubdomain = context.Request.Host.Host.Split('.')[0];

            return (subdomain == hostSubdomain);
        };
    }
}

This extensions compares the provided subdomain with whatever is in the Host header before first dot. This can be considered a simplification, but an acceptable one. With the extension we can separate the pipeline for static files from the rest of application.

public class Startup
{
    ...

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        ...

        app.MapSubdomain("static", ConfigureStaticSubdomain);

        app.UseAuthentication();

        app.UseMvc();
    }

    private static void ConfigureStaticSubdomain(IApplicationBuilder app)
    {
        app.UseStaticFiles();
    }
}

Running the application now would result in static files not loading. The reason for that is simple, the generated content URLs are incorrect. It would be nice to modify a single place to handle correct URL generation.

Prefixing content URLs

As far as I know all static files URLs (unless entered manually) are generated by calling IUrlHelper.Content() method. Luckily ASP.NET Core makes it relatively easy to replace default implementation of IUrlHelper. First thing which is needed is custom implementation. It can be derived from default UrlHelper.

public class ContentSubdomainUrlHelper : UrlHelper
{
    private readonly string _contentSubdomain;

    public ContentSubdomainUrlHelper(ActionContext actionContext, string contentSubdomain)
        : base(actionContext)
    {
        if (String.IsNullOrWhiteSpace(contentSubdomain))
        {
            throw new ArgumentNullException(nameof(contentSubdomain));
        }

        _contentSubdomain = contentSubdomain;
    }

    public override string Content(string contentPath)
    {
        if (String.IsNullOrEmpty(contentPath))
        {
            return null;
        }

        if (contentPath[0] == '~')
        {
            PathString hostPathString = new PathString("//" + _contentSubdomain + "."
                + HttpContext.Request.Host);
            PathString applicationPathString = HttpContext.Request.PathBase;
            PathString contentPathString = new PathString(contentPath.Substring(1));

            return HttpContext.Request.Scheme + ":"
                + hostPathString.Add(applicationPathString).Add(contentPathString).Value;
        }

        return contentPath;
    }
}

Once again it makes an important assumption - that it's enough to prefix current request host with the subdomain in order to create correct URL.

The second thing is IUrlHelperFactory implementation. This service is responsible for creating IUrlHelper implementation instances. It has a single method. One important thing is to keep the same caching capability as in default implementation, otherwise it can cause a performance regression.

public class ContentSubdomainUrlHelperFactory : IUrlHelperFactory
{
    private string _contentSubdomain;

    public ContentSubdomainUrlHelperFactory(string contentSubdomain)
    {
        if (String.IsNullOrWhiteSpace(contentSubdomain))
        {
            throw new ArgumentNullException(nameof(contentSubdomain));
        }

        _contentSubdomain = contentSubdomain;
    }

    public IUrlHelper GetUrlHelper(ActionContext context)
    {
        // Parameters validation removed for brevity.
        ...

        object value;
        if (context.HttpContext.Items.TryGetValue(typeof(IUrlHelper), out value)
            && value is IUrlHelper)
        {
            return (IUrlHelper)value;
        }

        IUrlHelper urlHelper = new ContentSubdomainUrlHelper(context, _contentSubdomain);
        context.HttpContext.Items[typeof(IUrlHelper)] = urlHelper;

        return urlHelper;
    }
}

Replacing the default implementation should be done after calling AddMvc().

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddMvc();

        services.Replace(new ServiceDescriptor(typeof(IUrlHelperFactory),
            new ContentSubdomainUrlHelperFactory("static")));
    }

    ...
}

Running the application now would result in it almost working...

Enabling CORS for static files

The not working part are dynamically requested resources (for example fonts in case of Bootstrap or lazy loaded scripts). Introduction of dedicated subdomain has changed those requests into cross-origin ones. This can be fixed by adding CORS middleware to the branch.

public class Startup
{
    ...

    private static void ConfigureStaticSubdomain(IApplicationBuilder app)
    {
        app.UseCors(builder =>
        {
            builder.WithOrigins("https://example.com");
        });

        app.UseStaticFiles();
    }
}

Now everything works as it should.

As mentioned, the above implementation is simplified based on few assumptions. In most cases those should be true, but if not it shouldn't be hard to modify the code to handle more complex ones.

Older Posts