28 November 2016

Generic controllers in ASP.Net Core

ASP.Net Core ignores generic controllers by default, so you have to add support for them yourself at start up. This is done by manipulating application parts, a mechanism which allow you to discover and load MVC features at runtime.

It’s worth pointing out that approach can give rise to a kind of generic data repository that I regard as a fairly lazy anti-pattern. A generic solution may reduce the amount of code but it often provides too much of a generalisation that does not define a meaningful contract. That said, it can be a useful approach to addressing some uncommon generic use cases, such as front-line import APIs that just ingest data for validation and processing elsewhere.

Identifying the entities

At application start-up the feature provider will need a list of the entity classes that can be passed to the generic controller. You will need a mechanism for creating a list of TypeInfo objects for each entity class that you want to support.

The easiest way of doing this is just to maintain a list of classes, i.e.

public static class IncludedEntities
{
    public static IReadOnlyList<TypeInfo> Types = new List<TypeInfo>
    {
        typeof(Animals).GetTypeInfo(),
        typeof(Insects).GetTypeInfo(),
    };
}

This is a little inelegant as you have to remember to add entries to the list every time you want the API to serve up a new entity class. Ideally you should use a mechanism such as an attribute or base class that can be used to mark model classes. Some discovery code can be used at start up to detect all the entity classes that you need to expose in the API.

For example, if we create the following attribute and use it to mark a simple entity class:

/// <summary>
/// This is just a marker attribute used to allow us to identifier which entities to expose in the API
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ApiEntityAttribute : Attribute
{
}

// These are two example entities that will be supported by the generic controller
 
[ApiEntityAttribute]
public class Animals { }
 
[ApiEntityAttribute]
public class Insects { }

This attribute can be used in a static class to build the required list of TypeInfo objects as show below:

public static class IncludedEntities
{
    public static IReadOnlyList<TypeInfo> Types;
 
    static IncludedEntities()
    {
        var assembly = typeof(IncludedEntities).GetTypeInfo().Assembly;
        var typeList = new List<TypeInfo>();
 
        foreach (Type type in assembly.GetTypes())
        {
            if (type.GetCustomAttributes(typeof(ApiEntityAttribute), true).Length > 0)
            {
                typeList.Add(type.GetTypeInfo());
            }
        }
 
        Types = typeList;
    }
}

When EntityTypes.Types is first referred to it will built a list of all the included entity types in the assembly.

Create the generic controller

In creating a generic controller we want any URL routes to default to the name of the entity class rather than the name of the controller. This can be done by creating a class attribute that implements the IControllerModelConvention interface as shown below:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class GenericControllerNameAttribute : AttributeIControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        if (controller.ControllerType.GetGenericTypeDefinition() == typeof(GenericController<>))
        {
            var entityType = controller.ControllerType.GenericTypeArguments[0];
            controller.ControllerName = entityType.Name;
        }
    }
}

This attribute is added to the controller class definition along with a Route attribute, ensuring that the name of the model class will define the route:

[Route("[controller]")]
[GenericControllerNameAttribute]
public class GenericController<T> : Controller
{
    [HttpGet]
    public IActionResult IndexAsync()
    {
        return Content($"GET from a {typeof(T).Name} controller.");
    }
 
    [HttpPost]
    public IActionResult Create([FromBodyIEnumerable<T> items)
    {
        return Content($"POST to a {typeof(T).Name} controller.");
    }
}

Adding the feature provider

The feature provider runs at start up and it runs after the regular ControllerTypeProvider class. It looks through the collection of TypeInfo objects we have created for all the model classes and adds them in as supported types for our generic controller.

public class GenericControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
    public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
    {
        // Get the list of entities that we want to support for the generic controller
        foreach (var entityType in IncludedEntities.Types)
        {
            var typeName = entityType.Name + "Controller";
                
            // Check to see if there is a "real" controller for this class
            if (!feature.Controllers.Any(t => t.Name == typeName))
            {
                // Create a generic controller for this type
                var controllerType = typeof(GenericController<>).MakeGenericType(entityType.AsType()).GetTypeInfo();
                feature.Controllers.Add(controllerType);
            }
        }
    }
}

This is wired up to the MVC runtime in the ConfigureServices() method of the Startup class as shown below:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().ConfigureApplicationPartManager(p =>
        p.FeatureProviders.Add(new GenericControllerFeatureProvider()));
 
}

Now you should be able to access generic types using URLs based on the entity names, e.g. http://localhost/animals.

Filed under .Net Core, ASP.NET, Web services.