ASP.NET Core Extension Points - MVC
Introduction
This is post #2 on my series of posts on ASP.NET Core extensibility. The first one is this one, on the core extension points. This time, I'm going to focus on extensibility that is specific to MVC.
I'm covering here:
- Controller factories and activators
- Action invokers, action invoker factories and action invoker providers
- Action constraints and action contraint factories
- Value providers and value provider factories
- Input and output formatters
- Output cache
Controller Factories and Activators
A controller factory is what creates a controller instance and also takes care of releasing (disposing of) it. A controller activator does similar things, even the methods they expose are very similar, as we can see it from the controller factory IControllerFactory and the controller activator IControllerActivator interfaces.
As it is, the default controller factory class, DefaultControllerFactory, receives a controller activator from Dependency Injection (DI), which by default is DefaultControllerActivator, and uses it to create the controller instance and release it too. So, if you in any way want to interfere with the controller generation process, you have two options:
- Change the default controller factory and don't worry with the activator
- Keep the default controller factory and just change the default activator
This would be, for example, to perform some custom initialisation, or to return something different, like a “non natural” controller class, not the type that would be expected. The way to replace the factory and activator is done, of course, through the DI container:
services.AddSingleton<IControllerFactory, MyControllerFactory>();
services.AddTransient<IControllerActivator, MyControllerActivator>();
A controller factory can be declared as a singleton, and an activator, depending on it's implementation (does it need to keep state, such as a cache?), can be too, but the default one is registered as transient.
Action Invokers, Action Invoker Factories and Action Invoker Providers
In an MVC architecture, we end up calling a method (action) on a class (controller). There are, as you may know, some conventions for this in the ASP.NET Core world, which can, of course, be overriden.
An action invoker factory is an instance of IActionInvokerFactory which is responsible for creating an action invoker (IActionInvoker). It usually (the default implementation) takes all registered action invoker providers and builds the action invoker from them. The default class is internal, but you can see it here.
The action invoker providers (IActionInvokerProvider) are used to execute actions before and after the action is invoked, which, by default, create the actual action invoker. If you have a custom action invoker factory, you can do it other way. See the default implementation here.
The action invoker is what actually calls the method for the current endpoint using reflection, passing it any parameters obtained from the request. Here is the default, also internal, implementation. A cache is used to speed up the process in future calls.
Now, before you can actually use any of these, you need to enable the MvcOptions.EnableActionInvokers property:
builder.Services.AddControllersWithViews(static options =>
{
options.EnableActionInvokers = true;
});
Then you can register your custom action invoker factory:
builder.Services.AddSingleton<IActionInvokerFactory, CustomActionInvokerFactory>();
And the same for action invoker providers, which probably should be injected into the action invoker factory, in case you need them:
builder.Services.AddSingleton<IActionInvokerProvider, CustomActionInvokerProvider>();
Action invokers are either created by action invoker providers or by the action invoker factory, no need to register them.
If you really want to override these, you better know what you're doing. You could use a custom action invoker to change the target method for an action request, for example, or to perform actions before/after/instead an action method is called, although there are better ways to do this, such as filters.
Action Constraints and Action Contraint Factories
An action constraint decides if a specific method can be called as the result of accessing an MVC action. It must be an instance of the IActionConstraint interface. Some notable examples are:
- ActionMethodSelectorAttribute: abstract base attribute class for an action constraint (implements IActionConstraint)
- ConsumesAttribute: checks if a method can be called depending on the Accept header, it must match any of the value of the ContentTypes property
- HttpMethodActionConstraint: used to determine the set of HTTP methods supported by an action
- OverloadActionConstraint: used when calling an API, it checks that all required parameters have been provided on the request
A simple action constraint, to limit access to certain browsers, could be:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class UserAgentConstraintAttribute : ActionMethodSelectorAttribute
{
public UserAgentConstraintAttribute(string userAgent)
{
UserAgent = userAgent;
}
public string UserAgent { get; }
public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action)
{
if (routeContext.HttpContext.Request.Headers.Contains(HeaderNames.UserAgent))
{
var userAgent = routeContext.HttpContext.Request.Headers.UserAgent;
return userAgent.ToString().Contains(UserAgent, StringComparison.InvariantCultureIgnoreCase);
}
return false;
}
}
It's a good idea to inherit from the ActionMethodSelectorAttribute, as it already gives us an easy to implement abstract method, IsValidForRequest, which checks the value of the User-Agent header and then compares it with the provided UserAgent property, if there is a match, it allows the method, otherwise, it fails. When applied to an action method and called from a browser whose name does not match the UserAgent property, ASP.NET Core returns HTTP 404 Not Found. The other way to apply a constraint is through a convention, which will be covered shortly.
An action constraint factory, on the other hand, is used to create action constraints. Its base interface is IActionConstraintFactory, and it is typically applied through an attribute. There's a single method, CreateInstance, which is responsible for returning the actual action constraint instance:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class SomeConstraintFactoryAttribute : Attribute, IActionConstraintFactory
{
public bool IsReusable => true;
public IActionConstraint CreateInstance(IServiceProvider serviceProvider)
{
return new SomeConstraint();
}
}
As you can see, the CreateInstance method receives a pointer to the IServiceProvider, which can be useful.
Value Providers and Value Provider Factories
A value provider is what provides a value for an action method parameter. A value provider factory builds the value provider.
Value provider factories must implement IValueProviderFactory and a value provider, IValueProvider. The collection of value provider factories can be modified from the MvcOptions.ValueProviderFactories collection.
builder.Services.AddControllersWithViews(static options =>
{
options.ValueProviderFactories.Add(new CustomValueProviderFactory());
});
When invoking an action method with parameters, all of the registered value provider factories are asked to create value providers, which, in turn, are asked if they can provide a value for each of the parameters, possibly from their name or type.
There are a few value provider factories that are registered by default, or available to use:
Value Provider Factory | Value Provider | Purpose |
CompositeValueProvider | Combines many value providers | |
FormFileValueProviderFactory | FormFileValueProvider | Gets a value from a POSTed file |
FormValueProviderFactory | FormValueProvider | Gets a value from a POSTed form |
JQueryFormValueProviderFactory | JQueryValueProvider | Gets a value from POSTed jQuery-formatted data |
JQueryQueryStringValueProviderFactory | JQueryQueryStringValueProvider | Gets a value from a jQuery-formatted query string |
QueryStringValueProviderFactory | QueryStringValueProvider | Gets a value from the query string |
RouteValueProviderFactory | RouteValueProvider | Gets a value from the route |
Input and Output Formatters
You may have read, on the first post, the section about serialisers (JSON and XML). Input and output formatters are what actually uses them, and they are registered by the methods detailed there (AddNewtonsoftJson, AddJsonOptions, AddXmlSerializerFormatters). Read more about input and output formatters here.
An output formatter implements IOutputFormatter, and an input formatter, IInputFormatter. An output formatted is only called when there is a result to serialise, and the same for an input formatter, only called when there is a parameter that requires is.
The built-in input formatters are:
Input Formatter | Content Type | Purpose |
JsonPatchInputFormatter | application/json-patch+json | Serialises from JSON using Newtonsoft.Json |
NewtonsoftJsonInputFormatter | application/json text/json application/*+json | Serialises from JSON using Newtonsoft.Json |
SystemTextJsonInputFormatter | application/json text/json application/*+json | Serialises from JSON using System.Text.Json |
XmlDataContractSerializerInputFormatter | application/xml text/xml application/*+xml | Serialises from XML using DataContractSerializer |
XmlSerializerInputFormatter | application/xml text/xml application/*+xml | Serialises from XML using XmlSerializer |
And the output ones:
Output Formatter | Content Type | Purpose |
HttpNoContentOutputFormatter | Does not send any contents but sets HTTP response status to 204 NoContent | |
HttpResponseMessageOutputFormatter | Sends HttpResponseMessage objects and its contents | |
NewtonsoftJsonOutputFormatter | application/json text/json application/*+json | Serialises to JSON using Newtonsoft.Json |
StringOutputFormatter | text/plain | Sends contents as strings |
StreamOutputFormatter | Streams contents | |
SystemTextJsonOutputFormatter | application/json text/json application/*+json | Serialises to JSON using System.Text.Json |
XmlDataContractSerializerOutputFormatter | application/xml text/xml application/*+xml | Serialises to XML using DataContractSerializer |
XmlSerializerOutputFormatter | application/xml text/xml application/*+xml | Serialises to XML using XmlSerializer |
You may want to build a custom output formatter, for example, to return data in a different format. Of course, you also need to tell ASP.NET Core to use it, maybe from an Content-Type header.
If you wanted to return data as CSV (text/csv content type), we could build this output formatter, which is a bit simplistic, but works:
public class CsvOutputFormatter : OutputFormatter
{
public CsvOutputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
}
protected override bool CanWriteType(Type type)
{
return type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
}
public async override Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
{
if (context?.Object is null)
{
await Task.CompletedTask;
}
var response = context.HttpContext.Response;
var type = context.Object.GetType().GetGenericArguments()[0];
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.GetProperty);
await using (var writer = new StreamWriter(response.Body))
{
var line = string.Join(";", props.Select(x => GetHeaderName(x)));
//header
await writer.WriteLineAsync(line);
//items
foreach (var item in context.Object as IEnumerable)
{
line = string.Join(";", props.Select(prop => GetValueAsString(prop.GetValue(item))));
await writer.WriteLineAsync(line);
}
await writer.FlushAsync();
}
}
private static string GetHeaderName(PropertyInfo prop)
{
if (prop.GetCustomAttribute<DisplayNameAttribute>() is DisplayNameAttribute attr)
{
return attr.DisplayName;
}
return prop.Name;
}
private static string GetValueAsString(object value)
{
var valueString = string.Empty;
if (value is IFormattable f)
{
valueString = f.ToString(null, CultureInfo.InvariantCulture);
}
valueString = value?.ToString() ?? string.Empty;
return valueString.Replace(";", ",");
}
}
This output formatter expects results to be of type IEnumerable<T>. It uses reflection to get all the public instance properties that have a getter. For the headers, it either uses the property names or the Name of the [Display] attribute, if one is applied to the property. Any ";" in the values are translated to ",", so not to break the formatting (";" for cell separators).
Now, we need to decorate our action methods with a [Consumes] attribute that expects text/csv:
[Consumes("text/csv")]
public IActionResult Csv()
{
//return some collection
}
[Consumes] requires that requesters send one of the content-types it is declared with, in this case, text/csv as the Content-Type. If it is not sent, ASP.NET Core returns 404 Not Found.
Finally, we need to register our custom output formatter, in MvcOptions.OutputFormatters:
builder.Services.AddControllersWithViews(static options =>
{
options.OutputFormatters.Insert(0, new CsvOutputFormatter());
});
A better option is to use a format filter attribute together with the appropriate middleware. A format filter allows us to map a parameter coming from the query string to a specific Content-Type header and formatter. The format filter is attribute [FormatFilter] and it can be applied like this:
[FormatFilter]
public IActionResult Csv()
{
//return some collection
}
Notice that we dropped the [Consumes] attribute, this time, we want to control how we are going to output the contents of the action method through a query string parameter, so it's not just for text/csv.
We need to define our mapping using SetMediaTypeMappingForFormat:
builder.Services.AddControllersWithViews(static options =>
{
options.OutputFormatters.Insert(0, new CsvOutputFormatter());
options.SetMediaTypeMappingForFormat("csv", MediaTypeHeaderValue.Parse("text/csv"));
});
Now, if we call our endpoint with a format parameter equal to csv:
/myactionreturningdata?format=csv
we get the mapped formatter, from the passed content-type. If we don't, we just get the default output formatter, the first that can handle the response type and Content-Type header, which is nornally JSON. By default, json is already mapped.
We’ve seen how to set the output formatters globally, but another option is to only set the one we want for a specific result. For that, we can leverage the OutputResult.Formatters property, from a custom attribute (action, resource, result filter). One example using an ResultFilterAttribute:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public sealed class CsvFormatterAttribute : ResultFilterAttribute { public override void OnResultExecuting(ResultExecutingContext context) { if (context.Result is ObjectResult res) { res.Formatters.Clear(); res.Formatters.Add(new CsvOutputFormatter()); } base.OnResultExecuting(context); } }
Apply this attribute to an action method for which you want to return results as CSV. Feel free to extend it, for example, to get the format parameter from the URL - query string, route - and select the appropriate output formatter from it, maybe from inspecting MvcOptions.FormatterMappings.
Read more about formatting and custom formatters here and here.
Output Cache
Output cache is a feature of ASP.NET Core that helps with performance. Essentially, it uses memory - local or out-of-process - to store the results of the invocation of controller actions, so as to speed up the response, for data that does not change many times and actions that do not have side effects. I will talk in more detail about caching on a future post.
It's important to know that the output caching service itself is represented by an instance of the IOutputCacheStore interface, which is registered to DI, and whose default implementation is MemoryOutputCacheStore (internal class), for in-memory caching using MemoryCache.
There are other implementations that use services such as Redis: the RedisOutputCacheStore from the Microsoft.AspNetCore.OutputCaching.StackExchangeRedis Nuget package.
If you want to roll out your own implementation of IOutputCacheStore, just register it with DI at startup:
builder.Services.AddSingleton<IOutputCacheStore, DictionaryOutputCacheStore>();
It is up to the implementation to know for how long the items will be kept in cache, but likely it will come from the [OutputCache] attribute, as explained here. The actual work of invoking the cache store comes from the output caching middleware.
An implementation of IOutputCacheStore is very simple, just three methods - add (SetAsync), remove by tag (EvictByTagAsync), and get (GetAsync). Just make sure you find a fast-enough store, or you will be countering the idea of output cache!
A naive implementation that lasts forever and doesn't care about tags could be:
public class DictionaryOutputCacheStore : IOutputCacheStore
{
private readonly Dictionary<string, byte[]> _cache = new(StringComparer.InvariantCultureIgnoreCase);
public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
public ValueTask<byte[]?> GetAsync(string key, CancellationToken cancellationToken)
{
_cache.TryGetValue(key, out var value);
return new ValueTask<byte[]?>(value);
}
public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken)
{
_cache.TryAdd(key, value);
return ValueTask.CompletedTask;
}
}
Conclusion
In this post we covered a few more extension points of ASP.NET Core, this time, all related to MVC. On the next post we'll talk about the extension points that are common between MVC and Minimal APIs, so stay tuned!
Comments
Post a Comment