One of those recurring themes that seem to come back fairly regularly among .NET web developers, is the usage of generic controllers to define endpoints in their Web APIs. I have witnessed these discussions as part of ASP.NET MVC, then ASP.NET Web API and most recently in ASP.NET Core MVC.
While I don’t necessarily see a huge need or benefit for generic controllers, I can imagine that - especially in enterprise context - there are scenarios where exposing similarly structured, “cookie-cutter” CRUD endpoints quickly and seamlessly, could possibly have some business value.
Let’s have a look at generic controllers then, and how we could also dynamically feed types into them.
Some background π
So for the purpose of our today’s discussion, let’s invent a couple of types. First we will need some dummy entities, which will represent our data objects that will be generically exposed from the API.
They are regular POCOs and are shown below.
public class Book
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Author { get; set; }
}
public class Album
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Artist { get; set; }
}
Aside from our entities, let’s also come up with a very simple generic storage mechanism. It is entirely illustrative, and it’s sole purpose is for us to be able to fill our generic controller with some more meaningful code.
The generic storage service I’d propose for this post is a very simple one, based on an in-memory dictionary. It only exposes read and save operations, and is shown next.
public class Storage<T> where T : class
{
private Dictionary<Guid, T> storage = new Dictionary<Guid, T>();
public IEnumerable<T> GetAll() => storage.Values;
public T GetById(Guid id)
{
return storage.FirstOrDefault(x => x.Key == id).Value;
}
public void AddOrUpdate(Guid id, T item)
{
storage[id] = item;
}
}
Finally, equipped in all these, we can proceed to writing a generic controller.
Generic controller π
Generic controllers are not supported out of the box by ASP.NET Core MVC. That said, it’s not difficult to imagine how a generic controller would look like.
Let’s create it for now, and see how we can make it “light up” in a moment.
[Route("api/[controller]")]
public class BaseController<T> : Controller where T : class
{
private Storage<T> _storage;
public BaseController(Storage<T> storage)
{
_storage = storage;
}
[HttpGet]
public IEnumerable<T> Get()
{
return _storage.GetAll();
}
[HttpGet("{id}")]
public T Get(Guid id)
{
return _storage.GetById(id);
}
[HttpPost("{id}")]
public void Post(Guid id, [FromBody]T value)
{
_storage.Add(id, value);
}
}
There is not much to discuss here as the code speaks for itself - we simply expose operations from our generic storage over as GET/POST operations. We could take it further by adding PUT, DELETE or whatever else you’d envision in your generically designed API - the actual implementation details are of secondary importance here.
As mentioned already, ASP.NET Core wouldn’t consider BaseController
Let’s now explore the ways we could bridge this gap.
Approach 1: Inheriting from the generic controller π
The simplest solution would be to make child controllers, that inherit from BaseController
public class BookController : GenericController<Book>
{
public BookController(Storage<Book> storage) : base(storage)
{
}
}
public class AlbumController : GenericController<Album>
{
public AlbumController(Storage<Album> storage) : base(storage)
{
}
}
This works straight away, and we don’t need any additional configuration or custom extensions. The names of our controllers - book and album slot themselves into the route template from the base class [Route(“api/[controller]”)], and all of the defined GET/POST operation are automatically available.
Of course this approach is not the best, because it means we have to manually create a controller type per every entity type we’d like to include in our application.
Approach 2: Dynamic controllers π
We could avoid having to manually create a controller type per entity, if we create our own custom IApplicationFeatureProvider
In our case, we could leverage that extensibility point to match our BaseController
To get there, we will need to introduce an extra marker attribute. It is not necessary, as we could achieve that in other ways too, but I think it’s a neat way to demonstrate the feature. We will introduce a GeneratedControllerAttribute.
It will be used by us to mark the types we’d like to use in conjunction with the BaseController
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class GeneratedControllerAttribute : Attribute
{
public GeneratedControllerAttribute(string route)
{
Route = route;
}
public string Route { get; set; }
}
As part of the attribute, we also require route to be specified. This would allows us to have custom base path for each of our types, instead of relying on the generic inherited route.
Next step is to introduce the aforementioned IApplicationFeatureProvider
So in other words, in our specific case, we will suggest the MVC framework to use BaseController
public class GenericTypeControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
var currentAssembly = typeof(GenericTypeControllerFeatureProvider).Assembly;
var candidates = currentAssembly.GetExportedTypes().Where(x => x.GetCustomAttributes<GeneratedControllerAttribute>().Any());
foreach (var candidate in candidates)
{
feature.Controllers.Add(
typeof(BaseController<>).MakeGenericType(candidate).GetTypeInfo()
);
}
}
}
We still need to handle the route that we defined as part of our GeneratedControllerAttribute. We can do that easily using a custom MVC convetion, which is an excellent extensibility point for modifying how things are laid out in the framework after the controllers have been discovered.
In our custom convention, we’ll pick up the route template from the attribute and inject it into the controller as if it was an inline attribute route (equivalent to using [Route(β¦)] attribute on a controller).
public class GenericControllerRouteConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
if (controller.ControllerType.IsGenericType)
{
var genericType = controller.ControllerType.GenericTypeArguments[0];
var customNameAttribute = genericType.GetCustomAttribute<GeneratedControllerAttribute>();
if (customNameAttribute?.Route != null)
{
controller.Selectors.Add(new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(customNameAttribute.Route)),
});
}
}
}
}
In the convention, we iterate over all controller candidates, and if any of them has generic type arguments (for example BaseController
Both of these custom features - GenericTypeControllerFeatureProvider and GenericControllerRouteConvention - must be added to the MVC framework at startup. That’s done as part of the Startup class, as soon as we call AddMvc().
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(typeof(Storage<>));
services.
AddMvc(o => o.Conventions.Add(
new GenericControllerRouteConvention()
)).
ConfigureApplicationPartManager(m =>
m.FeatureProviders.Add(new GenericTypeControllerFeatureProvider()
));
}
Finally, we should add the attribute on the entities:
[GeneratedController("api/book")]
public class Book
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Author { get; set; }
}
[GeneratedController("api/v1/album")]
public class Album
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Artist { get; set; }
}
And that’s it - everything will automatically light up now, and we have our generic controllers set up. We could add however many DTO types now, annotate them with the GeneratedControllerAttribute, and have them show up as publicly callable HTTP endpoint.
Approach 3: Dynamic controllers with dynamic types π
A slightly more flexible and sophisticated variation of the previous approach could be that types may be generated on demand.
For example - what if the types for which we generate our dynamic controllers don’t exist at compile time? Perhaps we want to load them from an HTTP endpoint or from a database? I think this is when we could really reap some of the benefits of such generic setup.
To illustrate this, I have moved the type definitions out of the project and into a gist file. In this case I removed the GeneratedControllerAttribute from the types - we don’t need it anymore, since, after all, we know which types we want to use with the generic controller (we are not fishing them out from an assembly with many types).
So in our IApplicationFeatureProvider
This is shown in the next snippet, which uses the Microsoft.CodeAnalysis.CSharp (“Roslyn”) package to facilitate dynamic compilation.
public class RemoteControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
var remoteCode = new HttpClient().GetStringAsync("https://gist.githubusercontent.com/filipw/9311ce866edafde74cf539cbd01235c9/raw/6a500659a1c5d23d9cfce95d6b09da28e06c62da/types.txt").GetAwaiter().GetResult();
if (remoteCode != null)
{
var compilation = CSharpCompilation.Create("DynamicAssembly",
new[] { CSharpSyntaxTree.ParseText(remoteCode) },
new[] {
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(RemoteControllerFeatureProvider).Assembly.Location)
},
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
using (var ms = new MemoryStream())
{
var emitResult = compilation.Emit(ms);
if (!emitResult.Success)
{
// handle, log errors etc
Debug.WriteLine("Compilation failed!");
return;
}
ms.Seek(0, SeekOrigin.Begin);
var assembly = Assembly.Load(ms.ToArray());
var candidates = assembly.GetExportedTypes();
foreach (var candidate in candidates)
{
feature.Controllers.Add(
typeof(BaseController<>).MakeGenericType(candidate).GetTypeInfo()
);
}
}
}
}
}
The one piece missing here is that we’d want the routing to work. Remember, we removed the GeneratedControllerAttribute, which allowed us to specify the route before. In this case though, we could solve this by relying on the generic route as it was defined in the BaseController
Of course this won’t work straight away, because the controller name is normally the type name of the controller type, and in our generic setup the controller type is something like this BaseController
Notice the new else code path which is used when no GeneratedControllerAttribute was found.
public class GenericControllerRouteConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
if (controller.ControllerType.IsGenericType)
{
var genericType = controller.ControllerType.GenericTypeArguments[0];
var customNameAttribute = genericType.GetCustomAttribute<GeneratedControllerAttribute>();
if (customNameAttribute?.Route != null)
{
controller.Selectors.Add(new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(customNameAttribute.Route)),
});
}
else
{
controller.ControllerName = genericType.Name;
}
}
}
}
And that’s it - the HTTP endpoints are alive and they come entirely from the types defined outside of the application and compiled on the fly at startup. As soon as we add more type definitions to our external storage, the HTTP API would expand automatically upon next application restart.
We only scratched the surface here, however I hope this will nudge you in the direction you wanted to go. A lot of the decisions we made could have been taken differently - and I’m sure you will want to adapt this to your own needs if you start exploring these generic concepts.
Source code π
The source code for this post can be found on Github.