Monday, July 21, 2008

Developing ASP.NET applications for Microsoft Dynamics CRM 4.0

This is a great article on Stunware's blog


I'm modifying the ISV configuration file quite often and at some point I got really tired about exporting the isv.config, extracting the customization file from the downloaded zip file, opening Visual Studio, loading the file, making changes, saving and re-importing in CRM. Export and import should only be necessary when deploying customizations, but not to add a button. I thought of creating a simple Windows Forms tool in the beginning and it wouldn't take more than 15 minutes. However, I realized that this would be a perfect sample to show how to develop a custom ASP.NET solution targeting Microsoft Dynmaics CRM 4.0.

I haven't started the implementation yet. Instead I decided to start writing the article first and develop the application while writing. This will make it much easier to create screenshots and it also is a good way to not forget anything important. I'm going to use Visual Studio 2008 and the final solution will be available as a download in this article. And as always I'm using C# and not VB.NET.

Creating the solution in Visual Studio 2008

Open Visual Studio 2008 and create a new project.

Select the "ASP.NET Web Application" template and provide a unique name for the solution. Make sure to select the .NET Framework 3.0 in the combo box at the upper right. If you select the .NET Framework 2.0, then you cannot add a reference to the Microsoft SDK assemblies, because they require the .NET Framework 3.0. You could use the .NET Framework 3.5, giving access to new language features, but then you require the .NET 3.5 Framework to be installed on the server. You can change the Framework type at any time though.

Click on the OK button to create the new project. Visual Studio adds the default.aspx page and a web.config to your solution:

Adding the CRM SDK assemblies

Server-side applications running in the context of CRM should use the assemblies from the Microsoft CRM SDK. Such applications are:

  • Plug-Ins
  • Custom Workflow Activities
  • ASP.NET applications deployed to the ISV subfolder of the CRM web

Our application will be deployed to the ISV subfolder, so the first thing to do is adding the SDK assemblies to our project. Assemblies are added to an ASP.NET solution like in any other project type. Right-Click on the "References" node and select "Add Reference":

The "Add Reference" dialog pops up. Navigate to the Browse tab and select the bin folder of the CRM SDK. If you haven't installed the SDK so far, then download it from http://www.microsoft.com/downloads/details.aspx?FamilyID=82E632A7-FAF9-41E0-8EC1-A2662AAE9DFB&displaylang=en.

Select the microsoft.crm.sdk.dll and microsoft.crm.sdktypeproxy.dll assemblies and click OK to add the references.

Designing the web page

The basic idea of this solution is editing the ISV customizations directly in Internet Explorer. So we need an editor and buttons to read and save the ISV configuration XML. I'm usually designing pages in FrontPage rather than Visual Studio, but we only have to add a single text box and two buttons and I don't care about any fancy design to make it a good looking page. So let's stay in Visual Studio and switch to the "Design" tab of the default.aspx file:

The div section at the top is added automatically and we place our controls right inside it. Let's start with the "Read" button. It's intended to read or refresh the displayed XML and I want it to be on the top left corner of the form.

Open the toolbox and select the "Button" element:

Now drag it onto the div element in the form to create the button:

Click on the button and switch to the properties window. Visual Studio names the button "Button1" and it's always a good idea to change it, even in test projects. Having code accessing properties like "Button1" or "TextBox2" isn't easy to read, so let's change it to "btnReadIsvConfig":

This name clearly says what the control is for: it's a button (btn) and it's used to the read the ISV Configuration.

We also don't want the button text to be "Button1", so change it to "Read/Refresh":

Our page now looks like this:

Besides reading the ISV Configuration we also want to save our changes, so repeat the same steps to add a second button and change its name to "btnSaveIsvConfig" and its text to "Save":

Finally we need a text box to display and edit the XML content and we want it to be displayed below the buttons. In the design window place the cursor behind the save button and press the Enter key to add a new line. Then open the toolbox window and select the TextBox control:

Drag it to the designer window and change the control name to "txtXml" in the properties window:

A single-line textbox certainly isn't well suited to display and edit XML content, so change the "Rows", "TextMode" and "Width" properties to have it display more text:

That's our basic setup. Before starting to write code, hit F5 to see what we have so far. You will be presented with the following dialog:

Accept the default setting and let Visual Studio add the appropriate debug statement to the web.config file. All it changes is the following line:

<compilation debug="false"> to <compilation debug="true">

You may get an additional warning if you haven't enabled script debugging in Internet Explorer:

Here's our page in Internet Explorer:

Connecting to Microsoft Dynamics CRM

Now it's time to write code. The first thing is adding appropriate using directives for easier access to objects we need:

using Microsoft.Crm.Sdk;
using Microsoft.Crm.Sdk.Query;
using Microsoft.Crm.SdkTypeProxy;
using Microsoft.Win32;

Microsoft.Win32 was added because we need accessing the registry to retrieve the proper CRM service URL.

The general code in the Page_Load event of an ASP.NET form targeting Microsoft Dynamics CRM 4.0 is this:

protected void Page_Load(object sender, EventArgs e) {

    this._isDevEnvironment = this.IsDevEnvironment();

    if (this._isDevEnvironment) {
        this._orgName = this.DevEnvOrganization;
        this._crmServiceUrl = this.DevEnvCrmServiceUrl;

        this.OnLoad();
    }   

    else {
        this._orgName = this.ParseOrgName();
        this._crmServiceUrl = this.BuildCrmServiceUrl();

        using (new CrmImpersonator()) {
            this.OnLoad();
        }
    }
}

private void OnLoad() {

    CrmAuthenticationToken token = this.CreateAuthenticationToken();
    CrmService crmService = this.CreateCrmService(token);

    //use crmService
}

There are a lot of helper methods in the code and I'm going to explain all of them. The most important thing, however, is the CrmImpersonator object that is required to run in the correct context. From the CRM SDK documentation:

When used inside a using statement, the CrmImpersonator class allows a block of code to execute under the process credentials instead of the running thread's identity. At the end of the using statement, execution will return back to running under thread id. For more information see the MSDN documentation for ImpersonateSelf, located at http://msdn2.microsoft.com/en-us/library/aa378729.aspx.

While developing though, the code is executed on our development machine and we don't want to use the CrmImpersonator. Furthermore, there is no context information passed that we can use to extract the organization name and the required registry settings to build the CRM service URL aren't available as well.

Determining if the code is executed in our development environment

There are many ways to differentiate if our code is deployed or not, so feel free to use any approach you like. I have added two keys to the web.config:

<?xml version="1.0"?>
<configuration>
    <appSettings>
        <add key="StunnwareDev_CrmServiceUrl" value="http://dc:5555/mscrmservices/2007/crmservice.asmx"/>
        <add key="StunnwareDev_Organization" value="sw"/>
    </appSettings>

And I have added a simple check to determine if these values exist:

private bool IsDevEnvironment() {
    return !string.IsNullOrEmpty(this.DevEnvCrmServiceUrl) &&
        !string.IsNullOrEmpty(this.DevEnvOrganization);
}

private string DevEnvCrmServiceUrl {
    get {
        return System.Configuration.ConfigurationManager.AppSettings["StunnwareDev_CrmServiceUrl"];
    }
}

private string DevEnvOrganization {
    get {
        return System.Configuration.ConfigurationManager.AppSettings["StunnwareDev_Organization"];
    }
}

The "StunnwareDev_CrmServiceUrl" and "StunnwareDev_Organization" values won't be present in the web.config file on the CRM server, but it allows us to easily configure these values in our development environment.

Parsing the organization name from the request URL

If our code is deployed on a CRM server then, of course, we cannot rely on configuration values. But there's no need for it, because all the information we need is available. Let's start with the organization name, which is required in multi-tenant environments. I'm going to deploy the default.aspx page to /isv/stunnware.com/isveditor/default.aspx at the end of this article.

When accessed from Internet Explorer, the request URL will be one of these:

  • http://server:port/organization/isv/stunnware.com/isveditor/default.aspx - Used in on-premise installations. The organization is specified as a virtual directory.
  • http://server:port/isv/stunnware.com/isveditor/default.aspx - Same as above, but without an organization name. The default organization has to be used.
  • http://organisation.server.com/isv/stunnware.com/isveditor/default.aspx - Used in hosted environments. The organization is specified as a host header.

When called from a custom element in the CRM sitemap or ISV.Config and the PassParams flag is set to "1", the organization name is also passed in the query string, for instance http://server:port/organization/isv/stunnware.com/isveditor/default.aspx?orgname=organization.

The ParseOrgName method does all of the above. It's based on what is documented in the SDK, but uses a slightly different implementation:

private string ParseOrgName() {

    string orgQueryString = this.Request.QueryString["orgname"];

    //Retrieve the Query String from the current URL
    if (!string.IsNullOrEmpty(orgQueryString)) {
        return orgQueryString;
    }

    else {
        string[] segments = this.Request.Url.Segments;

        //Windows Auth URL: parses the organization from http://server:port/organization/isv/...
        if ((segments.Length > 2) &&
            segments[2].TrimEnd('/').Equals("isv", StringComparison.OrdinalIgnoreCase)) {
            return segments[1].TrimEnd('/');
        }

        int p = this.Request.Url.Host.IndexOf('.');

        if (p != -1) {
            //IFD URL: parses the organization from http://organization.domain.com/isv/...
            return this.Request.Url.Host.Substring(0, p);
        }

        //No organization name found
        return string.Empty;
    }
}

Note that his method can fail. If the orgname parameter is not included in the query string and you are accessing CRM through a host header that does not match the CRM organization name, then the returned string will be incorrect.

Building the CRM Service URL

Building the correct service URL when running on a CRM server is simple:

private string BuildCrmServiceUrl() {
    RegistryKey regkey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\MSCRM");
    string serverUrl = regkey.GetValue("ServerUrl").ToString();
    return serverUrl + "/2007/crmservice.asmx";
}

Note that this code cannot be used when running in the Outlook Offline client. However, this solution will not support offline access, so there is no need to check for the Outlook client at all. Further information about how to build the correct URL in offline scenarios is available in the CRM SDK documentation.

Creating the CrmService object

Creating the CrmService object now is straightforward:

private CrmAuthenticationToken CreateAuthenticationToken() {

    CrmAuthenticationToken token;

    if (this._isDevEnvironment) {
        token = new CrmAuthenticationToken();
    }

    else {
        token = CrmAuthenticationToken.ExtractCrmAuthenticationToken(this.Context, this._orgName);
    }

    token.OrganizationName = this._orgName;
    token.AuthenticationType = 0;

    return token;
}

private CrmService CreateCrmService(CrmAuthenticationToken token) {

    CrmService service = new CrmService();
    service.Credentials = System.Net.CredentialCache.DefaultCredentials;
    service.CrmAuthenticationTokenValue = token;
    service.Url = this._crmServiceUrl;

    return service;
}

When running in the development environment, the CreateAuthenticationToken method simply creates a new authentication token like you would do when using the CRM web services. When running in the context of CRM though, the ExtractCrmAuthenticationToken method is used to obtain the correct token. The result of the CreateAuthenticationToken method is then passed to the CreateCrmService method, which finally gives us a CrmService object to use.

All of the above is housekeeping and we haven't even started with the solution. However, with the basic setup being done, we are now ready to create our editor.

Implementing the code for the Read button

When clicking the Read/Refresh button on the default.aspx page, we want to access the current ISV Configuration XML and display it in our multi-line text box. Adding a click event is the same as in a Windows Forms project, so simply double-click the Read/Refresh button in the designer window and Visual Studio creates an empty event handler for you:

protected void btnReadIsvConfig_Click(object sender, EventArgs e) {
}

The problem we are facing now, is that the btnReadIsvConfig_Click method is executed after Page_OnLoad has finished and therefore the CrmImpersonator object already is disposed and we are not running in the correct user context. So what is using (new CrmImpersonator()) all about?

The magic behind using(new CrmImpersonator())

The using statement is just an easy way of creating a resource on the fly and calling its Dispose method at the end. In other words, the following two code fragments do the same:

using (new CrmImpersonator()) {
    //do stuff
}

CrmImpersonator impersonator = new CrmImpersonator();
//do stuff
impersonator.Dispose();

The using statement implicitly calls the Dispose method when leaving the using block, which is the reason why you can only use objects implementing IDisposable in the using declaration. Without knowing the CrmImpersonator source, it will be roughly this:

public class CrmImpersonator : IDisposable {

    public CrmImpersonator() {
        //change user context
    }

    public void Dispose() {
        //revert to original user context
    }
}

To let our entire page use impersonation until we are finished, I'm restructuring the code a bit:

public partial class _Default : System.Web.UI.Page {

    bool _isDevEnvironment;
    string _orgName;
    string _crmServiceUrl;
    CrmImpersonator _impersonator;
    CrmService _crmService;

    protected void Page_Load(object sender, EventArgs e) {

        this._isDevEnvironment = this.IsDevEnvironment();

        if (this._isDevEnvironment) {
            this._orgName = this.DevEnvOrganization;
            this._crmServiceUrl = this.DevEnvCrmServiceUrl;
        }

        else {
            this._orgName = this.ParseOrgName();
            this._crmServiceUrl = this.BuildCrmServiceUrl();
            this._impersonator = new CrmImpersonator();
        }

        CrmAuthenticationToken token = this.CreateAuthenticationToken();
        this._crmService = this.CreateCrmService(token);
    }

    public override void Dispose() {
        if (this._impersonator != null) {
            this._impersonator.Dispose();
            this._impersonator = null;
        }

        base.Dispose();
    }

...

Instead of using the "using" statement, I'm creating the CrmImpersonator object and store it in the member variable "_impersonator". This only happens when running in the context of CRM, but not while developing and running the application in Visual Studio. To dispose the impersonator object, I overrode the Dispose method of the web page and call the impersonator's Dispose method in there. From now on we don't have to care about impersonation and the CrmService object anymore. It's just available everywhere in the code.

Back to the click event of the Read button

Having finished changing our supporting code, we can now focus on the read event. You may expect me to use an ExportXmlRequest to retrieve the customization XML, but CRM 4.0 has an isvconfig entity that can be used to access the configuration XML much easier than before:

protected void btnReadIsvConfig_Click(object sender, EventArgs e) {

    QueryExpression query = new QueryExpression(EntityName.isvconfig.ToString());
    query.ColumnSet = new ColumnSet(new string[] { "configxml" });
    query.EntityName = EntityName.isvconfig.ToString();

    query.PageInfo = new PagingInfo();
    query.PageInfo.Count = 1;
    query.PageInfo.PageNumber = 1;

    BusinessEntityCollection items = this._crmService.RetrieveMultiple(query);

    if (items.BusinessEntities.Count == 0) {
        this.txtXml.Text = "The isvconfig entity could not be retrieved!";
    }

    else {
        isvconfig config = (isvconfig) items.BusinessEntities[0];
        this.txtXml.Text = config.configxml;
    }
}

That's easy, right? The configuration XML is stored in the configxml property of the isvconfig entity and that's all we need to populate our text box. To see how it looks like, let's run the application one more time and click the "Read/Refresh" button:

So that's indeed the content of the ISV configuration. Doesn't look very good though, because there's no indentation and it would be very difficult to edit. So lets add another helper method:

static string FormatXml(string xml) {

    StringBuilder sb = new StringBuilder();
    XmlWriterSettings settings = new XmlWriterSettings();
    settings.Indent = true;
    settings.IndentChars = " ";
    settings.OmitXmlDeclaration = true;

    using (XmlWriter w = XmlTextWriter.Create(sb, settings)) {
        using (TextReader tr = new StringReader(xml)) {
            using (XmlReader r = XmlTextReader.Create(tr)) {
                w.WriteNode(r, true);
            }
        }
    }

    return sb.ToString();
}

FormatXml takes any valid XML content and formats it like IE or Visual Studio does - without syntax coloring though. We have to change our code to make use of it, but it's only a marginal modification:

    if (items.BusinessEntities.Count == 0) {
        this.txtXml.Text = "The isvconfig entity could not be retrieved!";
    }

    else {
        isvconfig config = (isvconfig) items.BusinessEntities[0];
        this.txtXml.Text = FormatXml(config.configxml);
    }

Let's run the application again and click the "Read/Refresh" button:

That looks good. You can click into the text box, hit CTRL-A to select the entire text and CTRL-C to copy it to the clipboard and paste it into a Visual Studio XML editor or any other editor you prefer. Even that will be faster than using the export from CRM, extracting the customization.xml and opening it. However, we want to make changes directly in this application and save it back to CRM.

Implementing the Save button

We have already done most of our job and with the CrmService object in place and the impersonation working, it's just adding the click event for the Save button. Double-click the Save button and add the following code:

protected void btnSaveIsvConfig_Click(object sender, EventArgs e) {

    QueryExpression query = new QueryExpression(EntityName.isvconfig.ToString());
    query.ColumnSet = new ColumnSet(new string[] { "configxml" });
    query.EntityName = EntityName.isvconfig.ToString();

    query.PageInfo = new PagingInfo();
    query.PageInfo.Count = 1;
    query.PageInfo.PageNumber = 1;

    BusinessEntityCollection items = this._crmService.RetrieveMultiple(query);

    if (items.BusinessEntities.Count == 0) {
        this.txtXml.Text = "The isvconfig entity could not be retrieved!";
    }

    else {
        isvconfig config = (isvconfig) items.BusinessEntities[0];
        config.configxml = this.txtXml.Text;
        this._crmService.Update(config);
    }
}

Yes, it's almost the same as for the Read button and we will change the implementation anyway. We need a better way to display error messages to the user and there is one thing that I came across while writing this article and the code: you can specify whatever you like in the configxml property of the isvconfig entity and the Update call will always succeed. You could write config.configxml = "Nonsense" and it would store "Nonsense" in the ISV configuration. That, of course, doesn't make sense and we have to add appropriate checks before saving the XML.

Adding the message field

I'm using a simple label control to show messages to the user. The steps are the same as before: open the toolbox, select the Label control and drag it onto the form:

As we are only using the field in case of an error, I'm setting the foreground color to red. I'm also changing the id to "lblMessage" and the text to "Message":

There is no need to set a default text, but it's easier to understand when looking at the form designer. "Error Message" instead of "Message" would also work, but I think you got the point. To set the message text, I add a very simply method:

private void SetMessage(string message) {
    this.lblMessage.Text = message;
}

I like such wrapper methods, because it allows us to change the implementation easily. If we want to display the message in a text box rather than a label, we only have to change a single line of code, instead of searching the entire implementation for occurrences of "lblMessage".

As I have set the default text of the label control to "Message", I'm clearing it in the Page_Load event first:

protected void Page_Load(object sender, EventArgs e) {

    this.SetMessage(null);
    this._isDevEnvironment = this.IsDevEnvironment();
    ...

And I'm using it when reading the ISV configuration as well:

protected void btnReadIsvConfig_Click(object sender, EventArgs e) {

    QueryExpression query = new QueryExpression(EntityName.isvconfig.ToString());
    query.ColumnSet = new ColumnSet(new string[] { "configxml" });
    query.EntityName = EntityName.isvconfig.ToString();

    query.PageInfo = new PagingInfo();
    query.PageInfo.Count = 1;
    query.PageInfo.PageNumber = 1;

    BusinessEntityCollection items = this._crmService.RetrieveMultiple(query);

    if (items.BusinessEntities.Count == 0) {
        this.SetMessage("The isvconfig entity could not be retrieved!");
    }

    else {
        isvconfig config = (isvconfig) items.BusinessEntities[0];
        this.txtXml.Text = config.configxml;
    }
}

Having a (really basic) error handling in place, we can now continue to add more logic.

Validating the XML before sending

To not corrupt our configuration with an invalid customization file, I'm going to add an XML validation process. The schema of the ISV configuration is available in the SDK (server\schemas\importexport\isv.config.xsd). And I have created a simple class doing the validation for us:

using System;
using System.Xml.Schema;
using System.Xml;
using System.Text;

namespace Stunnware.Crm4.IsvConfigEditor {

    public class XmlValidator {

        XmlSchema _schema;
        StringBuilder _messages;

        public XmlValidator(string schemaUri) {
            using (XmlReader reader = new XmlTextReader(schemaUri)) {
                this._schema = XmlSchema.Read(reader, null);
            }
        }

        public string Validate(string xml) {
            XmlDocument doc = new XmlDocument();
            doc.LoadXml(xml);
            return this.Validate(doc);
        }

        public string Validate(XmlDocument doc) {
            this._messages = new StringBuilder();
            doc.Schemas.Add(this._schema);
            doc.Validate(new ValidationEventHandler(this.ValidationCallBack));

            return this._messages.ToString();
        }

        private void ValidationCallBack(object sender, ValidationEventArgs e) {
            this._messages.AppendLine(e.Message);
        }
    }
}

The XmlValidator takes the location of the schema file in the constructor and validates the schema itself. If it contains errors, an XmlSchemaException is thrown. Otherwise the class can be used to validate an XML document or XML string against that schema definition and the result of a call to the Validate method is an error we can display or it's an empty string in which case validation succeeded.

I used the following validation method in the default.aspx code:

private bool ValidateIsvConfigXml(string xml) {

    try {
        string pagePath = this.Server.MapPath(this.Request.Url.AbsolutePath);
        FileInfo pageFile = new FileInfo(pagePath);
        string schemaPath = Path.Combine(pageFile.Directory.FullName, "isv.config.xsd");

        XmlValidator validator = new XmlValidator(schemaPath);
        string validationMessages = validator.Validate(xml);

        if (!string.IsNullOrEmpty(validationMessages)) {
            this.SetMessage(validationMessages);
            return false;
        }
    }

    catch (Exception x) {
        this.SetMessage(x.Message);
        return false;
    }

    return true;
}

Server.MapPath is used to map the request URL to a physical location on the disk and the two following lines build the full path to the schema file. The setup used in the code requires the isv.config.xsd file to be placed in the same directory as the default.aspx page, so I have copied it from the SDK to my solution.

You can also include the schema in a resource, but if the schema changes for whatever reason, we can simply replace the isv.config.xsd with an updated version and our application will continue to work without recompiling. This is not a big issue when used only in your own company, but when thinking about an application you want to sell to your customers, then every new version you have to distribute causes a lot of time and headaches.

The remainder of the code validates the XML stored in our text box and, if it fails, displays an appropriate message in our message label control. Finally, ValidateIsvConfigXml returns true if the validation succeeded.

And here's the modified save method:

protected void btnSaveIsvConfig_Click(object sender, EventArgs e) {

    if (!this.ValidateIsvConfigXml(this.txtXml.Text)) {
       
return;
    }

    QueryExpression query = new QueryExpression(EntityName.isvconfig.ToString());
    query.ColumnSet = new ColumnSet(new string[] { "configxml" });
    query.EntityName = EntityName.isvconfig.ToString();

    query.PageInfo = new PagingInfo();
    query.PageInfo.Count = 1;
    query.PageInfo.PageNumber = 1;

    BusinessEntityCollection items = this._crmService.RetrieveMultiple(query);

    if (items.BusinessEntities.Count == 0) {
        this.txtXml.Text = "The isvconfig entity could not be retrieved!";
    }

    else {
        isvconfig config = (isvconfig) items.BusinessEntities[0];
        config.configxml = this.txtXml.Text;
        this._crmService.Update(config);
    }
}

If there is an error in the validation process, we simply cancel the save operation. Everything else is done in ValidateIsvConfigXml. Let's try what we have done so far, run the application, load the configuration XML, add some nonsense to it and try to save:

I'm sorry for the German message - didn't know that I had the German language pack installed at all. Anyway, the message says that the configuration element contains an invalid child element named "Nonsense" and it also tells that the only valid child element is "Root". This is a very robust implementation and should prevent us from uploading anything wrong. To try it out, I'm now adding some valid XML and save it:

Hitting the Save button afterwards succeeded and I'm really interested to see if I got a new button on the contact form.

It's there. Actually, it wasn't in the beginning and I searched for about 30 minutes, after I realized that Outlook was running and I had specified the Client="Web" setting. I really hate that this still leads to conflicts and I hate myself for walking into this trap over and over again.

Validating the XML after reading

Now that validation seems working for the save operation, we can also use it to verify the integrity of the existing XML after reading it. As said, it's totally possible to store invalid data into the ISV configuration and we should at least display a warning if the retrieved data does not follow the current schema:

protected void btnReadIsvConfig_Click(object sender, EventArgs e) {

    try {
       QueryExpression query = new QueryExpression(EntityName.isvconfig.ToString());
       query.ColumnSet = new ColumnSet(new string[] { "configxml" });
       query.EntityName = EntityName.isvconfig.ToString();

       query.PageInfo = new PagingInfo();
       query.PageInfo.Count = 1;
       query.PageInfo.PageNumber = 1;

       BusinessEntityCollection items = this._crmService.RetrieveMultiple(query);

       if (items.BusinessEntities.Count == 0) {
            this.SetMessage("The isvconfig entity could not be retrieved!");
       }

       else {
            isvconfig config = (isvconfig) items.BusinessEntities[0];
           this.txtXml.Text = config.configxml;
            this.ValidateIsvConfigXml(config.configxml);
       }
    }

    catch (Exception x) {
        this.SetMessage(x.Message);
    }
}

Whether validation succeeds or not, I'm still displaying the loaded XML in the text box, so I'm not interested in the return value of the ValidateIsvConfigXml in the read method. All it does is displaying a warning in our message label, but the user is able to correct the error and save the updated version.

I also added a generic event handler, which usually isn't a good practice. But it's a simple application and the message is shown in our message label. Feel free to enhance the exception handling though.

Finally: Deploying our solution to the CRM server

So far, I only tested on my development machine. Now it's time for deployment. The default.aspx and the isv.config schema file go to /isv/stunnware.com/isveditor:

Note that I haven't copied the web.config intentionally. We run in the context of CRM and will use the same web.config as CRM does. The compiled assembly is copied into the /bin folder:

As a quick test, I'm simply calling the ISV editor directly by typing in the address in Internet Explorer:

Well, the read process succeeded, meaning that our CrmImpersonator implementation worked. However, there is an error displayed and it says that our schema file cannot be found. This is correct, because the "sw" directory doesn't exist. However, our page will be accessed like this when called from CRM through an ISV.Config or Sitemap extension, so we have to change our code once more. This highlights a very important thing: there is a difference between your development system and the CRM Server. And you won't be able to reproduce some behaviors while debugging.

To resolve this issue I have changed the following lines to the ValidateIsvConfigXml method:

private bool ValidateIsvConfigXml(string xml) {

    try {
        string absolutePath = this.Request.Url.AbsolutePath;
       
string virtualDir = "/" + this._orgName;

       
if (absolutePath.StartsWith(virtualDir + "/", StringComparison.OrdinalIgnoreCase)) {
            absolutePath = absolutePath.Substring(virtualDir.Length);
        }

       
string pagePath = this.Server.MapPath(absolutePath);
        FileInfo pageFile = new FileInfo(pagePath);
        string schemaPath = Path.Combine(pageFile.Directory.FullName, "isv.config.xsd");

        XmlValidator validator = new XmlValidator(schemaPath);
        string validationMessages = validator.Validate(xml);

        if (!string.IsNullOrEmpty(validationMessages)) {
            this.SetMessage(validationMessages);
            return false;
        }
    }

    catch (Exception x) {
        this.SetMessage(x.Message);
        return false;
    }

    return true;
}

It's not totally correct, because if an organization is named "isv", it may conflict with the "isv" subdirectory if accessed without "isv" being used as a virtual directory name. Anyway, I'm not going to correct it here, because at some point I want to finish this article. And it's not done yet, because we don't want to enter the URL in the IE address bar. Instead we want to see the ISV Config Editor in the CRM settings area.

Adding the ISV Config Editor to the settings area

If we had developed a sitemap editor, we could use our own tool to include it. But it's an ISV.Config editor and therefore we have to use the standard mechanisms to include it. So export your CRM sitemap and add the following entry:

<Area Id="Settings" ResourceId="Area_Settings" Icon="/_imgs/settings_24x24.gif" DescriptionResourceId="Settings_Area_Description">
    <
Group Id="Settings">
        <
SubArea Id="nav_administration" ... />
       
<SubArea Id="nav_systemjobs" ...  />
        <
SubArea Id="nav_swisveditor" PassParams="1" Url="/isv/stunnware.com/isveditor/default.aspx">
            <
Titles>
                <
Title LCID="1033" Title="ISV Config Editor" />
            </
Titles>
        </
SubArea>
    </
Group>
</
Area>

Save the changes and import the modified site map into your CRM system. Press F5 in Internet Explorer, change back to the Settings area and finally we have done our job:

Wow! That turned out to be a long article and I've spent some hours on it. Hope you find it useful though. The complete project is attached to this article (Download Button in the top left corner). I have included the release version of the assembly, just in case you want to try it without compiling.

One last note: Usually I'm including a bunch of comments in my code, but as everything is explained in this article I haven't done this time.

No comments: