by Phil Adams
3. December 2010 13:28
Well for the past week i have been battling with an IFD Server that is not on the main CRM server, so i logged a call with Microsoft and managed to get to resolve the issue, but i was still having problems accessing the AD Side of the IFD.....
I Checked all my IP Adresses by running IPCONFIG /allThis showed me that i had 6to4 Tunnel running, but i had unticked the IPv6 entries from the Adapters, so why was this still here?so i then pinged the server from itself e.g ping serverurlthis was trying to ping an IPv6 Address, so i edited the registry as below:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\tcpip6\ParametersCreate a DWORD 32 bit Value and rename it to "DisabledComponents"Double click it and type 255 in the value box, it will disable IPv6.Restart!
Then when the server came back up i had no 6to4 tunenel and the ping was resolving to the IPv4 Address, then tried logging in from the server and hey presto!!!The main reason for getting the machine to be able to login to CRM from itself via AD is that when running server based code e.g. Plugins, ISV customisations were failing with Error 4.01 as the server was trying to connect via the IPv6 Address which was disabled.
Hope this helps someone else !!
Phil
by Phil Adams
4. May 2010 13:09
Last week i have had an issue where during the install of CRM 4, this is a fresh install the setup as follows:
SQL Box - Windows 2008 R2 + SQL2008 SP1CRM Box - Windows 2008 R2
now both boxes have been freshly prepped and joined to the domain, all with out errors.for added installation proof both servers had their windows firewalls turned off.
upon the Installation of CRM i encountered this error:14:41:37| Error| System.Exception: Action Microsoft.Crm.Setup.Server.GrantConfigDBDatabaseAccessAction failed. ---> System.Data.SqlClient.SqlException: Windows NT user or group 'Domain\SQLAccessGroup {9e798758-54f6-44a6-93a6-51b6faf49928}' not found. Check the name again.
at Microsoft.Crm.Setup.Database.SharedDatabaseUtility.GrantDBAccess(String sqlServerName, String databaseName, String groupName, CrmDBConnectionType connectionType)
at Microsoft.Crm.Setup.Server.GrantConfigDBDatabaseAccessAction.Do(IDictionary parameters)
at Microsoft.Crm.Setup.Common.Action.ExecuteAction(Action action, IDictionary parameters, Boolean undo)
--- End of inner exception stack trace ---, Error, RetryCancel, Option1
Now its quite obvious at this stage that it is failing to Gannt Access to an account to the MSCRM_CONFIG Database on the SQL server.
so i tried to add the SQLAccessGroup manually in the SQL Management Tools and the Error 15401: Windows NT user or group 'SCDC2003\SQLAccessGroup {9e798758-54f6-44a6-93a6-51b6faf49928}' not found. Check the name again.
now this is strange as i could not add the account manually, So i thought is this just Group Related and tried add a domain user and the same problem reared it's ugly head.
so a call to the Microsoft Support Team and some tests later, after running:
name2sid.zip (13.04 kb)
via the command line:name2sid.exe domain\anyuser
the real error appeared:LookupAccountName failed with error: The trust relationship between this workstation and the primary domain failed. (0x6fd/1789)Now this helps the Microsoft Guys a lot so they asked me run:netdom resetpwd /server:<DomainControllerName> /userd:<domain\domainadminuser> /passwordd:<password>This Command Replied with:The machine account password for the local machine has been successfully reset.
The command completed successfully.now the next thing to do is to put this new command into action by restarting the netlogon service:net stop netlogonnet start netlogonand clear the kerberos Tickets on the sql server:klist purge
then i tried adding the group manually again and hey presto and shazzam, it was working.
i hope this helps others having similar problems.
Phil
by Phil Adams
30. April 2010 00:21
The concept of debugging plugins and workflows has been blogged about in the past. There is a lot of great information out there. One thing I have noticed about debugging is that it involves a lot of steps. These steps take time. You end up waiting forever from the time you make a code change to the time that you are debugging again. I wanted to share a couple things I have learned about debugging that have saved me a great deal of time. Time Wasters: Starting and stopping IIS and the async service Deploying your new assembly Attaching to processes Getting back to debugging after making a small code change Let’s get started… … Project Setup I like to start with a vanilla system on a virtual PC (VPC) that has CRM installed. I import the customizations into my environment and set up some quick test data. Note: I stay away from remote debugging when possible as it requires specific security privileges on the CRM server and you will affect users that are trying to access the system. From my VPC I open the project that includes my workflow or plugin. Be sure to set the build path to the bin/assembly folder of your CRM instance. On my machine it is C:\Program Files\Microsoft Dynamics CRM\server\bin\assembly\. It varies from installation to installation. Building to this location allows us to register the plugin to disk and make code changes quickly without having to move files around or re-register anything. Register Plugin Open the registration tool and register your plugin or workflow. Be sure to register the plugin to disk. Note: When you move to production I recommend registering to the database, but registering to disk works great for debugging. Attaching to Processes Now that you have the plugin registered, you are ready to start debugging. You need to attach to W3WP.exe to attach to plugins since they run within IIS. To attach to workflows you need to attach to the CRM async service which is Crmasyncservice.exe. Select w3wp.exe and Crmasyncservice.exe and click “Attach.” To expedite the process I used a trick from Janne Mattila. You leverage VS macros to attach to the processes by using a shortcut key. I have Ctrl+Shift+V tied to a script that attaches to both w3wp and crmasyncservice. See my script below. Courtesy of Janne Mattila: http://blogs.msdn.com/jannemattila/archive/2008/10/30/attaching-debugger-to-w3wp-exe-using-nice-and-easy-keyboard-shortcut.aspx Imports System Imports EnvDTE Imports EnvDTE80 Imports EnvDTE90 Imports System.Diagnostics Public Module AttachHelper ' This subroutine attaches to w3wp.exe: Sub Attach() Dim attached As Boolean = False Dim proc As EnvDTE.Process For Each proc In DTE.Debugger.LocalProcesses If (Right(proc.Name, 8) = "w3wp.exe") Then proc.Attach() attached = True End If Next If attached = False Then MsgBox("Couldn't find w3wp.exe") End If attached = False For Each proc In DTE.Debugger.LocalProcesses If (Right(proc.Name, 19) = "CrmAsyncService.exe") Then proc.Attach() attached = True End If Next If attached = False Then MsgBox("Couldn't find crmasyncservice.exe") End If End Sub End Module So we are now able to quickly attach to processes. We can debug. Awesome!!! But wait! Next, you discover you need to make a code change. We don’t have edit-and-continue available in plugins yet. So, you stop the your session, make your code change and you get the following error when you try to build. This is because you are trying to build to a file location that is already locked by the async service and/or IIS. To get around this I have a bat file on my desktop and I run it every time I want to rebuild. It includes the following: iisreset net stop MSCRMAsyncService net start MSCRMAsyncService "C:\Program Files\Internet Explorer\iexplore.exe" http://andrewvpc:5555 This script restarts IIS to unlock w3wp.exe. Second, it restarts the async service. Lastly, I reopen the CRM website in IE, so IIS will re-spawn itself (replace andrewvpc with your servername). Otherwise, when you try to attach to processes it won’t be able to find w3wp.exe. Now that you have ran the script, try to rebuild. You will be rid of your error message and it will build successfully. Now re-run your macro using Ctrl+Shift+V. You are back to debugging again. Enjoy!!! Source: Andrew Zimmer
by Phil Adams
22. March 2010 14:50
In BIDS, do the following:
Click on Report > Report Properties > Layout tab
Make a note of the values for Page width, Left margin, Right margin
Close and go back to the design surface
In the Properties window, select Body
Click the + symbol to expand the Size node
Make a note of the value for Width
To render in PDF correctly Body Width + Left margin + Right margin must be less than or equal to Page width. When you see blank pages being rendered it is almost always because the body width plus margins is greater than the page width.
Remember: (Body Width + Left margin + Right margin) <= (Page width)
by Phil Adams
12. February 2010 11:01
Integrating Microsoft Dynamics CRM via App Fabric I am excited about the Microsoft’s Azure initiative. App Fabric is a part of Window Azure platform and as indicated at http://www.microsoft.com/windowsazure, makes it simpler to connect cloud and on-premise applications. It also simplifies the on-premise to on-premise application connection separated by firewalls and enables management of such interaction via the configurable rules of Access Control Service (ACS). Consider a simple application which syncs accounts created in CRM system with another system. This application works in a classical polling model, where it polls CRM every so often for any newly added accounts. The polling interval may differ based on the other system requirements on how current the data needs to be represented. The higher the urgency, more the load it is going to put on CRM by repeatedly running the search query for new accounts. It would be nice if the syncing module had some mechanism of being notified if a new account has been created, classical push model, which will allow it to only query CRM if needed and better if it can be notified with all the account details it needs to sync between the two systems. Lets try to see how we can alleviate the need of continuous polling with still maintaining the same data consistency between the two systems. Pre-requisites We have a functional CRM 4.0 system with App Fabric SDK installed, an account with the Windows Azure platform App Fabric and a program that polls CRM via the SDK to sync data every, say 15 minutes. Our goal is to enhance the polling code to run search query against CRM only when new accounts are created and cut down on blind polling behavior. To achieve our goals, we will write a plug-in in CRM that would post to the App Fabric any time a new account is created, The endpoint to which the data is posted by CRM is maintained by the application and it will track when was the last account in CRM created on. Plug-in code Code Snippet using System; using System.Globalization; using System.IO; using System.Text; using System.Xml; using System.ServiceModel; using Microsoft.Crm.Sdk; using Microsoft.ServiceBus; namespace AppFabricPlugin { public class Plugin : IPlugin { // Variables private string SolutionName; private string ServicePath; private string MgmtKey; #region Constructor public Plugin(string config) { if (String.IsNullOrEmpty(config)) { throw new InvalidPluginExecutionException("config can not null or empty."); } // Parse config. string[] parts = config.Split(new char[] { ';' }); if (parts.Length == 3) { SolutionName = parts[0]; ServicePath = parts[1]; MgmtKey = parts[2]; } else { throw new InvalidPluginExecutionException("Invalid config."); } } #endregion #region IPlugin Members public void Execute(IPluginExecutionContext context) { // Set mode to http (https). ServiceBusEnvironment.SystemConnectivity.Mode = ConnectivityMode.Http; // Address. Uri serviceUri = ServiceBusEnvironment.CreateServiceUri(Uri.UriSchemeHttps, SolutionName, ServicePath); // Binding WS2007HttpRelayBinding binding = new WS2007HttpRelayBinding(); binding.Security.Mode = EndToEndSecurityMode.Transport; // Create the channel factory. using (ChannelFactory<IServiceBusChannel> channelFactory = new ChannelFactory<IServiceBusChannel>(binding, new EndpointAddress(serviceUri))) { // Apply the auth behavior channelFactory.Endpoint.Behaviors.Add(RetrieveAuthBehavior()); // Create and open the client channel using (IServiceBusChannel channel = channelFactory.CreateChannel()) { channel.Open(); // Use context correlationupdatetime to track the operation time. channel.Execute(context.PrimaryEntityName + ";" + context.CorrelationUpdatedTime.Value); } } } #endregion #region Private members private TransportClientEndpointBehavior RetrieveAuthBehavior() { // Behavior TransportClientEndpointBehavior behavior = new TransportClientEndpointBehavior(); behavior.CredentialType = TransportClientCredentialType.SharedSecret; behavior.Credentials.SharedSecret.IssuerName = "owner"; behavior.Credentials.SharedSecret.IssuerSecret = MgmtKey; return behavior; } #endregion #region Contracts [ServiceContract(Namespace = "http://schemas.microsoft.com/crm/2007/Contracts")] public interface IRemotePluginContract { [OperationContract(IsOneWay = true)] void Execute(string data); } public interface IServiceBusChannel : IRemotePluginContract, IClientChannel { } #endregion } } Service listener code Code Snippet public void Main(string[] args) { // Set mode to http (https). ServiceBusEnvironment.SystemConnectivity.Mode = ConnectivityMode.Http; // Address. Uri serviceUri = ServiceBusEnvironment.CreateServiceUri(Uri.UriSchemeHttps, SolutionName, ServicePath); // Binding WS2007HttpRelayBinding binding = new WS2007HttpRelayBinding(); binding.Security.Mode = EndToEndSecurityMode.Transport; using (ServiceHost host = new ServiceHost(typeof(RemoteService))) { host.AddServiceEndpoint(typeof(IRemotePluginContract), binding, serviceUri); host.Description.Endpoints[0].Behaviors.Add(RetrieveAuthBehavior()); host.Open(); Console.WriteLine("Press [Enter] to exit"); Console.ReadLine(); } } [ServiceBehavior] public class RemoteService : IRemotePluginContract { #region IRemotePluginContract Members public void Execute(string data) { // Do something. } #endregion } Plug-in registration Compile and register the plug-in on account create. Register it to run asynchronously so that the post can happen independent of the actual create. In the config part add a semicolon separated string containing the solutionname;solutionpath;sharedkey Configuring the ACS We don’t need any rules configured in the ACS as we are using shared secret key to authenticate. All you would need to have is a solution name space on the App Fabric and use the management key. I have provided some links for you to get started. With the above system in-place, the syncing module only needs to query CRM if the last account created on time is greater than then last time it synced. The model can be further improved and tweaked based to the requirements, but this simple demo enables us to see the integration empowerment that Windows Azure platform App Fabric enables. The best thing I like is it enables management of data syncing between the two systems. References If you are new to Windows Azure: http://www.microsoft.com/windowsazure/ If need more info on App Fabric: http://www.microsoft.com/windowsazure/appfabric If you to download App Fabric SDK: http://www.microsoft.com/downloads/details.aspx?FamilyID=39856a03-1490-4283-908f-c8bf0bfad8a5&displaylang=en If you need more info on CRM plug-in registration tool: http://blogs.msdn.com/crm/archive/2008/07/01/plug-in-registration-tool-2-1.aspx http://code.msdn.microsoft.com/crmplugin http://code.msdn.microsoft.com/crm40pluginbulk Cheers, Shashi Ranjan Microsoft Dynamics CRM Team Blog : Integrating Microsoft Dynamics CRM via App Fabric
by Phil Adams
31. March 2009 22:53
As you all know a CRM lookup displays its related entity primary field. Although this can not be changed using existing customizations; in most cases that suffices. However, there are occasions where you want to display more information in order to avoid opening the related entity form. One solution which I posted about was the lookup preview which builds a preview window for each lookup DataValue. I personally think it’s a great solution and we also have a wizard that facilitates the creation of the preview for us. This post offers a different solution which utilizes a plug-in that retrieves the extra information you wish to display and injects it inside the lookup text. The drawback of this solution is that the lookup can only occupy a certain amount of space. So you should consider expanding the lookup colspan before you use it.
The solution makes use of the post retrieve message on the incident entity. My goal in this demo is to show how to extend the customer lookup on the incident form so if you select an account the customer lookup will display the account name , number and primary contact and if you select a contact then the customer lookup displays the salutation , job title and company fields.
The solution is static but might give you a head start when other requirements of similar nature are in need.
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.SdkTypeProxy;
using Microsoft.Crm.Sdk.Query;
namespace LookupTextPlugIn
{
public class LookupRetrieveHandler : IPlugin
{
#region IPlugin Members
public void Execute(IPluginExecutionContext context)
{
try
{
LookupRetrieveProxy plugin = new LookupRetrieveProxy(context);
plugin.Execute();
}
catch
{
}
}
#endregion
}
public class LookupRetrieveProxy
{
private IPluginExecutionContext Context;
private Customer Customer;
private DynamicEntity CustomerInfo;
public LookupRetrieveProxy(IPluginExecutionContext context)
{
this.Context = context;
}
public void Execute()
{
if (!Context.OutputParameters.Contains(ParameterName.BusinessEntity))
{
return;
}
DynamicEntity entity = (DynamicEntity)Context.OutputParameters[ParameterName.BusinessEntity];
if (!entity.Properties.Contains("customerid"))
{
return;
}
this.Customer = (Customer)entity.Properties["customerid"];
if ((this.CustomerInfo = RetrieveCustomer()) != null)
{
this.Customer.name = ChangeCustomerName();
}
}
private DynamicEntity RetrieveCustomer()
{
ColumnSet customerColumns = new ColumnSet();
switch (this.Customer.type)
{
case "account":
customerColumns.AddColumn("accountnumber");
customerColumns.AddColumn("primarycontactid");
customerColumns.AddColumn("telephone1");
break;
case "contact":
customerColumns.AddColumn("salutation");
customerColumns.AddColumn("jobtitle");
customerColumns.AddColumn("parentcustomerid");
break;
}
ICrmService Service = this.Context.CreateCrmService(true);
TargetRetrieveDynamic targetRetrieve = new TargetRetrieveDynamic();
targetRetrieve.EntityId = this.Customer.Value;
targetRetrieve.EntityName = this.Customer.type;
RetrieveRequest retrieveRequest = new RetrieveRequest();
retrieveRequest.ColumnSet = customerColumns;
retrieveRequest.ReturnDynamicEntities = true;
retrieveRequest.Target = targetRetrieve;
RetrieveResponse retrieveResponse = (RetrieveResponse)Service.Execute(retrieveRequest);
return retrieveResponse.BusinessEntity as DynamicEntity;
}
private string ChangeCustomerName()
{
StringBuilder lookupText = new StringBuilder();
lookupText.Append(this.Customer.name).Append(" ");
switch (this.CustomerInfo.Name)
{
case "account":
lookupText.Append(this.GetProperty("accountnumber")).Append(", ");
lookupText.Append(this.GetProperty("primarycontactid")).Append(", ");
lookupText.Append(this.GetProperty("telephone1"));
break;
case "contact":
lookupText.Append(this.GetProperty("salutation")).Append(", ");
lookupText.Append(this.GetProperty("jobtitle")).Append(", ");
lookupText.Append(this.GetProperty("primarycustomerid"));
break;
}
return lookupText.ToString();
}
private String GetProperty(string propName)
{
if (!this.CustomerInfo.Properties.Contains(propName))
{
return "";
}
Object property = this.CustomerInfo.Properties[propName];
if (property is String)
{
return property.ToString();
}
else if (property is Customer)
{
return ((Customer)property).name;
}
else if (property is Lookup)
{
return ((Lookup)property).name;
}
//not supported
return String.Empty;
}
}
}
by Phil Adams
26. March 2009 13:27
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.
by Phil Adams
12. March 2009 14:47
void Page_Error (object sender, EventArgs e) {
string errorMessage = "Error Occurred:" + Server.GetLastError(); Server.ClearError(); if (!(EventLog.SourceExists("myWebApp","."))) EventLog.CreateEventSource("myWebApp","System",".");
EventLogPermission eventLogPerm = new EventLogPermission(EventLogPermissionAccess.Administer,"."); eventLogPerm.PermitOnly(); EventLog evLog = new EventLog(); evLog.Source = "myWebApp"; evLog.WriteEntry(errorMessage, EventLogEntryType.Error); evLog.Close(); }