Getting Started with Hsl.Xrm.Sdk Plugin Development
Check out the API reference and the samples below.
Plugin Basics
Plugins receive a "context" parameter which contains a variety of things but the most important are:
context.Targets
- When registered to CreateMultiple or UpdateMultiple, this contains a list of records that are being created or updated. Each item in the list is aTargetItem
object which contains the Target, PreImage, and PostImage information.context.XrmContext
- Provides access to the IOrganizationService instance.context.PluginContext
- Provides access to the IPluginExecutionContext instance.
using Hsl.Xrm.Sdk;
using Hsl.Xrm.Sdk.Plugin;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Sample.Plugins
{
public class ExampleBulkPlugin : PluginBase2, IPlugin
{
public ExampleBulkPlugin()
{
}
public ExampleBulkPlugin(string unsecureConfig, string secureConfig) : base(unsecureConfig, secureConfig)
{
}
protected override void LoadConfiguration(string unsecureConfig, string secureConfig)
{
base.LoadConfiguration(unsecureConfig, secureConfig);
}
public override void ExecuteAction(IPluginEventContext2 pluginEventContext)
{
var systemService = pluginEventContext.XrmContext.SystemOrgService;
var targets = pluginEventContext.Targets;
foreach(var target in targets)
{
if (!target.Input.Contains("hsl_field"))
continue;
// More code here
}
var parentAccountIds = targets.Select(x => x.GetValue<Guid>("hsl_accountid")).Distinct();
var parentAccountsById = systemService.BulkRetrieveByIds("account", parentAccountIds, new ColumnSet("name", "customertypecode"))
.ToDictionary(x => x.Id);
foreach (var target in targets)
{
var parent = parentAccountsById[target.GetValue<Guid>("hsl_accountid")];
var parentType = parent.GetFieldValue<int>("customertypecode");
var priorOwner = target.PreImage.GetAttributeValue<Guid>("ownerid");
// Do something with the target
}
}
}
}
Configuration
Use JSON to configure the plugin.
using Hsl.Logging;
using Hsl.Xrm.Sdk;
using Hsl.Xrm.Sdk.Plugin;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace Sample.Plugins
{
public class ExampleConfiguredBulkPlugin : PluginBase2, IPlugin
{
#region Configuration/Fields
private int totalNumber;
private SecureConfigSettings apiConfig;
// Any configuration that is sensitive OR that differs between environments should be stored in the secure configuration
public class SecureConfigSettings
{
public string ApiKey { get; set; }
public string ApiUrl { get; set; }
}
// The unsecure configuration is moved with solutions so don't include information that differs between environments.
public class UnsecureConfigSettings
{
public int SomeNumber { get; set; }
public int SomeNumber2 { get; set; }
}
protected override void LoadConfiguration(string unsecureConfig, string secureConfig)
{
// Recommended approach is to use LoadConfiguration to deserialize the configuration.
// The platform gives a confusing error if the constructor throws an exception.
// LoadConfiguration will surface an error with a reasonable message to the user.
// Recommended approach is to use System.Text.Json.JsonSerializer to deserialize the configuration.
var unsecureConfigObj = JsonSerializer.Deserialize<UnsecureConfigSettings>(unsecureConfig, new JsonSerializerOptions
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
});
apiConfig = JsonSerializer.Deserialize<SecureConfigSettings>(secureConfig);
// You can have validation logic in LoadConfiguration.
if (unsecureConfigObj.SomeNumber <= 0)
throw new InvalidPluginExecutionException($"Invalid plugin configuration. SomeNumber must be positive. Please contact a system administrator");
// You can also do calculations or other actions based on the config.
totalNumber = unsecureConfigObj.SomeNumber + unsecureConfigObj.SomeNumber2;
}
#endregion
public override void ExecuteAction(IPluginEventContext2 pluginEventContext)
{
var logger = pluginEventContext.Logger;
var systemService = pluginEventContext.XrmContext.SystemOrgService;
var targets = pluginEventContext.Targets;
// GetValue<T> will get the expected value after the plugin operation - will check the images if available.
var parentAccountIds = targets.Select(x => x.GetValue<Guid>("hsl_accountid")).Distinct();
var parentAccountsById = systemService.BulkRetrieveByIds("account", parentAccountIds, new ColumnSet("name", "customertypecode"))
.ToDictionary(x => x.Id);
foreach (var target in targets)
{
var parent = parentAccountsById[target.GetValue<Guid>("hsl_accountid")];
var parentType = parent.GetFieldValue<CustomerTypeCode>("customertypecode");
if (parentType == CustomerTypeCode.ParentGroup && target.GetValue<int>("hsl_fielda") >= totalNumber)
{
// If multiple records are being created/updated, CreateException will include information about which record failed.
// If only a single record is being created/updated, it will not so that the user doesn't get confused by an error message
// like "Error on 7B4D499F-4FB0-45CC-A074-853E7EDE2263 index 0: Field A must be less than 8" a user editing a single record
// will just see "Field A must be less than 8".
throw target.CreateException($"Field A must be less than {totalNumber:N0}");
}
else
{
var number = target.GetValue<string>("hsl_someidentifier");
var request = new HttpRequestMessage(HttpMethod.Get, $"{apiConfig.ApiUrl}/{number}");
request.Headers.Add("ApiKey", apiConfig.ApiKey);
var response = client.SendAsync(request).Result;
if (response.IsSuccessStatusCode)
{
var responseString = response.Content.ReadAsStringAsync().Result;
logger.LogInfo($"Got result {responseString}");
}
else
{
throw target.CreateException($"Request to some intergrated service failed with status {response.StatusCode}");
}
}
}
}
private static readonly HttpClient client = new HttpClient();
public enum CustomerTypeCode
{
Prospect = 1,
Customer = 2,
Competitor = 3,
ParentGroup = 296010001,
}
#region Constructors
public ExampleConfiguredBulkPlugin()
{
}
public ExampleConfiguredBulkPlugin(string unsecureConfig, string secureConfig) : base(unsecureConfig, secureConfig)
{
}
#endregion
}
}
Trace Configuration
"TraceConfiguration" can be used to configure plugin trace settings. You can add it to the root object of your configuration.
{
// Comments are allowed in plugin configuration.
"ApiKey": "your-api-key",
"ApiUrl": "https://example.com",
// Trace config
"TraceConfiguration": {
"LogLevel": "Warning",
"Indent": " ",
"LogContext": false,
},
}
The following log levels are available:
- None (least messages)
- Critical
- Error
- Warning
- Info
- Debug
- Trace (most messages)
The trace configuration can appear in either the unsecure or secure config. If it appears in both, the secure config will be used. If not supplied at all, LogLevel defaults to Info.
Debugging
Images are not yet available when debugging CreateMultiple and UpdateMultiple plugins. To work around this:
- Add the following setting to your secure config and change your plugin
- This will allow IPluginEventContext2.Targets to be used with the those messages so your code written for CreateMultiple should work against the Create message unchanged.
{
"EnableNonBulkMessageCompatibility": true
}
- Change the plugin to be registered to the Create or Update message temporarily.
- Capture your debugging profile.
- Cleanup
- Change the message back to CreateMultiple or UpdateMultiple
- Remove
"EnableNonBulkMessageCompatibility": true
from the secure config.
Be sure to cleanup before deploying to downstream environments!
Plugin Fields
Be careful with fields in your plugin class. The instance is reused by multiple executions which might be executing in parallel.
using Hsl.Xrm.Sdk;
using Hsl.Xrm.Sdk.Plugin;
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Sample.Plugins
{
public class ExamplePluginFieldsBad : PluginBase2
{
public ExamplePluginFieldsBad()
{
}
public ExamplePluginFieldsBad(string unsecureConfig, string secureConfig) : base(unsecureConfig, secureConfig)
{
}
private bool shouldDoSomething;
private List<string> firstCharacterList = new List<string>();
public override void ExecuteAction(IPluginEventContext2 pluginEventContext)
{
var targets = pluginEventContext.Targets;
foreach (var item in targets)
{
// WRONG: don't use fields this way
// The plugin class is reused and different executions
// might interfere with each other.
shouldDoSomething = item.GetValue<string>("hsl_abc") == "def";
MethodCall(item, "hsl_string1");
MethodCall(item, "hsl_string2");
item["hsl_firstcharacters"] = String.Join(", ", firstCharacterList);
}
}
private void MethodCall(TargetItem target, string field)
{
var value = target.GetValue<string>(field);
// WRONG: don't use fields this way
// The plugin class is reused and different executions
// might interfere with each other.
if (value != null && (value.Length > 3 || shouldDoSomething))
{
firstCharacterList.Add(value.Substring(0, 1));
}
}
}
}
Using local functions is a great workaround. Alternatively, create a business logic class and use it in the plugin.
using Hsl.Xrm.Sdk;
using Hsl.Xrm.Sdk.Plugin;
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Sample.Plugins
{
public class ExamplePluginFieldsGood : PluginBase2
{
public ExamplePluginFieldsGood()
{
}
public ExamplePluginFieldsGood(string unsecureConfig, string secureConfig) : base(unsecureConfig, secureConfig)
{
}
public override void ExecuteAction(IPluginEventContext2 pluginEventContext)
{
// This is not necessarily the best way to achieve what this code is doing. It is just a contrived example
// to should how switching fields to local variables and local functions might work when
// comparing to ExamplePluginFieldsBad.
var targets = pluginEventContext.Targets;
foreach (var item in targets)
{
// The fields from our previous example become local variables.
var shouldDoSomething = item.GetValue<string>("hsl_abc") == "def";
var firstCharacterList = new List<string>();
MethodCall("hsl_string1");
MethodCall("hsl_string2");
item["hsl_firstcharacters"] = String.Join(", ", firstCharacterList);
// I like to put a return statement before any local functions to make it clear that this is the end of the main execution of the method.
// This isn't too important here since the local function is short. If you have several local functions or a long one, it can help.
return;
// our private method becomes a local function.
void MethodCall(string field)
{
var value = item.GetValue<string>(field);
if (value != null && (value.Length > 3 || shouldDoSomething))
{
firstCharacterList.Add(value.Substring(0, 1));
}
}
}
}
}
}