Generic and dynamically generated controllers in ASP.NET Core MVC

Β· 1953 words Β· 10 minutes to read

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 as a valid controller. The reason is quite obvious - it wouldn’t know what to put in the T.

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 and fill in the type parameter. This way, the child controller is a perfectly valid non-generic controller anymore, and it can be discovered by MVC without problems.

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. This extensibility point is invoked at application startup, and allows us to explicitly inject certain types that should be treated by MVC as controllers. This means, that we could use as controllers certain types that would normally not be discovered by the default controller discovery mechanism.

In our case, we could leverage that extensibility point to match our BaseController with specific entity types, and use those as concrete controllers.

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 as HTTP endpoints. The reason for that is of course we don’t want generic controllers for all the types in our current Web API assembly. Obviously, we could do that differently too i.e. by dedicated a specific assembly for the DTOs, and just creating controllers for all the types from that - it’s up to you how you’d want to handle that.

[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 implementation, that would discover types annotated with the attribute, create the generic controller types and include them as controller candidates for the MVC framework.

So in other words, in our specific case, we will suggest the MVC framework to use BaseController and BaseController as controllers.

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) we will pick up the route from the attribute and use it as a so-called SelectorModel. Again, at the end of the day, this is equivalent to defining an inline attribute route.

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 we will have to download them, use the C# compiler as a service to compile them, emit an assembly and use types from that dynamically emitted assembly as type arguments for our BaseController.

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 - [Route(“api/[controller]”)]. So the name of the controller will dictate how the route looks like.

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, and its type name is not a friendly one (something like BaseController[FullNamespace.Book]). However we can easily recitify it by using the name of our type parameter as controller name - and this can be done by modifying our existing convention.

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.

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP