Introduction
In part one of this article, I went over the implementation of a plugin architecture for ASP.NET MVC applications and provided code for a solution. Although the initial implementation met the requirements at the time, I noted at the end of the article that there are several outstanding issues that should be addressed.
- If the plugin does not apply the
[PluginActionFilter]
attribute, it can bypass the ability to be deactivated. - There should be some generic test cases to ensure that the plugin project meets the minimum requirements.
- The handling of resources, other than views which are embedded, such as images and javascript files.
In the second, and final chapter of this article, I will cover each of these concerns and provide updated solutions to resolve them.
You Shall Not Pass!
The management of the plugins is handled by the PluginManager class. It maintains state information about each plugin, such as the pluginName and activated properties (within a list of PluginStatus objects), and provides methods to query and set said information. In the sample host application, an administration form provides functionality for activating and deactivating plugins, and the ASP.NET MVC override OnActionExecuting uses an attribute called PluginActionFilter
on the Index actions to either allow the plugin to function normally or redirect to a DeactivatedPage action. This, however, works on the honor system in that it's up to the plugin developer to add [PluginActionFilter]
to the Index action. Now, in the updated version of the PluginManager class, we have the tools to enforce this rule by requring that plugins be considered "valid" only when their Index method has the [PluginActionFilter]
attribute.
PluginHelper
Additional methods and updates were made to PluginHelper to query additional information about the plugin assembly.
HasPluginActionFilter
- this method checks to see if a plugin assembly is implementing the [PluginActionFilter]
attribute on the Index action method.
Private static bool HasPluginActionFilter(Assembly plugin)
{
var types = plugin.GetTypes().Where(t => t.BaseType == typeof(System.Web.Mvc.Controller));
foreach (var type in types)
{
if (type != null)
{
var method = type.GetMethod("Index");
if (method != null)
{
var actionFilterAttributes = method.GetCustomAttributes(typeof(PluginActionFilter), false);
if (actionFilterAttributes.Count() > 0)
return true;
}
}
}
return false;
}
InitializePluginsAndLoadViewLocations
- this method was updated to call HasPluginActionFilter, and will only register the plugin if it returns "true". If the plugin does not have the PluginActionFilter
attribute specified for its Index action method, it will be marked invalid.
public static void InitializePluginsAndLoadViewLocations(bool defaultStatus)
{
Assembly[] pluginAssemblies = GetPluginAssemblies();
List<string> viewLocations = new List<string>();
foreach (Assembly plugin in pluginAssemblies)
{
var pluginAttribute = plugin.GetCustomAttributes(typeof(MvcPluginViewLocations), false).FirstOrDefault() as MvcPluginViewLocations;
if (pluginAttribute != null)
{
if (HasPluginActionFilter(plugin))
{
viewLocations.AddRange(pluginAttribute.viewLocations);
PluginManager.RegisterPlugin(plugin.ManifestModule.Name,DefaultStatus);
ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new PluginViewEngine(viewLocations.ToArray()));
}
else
{
PluginManager.InvalidatePlugin(plugin.ManifestModule.Name);
}
}
}
}
PluginManager
Here are the changes that were made to the PluginManager class to make use of the updates to PluginHelper.
PluginStatus
- added the property valid to track the validity of the plugin.
public class PluginStatus
{
public string pluginName;
public bool activated;
public bool valid;
}
IsPluginValid
- returns "true" if the plugin is valid, otherwise "false".
public static bool IsPluginValid(string name)
{
var plugin = PluginList.First(p => p.pluginName == name);
return (plugin == null ? false : plugin.valid);
}
SetPluginStatus
- this method was updated to check the validity of the plugin before activating it.
public static void SetPluginStatus(string name, bool status)
{
var plugin = PluginList.First(p => p.pluginName == name);
if (!plugin.valid && status == true)
throw new Exception("An invalid plugin cannot be activated.");
if (plugin != null)
plugin.activated = status;
}
Testing
In order to make sure a plugin meets the minimum requirements needed to actually be a plugin, we will add some automated unit testing into the mix.
Validity
The requirements for a valid plugin are simple. It has to have an Index action and that Index action has to have a [PluginActionFilter]
attribute. Below is the test method.
[TestMethod]
public void IndexHasPluginActionFilter()
{
var indexMethod = pluginControllerInstance.GetType().GetMethod("Index");
Assert.IsNotNull(indexMethod, "Index action method does not exist in plugin.");
var actionFilterAttributes = indexMethod.GetCustomAttributes(typeof(PluginActionFilter), false);
Assert.IsTrue(actionFilterAttributes.Count() > 0, "Index action method does not have [PluginActionFilter] attribute.");
}
To try this out, simply remove the [PluginActionFilter]
attribute from the Index method, recompile and execute the test case. Here is what you will get.
This test method also checks to make sure you have an Index action method defined, and will fail if not.
Embedded Views
Now this one is interesting. Any views provided by the plugin have to be embedded, but there is nothing stopping a plugin from using a view that is provided by the hosting application. The other question is, "How do we determine what view is being requested within an action?"
The short answer is, "I don't think we can do that."
The one thing we can do is assume that there should be at least one embedded view, and if there is not issue some sort of a warning. In this case, the warning will be an Inconclusive assertion.
[TestMethod]
public void HasEmbeddedViews()
{
int numViews = pluginControllerInstance.GetType().Assembly.GetCustomAttributes(typeof(MvcPluginViewLocations), false).Count();
if (numViews == 0)
Assert.Inconclusive("Warning: There are no embedded views.");
}
Here is what you will see if there are no embedded views in your plugin.
Additional Resources
In order to access the views, which in a normal MVC application are separate files, the plugin must internalize them as embedded resources. Well, what about other files, such as images and javascript files? For the scope of this article we will not worry about files that are expected to be part of the hosting application, and will focus on files that are assumed to be specific to the plugin. Since we already have some of the plumbing to reference and use embedded resource files, let's take a look at leveraging it to access other files.
Referencing the Resource
We already know how to embed these resources, so what we need to do now is find out how to reference and use them. The plugin library already expects embedded views to have URLs that start with ~/Plugins. The library also references the plugin in the URL. So, let's take a look at doing the same. After some poking around, here is what I came up with.
The example plugin now has an image. Since it is embedded, we need to compose the URL in such a way that the plugin library can know it's embedded, know which plugin assembly it's embedded in, and be able to retrieve it when the request is made by the browser. Here is the URL.
<img src="/Plugins/PluginExample.dll/Resources.Santa_hat_smiley_T.png" />
- /Plugins identify that this resource is embedded.
- PluginExample.dll identifies the assembly.
- Resources.Santa_hat_smiley_T.png is the embedded resource.
Note that the embedded resource in the URL is different than when viewed in the dll when decompiled, and that the URL does not have the ~. This is because the resource is prepended with the assembly name, and IsAppResourcePath method of the AssemblyResourceProvider class prepends the tilde.
Handling the Resource Request
In order to handle requests to embedded resources we need to intercept them. Since we are already doing this for the views, all we need to do is make updates to the existing code to handle other files. We start by making sure we disable routing for the files we are interested in (.css, .js, .gif, .jpg, .png), by adding the following to the RegisterRoutes method in Global.ascx.cs.
routes.IgnoreRoute("{*staticfile}", new { staticfile = @".*\.(css|js|gif|jpg|png)(/.*)?" });
Now, we have to update the AssemblyResourceProvider class to check for embedded resources. Note that we have to take into account the namespace since it is added to the resource in the assmbly. This is the code that gets the name from the assembly:
string assemblyNamespace = assembly.GetName().Name;
This information is used in the updated FileExists method of the provider, so that it will also check the assembly for the existence of the requested file.
bool found = Array.Exists(resourceList,
delegate(string r) { return r.Equals(resourceName); })
|| Array.Exists(resourceList,
delegate(string r) { return r.Equals(assemblyNamespace + "." + resourceName); });
Once the AssemblyResourceProvider methods indicate that the request is for an embedded resource, we need to make sure we update the AssemblyResourceVirtualFile class to retrieve the file from the assembly. So we update the Open method to check to see if the resource exists with the namespace prepended, and if it is use that when calling GetManifestResourceStream. Here is the code snippet:
...
string assemblyNamespace = assembly.GetName().Name;
if (assembly != null)
{
if (assembly.GetManifestResourceInfo(resourceName) == null)
{
resourceName = assemblyNamespace + "." + resourceName;
}
return assembly.GetManifestResourceStream(resourceName);
}
...
Conclusion
Now we have a more complete solution for the implementation of a plugin architecture for ASP.NET MVC2 applications.
References
Ignoring routes for specific file types.
History