Recently I faced an interesting problem, where we needed to provide controllers with controller-specific configuration - but based on settings only known at runtime.
In Web API, per-controller configuration is a very useful, yet little known feature (aside from a great blog post by Mike Stall), as it allows you to create configuration profiles and assign them to specific controllers.
However it is only supported statically - through attributes, so it cannot be altered at runtime. Let’s have a look at how you might be able to hack away at it.
Controller configuration - the classic way π
In a traditional per controller configuration, you have to implement an IControllerConfiguration interface as an Attribute and decorate a given controller with it.
For example:
public class JsonOnlyAttribute : Attribute, IControllerConfiguration
{
public void Initialize(HttpControllerSettings controllerSettings,
HttpControllerDescriptor controllerDescriptor)
{
controllerSettings.Formatters.Clear();
controllerSettings.Formatters.Add(new JsonMediaTypeFormatter());
}
}
What Web API pipeline would do, is that in the HttpControllerDescriptor, the first time a given controller is accessed, it would look for the per controller configuration attributes on it in a private method:
private static void InvokeAttributesOnControllerType(HttpControllerDescriptor controllerDescriptor, Type type)
It would then copy the default configuration (pointing to the global HttpConfiguration) and merge its ParameterBindingRules, Services and Formatters with the ones supplied through the custom configuration (or rather, through HttpControllerSettings).
Unfortunately the fact that it’s only applicable through an attribute, prevents us from providing this controller-scoped configuration at runtime. You could add attributes at runtime through TypeDescriptor but they would have to be retrieved through it too; Web API reads them through the good old GetCustomAttributes() instead, and that means the config has to be applied at compile time.
Controller configuration - the dynamic way π
This is a hacky way, but I feel like it’s justifiable. It all comes down to accessing a private constructor of HttpConfiguration - the one that is responsible for merging the old configuration and HttpControllerSettings and producing a controller-scoped configuration.
We could do that through reflection and create a custom extension method. Mind you, we’ll cache the config afterwards so we can take the performance hit once.
public static class HttpConfigurationExtensions
{
public static HttpConfiguration Copy(this HttpConfiguration configuration, Action<HttpControllerSettings> settings)
{
var controllerSettings = new HttpControllerSettings(configuration);
settings(controllerSettings);
var constructor = typeof(HttpConfiguration).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, new[] {typeof(HttpConfiguration), typeof(HttpControllerSettings)}, null);
var instance = (HttpConfiguration)constructor.Invoke(new object[] {configuration, controllerSettings});
return instance;
}
}
In this method we create new instance of HttpControllerSettings and apply the changes provided by the configuring user. Then we invoke a private constructor of HttpConfiguration that creates a configuration object adjusted by the HttpControllerSettings
private HttpConfiguration(HttpConfiguration configuration, HttpControllerSettings settings)
Next, we need some interafce/storage to configure the controllers, for example a dummy Dictionary would be enough for this demo:
public static class ControllerConfig
{
public static Dictionary<Type, Action<HttpControllerSettings>> Map = new Dictionary<Type, Action<HttpControllerSettings>>();
}
In the configuration of Web API we could now dynamically add controllers with settings to it. For example, let’s imagine TestController should use only JSON, then we simply register that in the mapping:
var config = new HttpSelfHostConfiguration("http://localhost:999");
config.Routes.MapHttpRoute("API Default", "api/{controller}/{id}", new { id = RouteParameter.Optional });
ControllerConfig.Map.Add(typeof(TestController), settings =>
{
settings.Formatters.Clear();
settings.Formatters.Add(new JsonMediaTypeFormatter());
});
using (var server = new HttpSelfHostServer(config))
{
server.OpenAsync().Wait();
Console.WriteLine("Press Enter to quit.");
Console.ReadLine();
}
Of course, this will still not work, because we don’t invoke our new configuration extension method anywhere, and that is the last piece to our puzzle - a customized IHttpControllerActivator.
public class PerControllerConfigActivator : IHttpControllerActivator
{
private static readonly DefaultHttpControllerActivator Default = new DefaultHttpControllerActivator();
private readonly ConcurrentDictionary<Type, HttpConfiguration> _cache = new ConcurrentDictionary<Type, HttpConfiguration>();
public IHttpController Create(HttpRequestMessage request, HttpControllerDescriptor controllerDescriptor, Type controllerType)
{
HttpConfiguration controllerConfig;
if (_cache.TryGetValue(controllerType, out controllerConfig))
{
controllerDescriptor.Configuration = controllerConfig;
}
else if (ControllerConfig.Map.ContainsKey(controllerType))
{
controllerDescriptor.Configuration = controllerDescriptor.Configuration.Copy(ControllerConfig.Map[controllerType]);
_cache.TryAdd(controllerType, controllerDescriptor.Configuration);
}
var result = Default.Create(request, controllerDescriptor, controllerType);
return result;
}
}
What it does is it will check if the per-controller configuration mapping contains an entry for the current controller, and if it does, apply the custom config on the HttpControllerDescriptor. Then it will simply use Web APIs DefaultHttpControllerActivator to create an ApiController instance.
Since we don’t want to pay the reflection penalty, we also cache the configuration (we cannot cache ApiController as Web API would error out! they are nto reusable). At this point you may want to implement some logic to invalidate the cache under your own specific conditions or scenarios.
You now need to register the activator:
var config = new HttpSelfHostConfiguration("http://localhost:999");
config.Routes.MapHttpRoute("API Default", "api/{controller}/{id}", new { id = RouteParameter.Optional });
config.Services.Replace(typeof(IHttpControllerActivator), new PerControllerConfigActivator());
From now on, navigating to TestController will use the config with JSON formatter only. All other controllers, still use the global configuration.
Summary π
While this is a very hacky way to approach the problem, it does solve a considerable limitation in Web API, and allows you to apply custom configuration at runtime.
This might be something the team would like to look at, and allow more flexible way of applying per-controller configuration, rather than just through attributes.