ASP.NET Core Extension Points - Core
Introduction
This will be a long series of post about some of the ASP.NET Core extensibility points. By this, I mean any feature of ASP.NET Core that can be replaced/augmented by adding custom code, or switching to a different existing option. I will mention each extension point, how to change it, and some of its typical use cases.
This will be split into different posts, and the structure is likely to be:
- ASP.NET Core itself (this one)
- MVC only
- MVC and Minimal APIs: things that are relevant to both
- MVC and Razor Pages: same here
- Razor Pages only
Let's start first with the basics, functionality that exists regardless of MVC or Minimal APIs or Razor Pages.
Hosts and Servers
A host is what executes a server to, you know, serve HTTP requests. The standard ASP.NET Core make use of WebApplication to create this web host (a class that inherits from IWebHost) and which is hidden inside WebApplication. Inside this host just executes an IServer instance, of which there are different implementations (Kestrel, HTTP.sys, Test). You can read about the different servers here and about the hosts here.
This is one extension point that you'll likely won't use much, unless you want to roll out your own server or host. The host is responsible for application startup and lifetime management, and the server, well, actually serves the requests.
Dependency Injection
ASP.NET Core heavily depends (pun intended) on dependency injection (DI). It is used from the ground up, meaning, ASP.NET Core requests the services it needs from the DI container. It includes its own container but it is possible to provide a custom one... kind of. Mind you, this is a rocky trail, as the supplied DI container will not be used for everything, so you may have some surprises. In the early days of ASP.NET Core it was easy to return a different implementation, it was just a matter of returning an IServiceProvider implementation from the ConfigureServices convention method. Now, however, because WebApplication, the class normally used to bootstrap ASP.NET Core in latest versions, instantiates and uses it's own DI container, not everything can be changed. It's still possible, although a bit more tricky and with some caveats: it involves calling HostBuilder.UseServiceProviderFactory or ConfigureHostBuilder.UseServiceProviderFactory and setting a custom IServiceFactoryProvider<T> and also using perhaps middleware to set the HttpContext.RequestServices/IServiceProvidersFeature for each request. I asked Microsoft about it here and, before me, someone else, and got nowhere. If you are bold enough, I wrote a post about this topic here and I think I found a way around it!
Middleware and Middleware Factories
ASP.NET Core runs a pipeline, which essentially is a collection of middleware components. I already talked about middleware before. There's a specific kind of middleware, factory-activated middleware (the other being convention-based middleware) that is instantiated by a factory; this factory is an implementation of IMiddlewareFactory that needs to be registered with the DI. I ask you to refer to my post to learn how you can leverage this.
JSON and XML Serialisation
Since the early days, ASP.NET and ASP.NET Core used XML and JSON serialisers to parse incoming requests and perform model binding (input formatters), as well as to produce outputs in different formats (output formatters). They are picked based on the Content-Type and Accept headers.
Base interfaces are, for input formatters, IInputFormatter and IOutputFormatter, for output formatters. You can freely register your own implementations and we'll talk more about formatters on a future post on this series.
The default serialiser for JSON contents used to be a well-known third-party, Newtonsoft.Json. The method to register it was AddNewtonsoftJson, and it required a reference to Nuget package Microsoft.AspNetCore.Mvc.NewtonsoftJson to register NewtonsoftJsonInputFormatter and NewtonsoftJsonPatchInputFormatter for ASP.NET Core.
Now, since we have JSON support built-in, we just to call AddJsonOptions, for the System.Text.Json API version, which is SystemTextJsonInputFormatter.
There's also, of course, a XML serialiser, for handling XML requests and responses. The method to register the default XML serialiser is AddXmlSerializerFormatters, and the serialisers are XmlSerializerInputFormatter and XmlDataContractSerializerInputFormatter.
File Providers
ASP.NET Core does not directly access files on the filesystem; instead, it relies on file providers to do it. The interface for a file provider is IFileProvider, and there are a few implementations:
Provider | Nuget Package | Purpose |
CompositeFileProvider | Microsoft.Extensions.FileProviders.Composite | Combines multiple file providers together, and returns the first valid response |
EmbeddedFileProvider | Microsoft.Extensions.FileProviders.Embedded | Returns files from embedded resources in an assembly |
ManifestEmbeddedFileProvider | Microsoft.Extensions.FileProviders.Embedded | Similar to EmbeddedFileProvider, but uses a manifest file to specify the actual file locations |
NullFileProvider | Microsoft.Extensions.FileProviders.Abstractions | Always returns empty |
PhysicalFileProvider | Microsoft.Extensions.FileProviders.Physical | Default, loads files form the physical file system |
This makes possible interesting scenarios, such as serving files from different filesystem locations, from an assembly as an embedded resource (files marked as action EmbeddedResource in the project), or even from a database like SharePoint does.
By default, ASP.NET Core registers a PhysicalFileProvider which points to the wwwroot folder, and it is available from properties IWebHostEnvironment.WebRootFileProvider and IHostingEnvironment.ContentRootFileProvider but you can register other providers, including a CompositeFileProvider, which combines multiple ones, and returns the first valid response from all of them:
builder.Services
.AddSingleton<IFileProvider>(
new CompositeFileProvider(
builder.Environment.ContentRootFileProvider,
new EmbeddedFileProvider(typeof(Program).Assembly, "MyAssembly")));
This registers a CompositeFileProvider provider with the default provider and also an EmbeddedFileProvider which points to the currently executing assembly.
Data Protection and Data Protection Providers
Data protection is a key functionality of ASP.NET Core, because it deals with security. It is used to protect (encrypt and decrypt) data in a way that is machine-dependant, maintaining and rotating keys in a secure way. It is used for:
- TempData: encrypts and later decrypts the values that are stored there (more on this on a future post)
- Authentication cookies: the cookies that are used for authentication are encrypted, so that they cannot be tampered with
- Anti-forgery tokens: web forms are protected with a token, so as to prevent Cross-Site Scripting (XSS) attacks
- Session state: the data stored on the session is encrypted too
The provider is an instance of IDataProtectionProvider which returns a IDataProtector. You can register your own implementations of IDataProtectionProvider, if you want something different than the default - you probably shouldn't, unless you know very well what you are doing. The included provider is a private class, KeyRingBasedDataProtectionProvider, and it takes care of everything for us, generating encrypted strings that can only be decrypted on the same machine (it is possible to store the keyring on a shared location, such as a folder, or Azure, for clustered scenarios).
The way to register data protection is through a call to AddDataProtection, which is normally done for us, unless you want to configure additional settings.
Cryptography
Similarly to the Data Protection API (DPAPI), which is kind of high-level, it is possible to customise the actual encryption, the key management, and the persistence of keys, that DPAPI will use. Now, this is a complex topic, and you can read more about it here and here, as I won't go into details here.
Session Store
By default, the session state in an ASP.NET Core is stored in memory, I'm talking about the ISession object that is available from HttpContext.Session. This provides a key-value store for keeping values, which are serialised into bytes, meaning, they are not live objects, as in the previous versions of ASP.NET.
The default implementation of ISessionStore is DistributedSessionStore, and is implicitly registered by the AddSession method, which means it requires an IDistributedCache (distributed cache implementation), but you can certainly roll out your own, even stored in-memory, or a database. The ISession is the more complex part to implement, but it's just a couple methods, for loading, storing, setting, and removing an entry.
One possible (and somewhat naive) implementation of an in-memory session store would be:
internal class InMemorySessionStore(IMemoryCache cache) : ISessionStore
{
public ISession Create(string sessionKey, TimeSpan idleTimeout, TimeSpan ioTimeout, Func<bool> tryEstablishSession, bool isNewSessionKey) => new InMemorySession(sessionKey, idleTimeout, cache);
}
internal class InMemorySession(string sessionKey, TimeSpan idleTimeout, IMemoryCache cache) : ISession
{
public bool IsAvailable => true;
public string Id => sessionKey;
public IEnumerable<string> Keys => (cache as MemoryCache).Keys.OfType<string>();
public void Clear() => (cache as MemoryCache).Clear();
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public void Remove(string key) => cache.Remove(key);
public void Set(string key, byte[] value) => cache.Set(key, value, DateTimeOffset.Now.Add(idleTimeout));
public bool TryGetValue(string key, out byte[]? value) => cache.TryGetValue(key, out value);
}
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInMemorySession(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ISessionStore, InMemorySessionStore>();
services.AddMemoryCache();
services.AddDataProtection();
return services;
}
}
Unlike the default AddSession implementation, which registers the ISessionStore as Transient, we are here registering it as Singleton, the same as the default registration of IMemoryCache, which it depends upon. Mind you that this also requires adding the memory cache (AddMemoryCache), which in turn requires the Microsoft.Extensions.Caching.Memory Nuget package, which should probably already be included. This is why we know that the IMemoryCache is always a MemoryCache instance, as it is the only implementation. Finally, for this to work, you need to include the session middleware, with a call to UseSession when building the pipeline.
Conclusion
In this first post, I talked about some of the extensibility points of ASP.NET Core itself, without getting into APIs such as MVC, Minimal APIs, or Razor Pages. In the future posts, I will get into each of these topics.
Comments
Post a Comment