Wednesday, May 16, 2007

Creating a Custom NAnt Task with Nested Elements

NAnt has built in tasks (e.g., the copy task) that utilize nested elements, and sometimes a custom task utilizing this feature is needed. While it's certainly possible to take advantage of some of the built in nested types already provided by Nant, the bulk of the standard nested types are file related (fileset, filterchain, path, resourcefileset, etc.). If you need something more specific to your application, or if you need vastly different or more complex types, the standard types that come with NAnt are not enough.

Fortunately, creating custom NAnt tasks with nested elements is not much more difficult than creating a standard custom task.

This post we'll walk through the steps to create a task we'll call iis.httperrors. This is a task I created to be able to set the http errors for a specific site on a Windows server.

This type of task is something that can be accomplished using vbscript...

Option Explicit
Dim objIISObject
Dim objHTTPErrors
Dim intIndex
Dim arrOrigHTTPErrors
Dim arrNewHTTPErrors(43)
arrNewHTTPErrors( 0) "400,*,FILE,C:\WINDOWS\help\iisHelp\common\400.htm"
arrNewHTTPErrors( 1) = "401,1,FILE,C:\WINDOWS\help\iisHelp\common\401-1.htm"
arrNewHTTPErrors( 2) = "401,2,FILE,C:\WINDOWS\help\iisHelp\common\401-2.htm"
arrNewHTTPErrors( 3) = "401,3,FILE,C:\WINDOWS\help\iisHelp\common\401-3.htm"
arrNewHTTPErrors( 4) = "401,4,FILE,C:\WINDOWS\help\iisHelp\common\401-4.htm"
arrNewHTTPErrors( 5) = "401,5,FILE,C:\WINDOWS\help\iisHelp\common\401-5.htm"
arrNewHTTPErrors( 6) = "401,7,FILE,C:\WINDOWS\help\iisHelp\common\401-1.htm"
arrNewHTTPErrors( 7) = "403,1,FILE,C:\WINDOWS\help\iisHelp\common\403-1.htm"
arrNewHTTPErrors( 8) = "403,2,FILE,C:\WINDOWS\help\iisHelp\common\403-2.htm"
arrNewHTTPErrors( 9) = "403,3,FILE,C:\WINDOWS\help\iisHelp\common\403-3.htm"
arrNewHTTPErrors(10) = "403,4,FILE,C:\WINDOWS\help\iisHelp\common\403-4.htm"
arrNewHTTPErrors(11) = "403,5,FILE,C:\WINDOWS\help\iisHelp\common\403-5.htm"
arrNewHTTPErrors(12) = "403,6,FILE,C:\WINDOWS\help\iisHelp\common\403-6.htm"
arrNewHTTPErrors(13) = "403,7,FILE,C:\WINDOWS\help\iisHelp\common\403-7.htm"
arrNewHTTPErrors(14) = "403,8,FILE,C:\WINDOWS\help\iisHelp\common\403-8.htm"
arrNewHTTPErrors(15) = "403,9,FILE,C:\WINDOWS\help\iisHelp\common\403-9.htm"
arrNewHTTPErrors(16) = "403,10,FILE,C:\WINDOWS\help\iisHelp\common\403-10.htm"
arrNewHTTPErrors(17) = "403,11,FILE,C:\WINDOWS\help\iisHelp\common\403-11.htm"
arrNewHTTPErrors(18) = "403,12,FILE,C:\WINDOWS\help\iisHelp\common\403-12.htm"
arrNewHTTPErrors(19) = "403,13,FILE,C:\WINDOWS\help\iisHelp\common\403-13.htm"
arrNewHTTPErrors(20) = "403,14,FILE,C:\WINDOWS\help\iisHelp\common\403-14.htm"
arrNewHTTPErrors(21) = "403,15,FILE,C:\WINDOWS\help\iisHelp\common\403-15.htm"
arrNewHTTPErrors(22) = "403,16,FILE,C:\WINDOWS\help\iisHelp\common\403-16.htm"
arrNewHTTPErrors(23) = "403,17,FILE,C:\WINDOWS\help\iisHelp\common\403-17.htm"
arrNewHTTPErrors(24) = "403,18,FILE,C:\WINDOWS\help\iisHelp\common\403.htm"
arrNewHTTPErrors(25) = "403,19,FILE,C:\WINDOWS\help\iisHelp\common\403.htm"
arrNewHTTPErrors(26) = "403,20,FILE,C:\WINDOWS\help\iisHelp\common\403-20.htm"
arrNewHTTPErrors(27) = "404,1,FILE,C:\WINDOWS\help\iisHelp\common\404b.htm"
arrNewHTTPErrors(28) = "404,2,FILE,C:\WINDOWS\help\iisHelp\common\404b.htm"
arrNewHTTPErrors(29) = "404,3,FILE,C:\WINDOWS\help\iisHelp\common\404b.htm"
arrNewHTTPErrors(30) = "405,*,FILE,C:\WINDOWS\help\iisHelp\common\405.htm"
arrNewHTTPErrors(31) = "406,*,FILE,C:\WINDOWS\help\iisHelp\common\406.htm"
arrNewHTTPErrors(32) = "407,*,FILE,C:\WINDOWS\help\iisHelp\common\407.htm"
arrNewHTTPErrors(33) = "412,*,FILE,C:\WINDOWS\help\iisHelp\common\412.htm"
arrNewHTTPErrors(34) = "414,*,FILE,C:\WINDOWS\help\iisHelp\common\414.htm"
arrNewHTTPErrors(35) = "415,*,FILE,C:\WINDOWS\help\iisHelp\common\415.htm"
arrNewHTTPErrors(36) = "500,12,FILE,C:\WINDOWS\help\iisHelp\common\500-12.htm"
arrNewHTTPErrors(37) = "500,13,FILE,C:\WINDOWS\help\iisHelp\common\500-13.htm"
arrNewHTTPErrors(38) = "500,15,FILE,C:\WINDOWS\help\iisHelp\common\500-15.htm"
arrNewHTTPErrors(39) = "500,16,FILE,C:\WINDOWS\help\iisHelp\common\500.htm"
arrNewHTTPErrors(40) = "500,17,FILE,C:\WINDOWS\help\iisHelp\common\500.htm"
arrNewHTTPErrors(41) = "500,18,FILE,C:\WINDOWS\help\iisHelp\common\500.htm"
arrNewHTTPErrors(42) = "500,19,FILE,C:\WINDOWS\help\iisHelp\common\500.htm"
arrNewHTTPErrors(43) = "500,19,FILE,C:\WINDOWS\help\iisHelp\common\500.htm"

Set objIISObject = GetObject("IIS://LocalHost/W3SVC")
objIISObject.HTTPErrors = arrNewHTTPErrors
objIISObject.Setinfo
Set objIISObject = Nothing


... but things get more difficult if you want to implement this in NAnt without shelling out the execution to a separate vbscript.

You might consider executing a series of "exec" commands in NAnt to accomplish this, but you'll discover there is no single command to set an individual http error setting (well, there may actually be, but let's say for the sake of argument that there isn't an easy way to do this atomically). Wouldn't it be great if we could just include lines in a NAnt script to specify these settings?

<iis.httperrors sitename="MySite">
<httperrorset>
<httperror definition="400,*,FILE,C:\WINDOWS\help\iisHelp\common\400.htm">
<httperror definition="401,1,FILE,C:\WINDOWS\help\iisHelp\common\401-1.htm">
<httperror definition="401,2,FILE,C:\WINDOWS\help\iisHelp\common\401-2.htm">
<httperror definition="401,3,FILE,C:\WINDOWS\help\iisHelp\common\401-3.htm">
<httperror definition="401,4,FILE,C:\WINDOWS\help\iisHelp\common\401-4.htm">
...

</httperrorset>
</iis.httperrors>


Good news! We can. More good news! It's not that hard to do.

Define the xml schema



Referencing the nant script above as our guide for a schema, the first line declares the task iis.httperrors with one parameter, sitename:

<iis.httperrors sitename="MySite">


It contains an embedded type, httperrorset which is a set of zero or more httperror definitions:

<httperrorset>
<httperror definition="400,*,FILE,C:\WINDOWS\help\iisHelp\common\400.htm">
<httperror definition="401,1,FILE,C:\WINDOWS\help\iisHelp\common\401-1.htm">
<httperror definition="401,2,FILE,C:\WINDOWS\help\iisHelp\common\401-2.htm">
<httperror definition="401,3,FILE,C:\WINDOWS\help\iisHelp\common\401-3.htm">
<httperror definition="401,4,FILE,C:\WINDOWS\help\iisHelp\common\401-4.htm">
...


If we really needed to get fancy, we could break apart the definition, for example:

<httperror url="C:\WINDOWS\help\iisHelp\common\400.htm" uri="FILE" suberror="*" error="400">


This is not difficult to do either, but the goal of the post is the embeded type itself so I'm going to stick with the simpler case. As an exercise you can make the modifications yourself to accomplish the more complex schema (see hints at the end of this post).

Create the Custom Class



The first steps involve creating a ordinary custom NAnt task. Here's a summary of the steps (but I highly recommend you read and make yourself familiar with Richard Case's blog post on creating custom NAnt tasks).


  1. Create a Class Library project. The assembly name should end in "Task" and you should reference Nant.Core.dll. We'll also be using the System.DirectoryService assembly to do the work, so you can also go ahead and include that, too.
  2. Create a class (or rename the initial class) with the name IISHttpErrorTask

  3. Add the necessary using statements:
    using NAnt.Core;
    using NAnt.Core.Attributes;
    using System.DirectoryServices;

  4. Have the class derive from Task and add the TaskName attribute to the class definition.
    [TaskName("iis.httperrors")]
    class IISHttpErrorsTask : Task
    {

  5. There is only one property to define: sitename (the name of the IIS site on the local machine to update). Add this in the standard way:
    class IISHttpErrorsTask : Task
    {
    string m_siteName;

    ///
    /// The site name as displayed in IIS
    ///

    /// Required.
    [TaskAttribute("sitename", Required = true)]
    [StringValidator(AllowEmpty = false)]
    public string SiteName
    { get { return m_siteName; }
    set { m_siteName = value; }
    }

  6. For now just add a "do nothing" Execute() method. We'll come back to it later.
    /// 
    /// Executes the IISHttpErrorsTask task.
    ///

    protected override void ExecuteTask()
    {
    // TODO: Implement the Execute() method.
    }


At this point, you should be able to build the project. If all goes well, you are now ready to create the nested type!

Creating the Nested Type



Now we need to define what a NAnt "httperrorset" is, and map how the data gets loaded from the NAnt script. Add a new member variable instance to the class we just created:

IISHttpErrorSet m_httpErrorSet;


You won't be able to compile after adding this declaration because we haven't yet defined what an IISHttpErrorSet type is, but we will define that class soon enough. For now though, go ahead and add the HttpErrorSet property and add the NAnt attribute "BuildElement". This attribute tells NAnt that there is additional instance data in the IISHttpErrorSet class to load from the script.

/// 
/// Used to specify the HttpErrorSet to configure.
///

[BuildElement("httperrorset")]
public virtual IISHttpErrorSet HttpErrorSet
{ get { return m_httpErrorSet; }
set { m_httpErrorSet = value; }
}


Ok, if you are like me, having dangling class definitions like IISHttpErrorSet is something that just nags at you, so let's get to it. Create a new class called IISHttpErrorSet, add the necessary using statements:

using NAnt.Core;
using NAnt.Core.Attributes;


Derive the class from DataTypeBase and also add the NAnt attribute "ElementName".

[ElementName("httperrorset")]
class IISHttpErrorSet : DataTypeBase
{


So now you can start to see how NAnt pulls all this together. In the IISHttpErrorsTask we declared a property with a BuildElement attribute, and we have just created a class with an ElementType attribute, and both have the same value. This essentially maps these guys to each other, allowing NAnt to build the "httperrorset" from the data in the script. Cool, huh? This should all compile without errors now, but we're not done yet. NAnt has no idea what kind of data belongs to this thing we have defined as an "httperrorset" so it's now time to get that going.

If you peruse the NAnt classes in the Object Browser you'll notice that NAnt has a convention of adding all the classes for their nested types in the NAnt.Core.Types namespace, whereas the tasks can be found in the NAnt.Core.Tasks namespace. You can (and probably should) adopt a similar convention for your own embedded types; however, for the purpose of making this example easier to follow, I've simply added the nested types to the same assembly as the task.


If we say that an "httperror" definition is essentially a string, we can define our "httperrorset" implementation as a collection of strings. Add a member variable to the IISHttpErrorSet to reflect this:

private StringCollection m_httpErrors = new StringCollection();


To make it easier to "get at" this collection from another class, let's go ahead and define a read-only property:

/// 
/// Gets the collection of HttpError strings
///

public StringCollection HttpErrors
{
get { return m_httpErrors; }
}


To have NAnt build this collection from data in the script we need to define a specialized property. This property has an the BuildElementArray attribute "httperror" defined.

/// 
/// The items to include in the httperrorset.
///

[BuildElementArray("httperror")]
public HttpError[] HttpErrorElements
{
set
{ foreach (HttpError httpError in value)
{
HttpErrors.Add(httpError.Definition);
}
}
}


Ok, I pulled another one of those dangling definitions. The HttpError type is undefined, so this will not yet compile. If we're just loading a bunch of strings and we've already defined an httperrorset as a collection of strings, why do we need this extra type? Because it will help us set up a complex element type later (if we need one).

We're going to define the HttpError type as an embedded class which derives from Element:

[ElementName("httperrorset")]
class IISHttpErrorSet : DataTypeBase
{
...

public class HttpError : Element
{
private string m_definition;

///
/// The http error definition.
///

[TaskAttribute("definition", Required = true)]
[StringValidator(AllowEmpty = false)]
public virtual string Definition
{
get { return m_definition; }
set { m_definition = value; }
}
}


This class has a single member variable (the string which defines the error) and a single read/write property for get/set operations. Notice also that the property has TaskAttribute and StringValidator attributes very similar to the TaskAttributes we've seen before.

Testing what we have so far



At this point you should be able to compile the assembly. You can test that the data gets loaded properly by adding the following to the Execute() method:

protected override void ExecuteTask()
{
foreach (string httpError in m_httpErrorSet.HttpErrors)
{
Project.Log(this, Level.Info, String.Format("Found httperror: {0}", httpError));
}
}


I'm going to follow up the implementation of the Execute() method using ADSI in a later post. There's actually a lot of ground to cover there and I don't want to shift the focus of creating the framework for the custom NAnt script nested types.

More complex Elements



I mentioned earlier in this post that it should be possible to create a more complex type for the "httperror" element:

>httperror url="C:\WINDOWS\help\iisHelp\common\400.htm" uri="FILE" suberror="*" error="400">


By now, it should be pretty obvious how you might go about this. In the embedded HttpError class we just added, just add some additional member variables and properties with the necessary TaskAttributes for each of the parameters. Presto. You now have a class that represents a complex element. Have fun!

No comments: