Wednesday, July 25, 2012

Execute Server-Side Code from Javascript

Background:

General business rules shall be implemented in server-side code to ensure its execution regardless of the origin of data changes; CRM UI, workflow, integration or any other application consuming the CRM web services.

But on some occasions it would be handy to be able to execute this code from JavaScript to improve the user's experience of the application.

Scenario:

When creating or updating an Account, there is a plugin registered Pre Validation Create/Update to verify that the given VAT number is in a correct format. If not, the plugin throws an exception instructing the user to enter a correct VAT number.

VATincorrect

Another plugin is registered Pre Operation Create/Update to look up city/state/county from given zip-code to ensure correct data for the account. This function consumes an external service from a third party accessible as a web service.

Account Cinteros zip

Challenge

To improve user experience, the customer requires immediate verification of the VAT no and lookup of geographic information for the zip-code.

Solution 1 (bad):

Required functionality can of course be implemented entirely in JavaScript. Rules for VAT numbers and calls to external web services can be coded in client side code. Calling external services may be a problem, depending on firewall configuration, local browser settings etc. but usually it is possible to find a way around these problems.

Composing and parsing SOAP-messages in javascript is neither intuitive nor fun, but of course it can be done.

This solution however would duplicate the same code in two entirely different environments and languages. Duplicated code is, and I think everyone agrees to this, NOT something we want. Right?!

Especially not from a maintenance perspective.

Solution 2 (good:)

Create a custom entity jr_serverfunction with one text field jr_parameter and one text field jr_result.

ServerFunction blank

Server-side

  • Extract logic from the two plugins mentioned to methods in an isolated C# class
  • Rewrite the plugins to consume this class (to preserve existing functionality invoked on create/update)
  • Create a plugin triggering Post Operation RetrieveMultiple on jr_serverfunction. This plugin shall investigate the incoming query to extract it's condition for field jr_parameter and use this condition to execute desired server-side code
  • When the result of the function is determined, an instance of entity jr_serverfunction is created (in code, not saved to the database), resulting data/information placed in field jr_result, and the entity placed in the EntityCollection that is to be returned in the query response

ServerFunction registration

Note that the custom entity will actually never hold any records in the CRM database. This is why I also trigger the Create message and immediately throw an error.

ServerFunction assembly

Plugin code:

public class ServerSideExecution : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        if (context.MessageName == "Create")
            throw new InvalidPluginExecutionException("Server Function entity cannot be instantiated");

        if (context.MessageName == "RetrieveMultiple" &&
            context.Stage == 40 &&      // Post Operation
            context.PrimaryEntityName == "jr_serverfunction" &&
            context.InputParameters.Contains("Query") &&
            context.InputParameters["Query"] is QueryExpression)
        {
            ITracingService tracer = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
            tracer.Trace("Initialize service etc");
            IOrganizationServiceFactory serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            IOrganizationService service = serviceFactory.CreateOrganizationService(context.UserId);
            QueryExpression query = (QueryExpression)context.InputParameters["Query"];
            tracer.Trace("Extract condition from query");
            ConditionExpression parameterCondition = MyFunctions.GetFilterConditionByAttribute(query.Criteria, "jr_parameter");
            if (parameterCondition != null && parameterCondition.Values.Count == 1)
            {
                string parameter = parameterCondition.Values[0].ToString().Trim('%');
                tracer.Trace("Parameter is: {0}", parameter);
                string command = parameter.Split(';')[0];
                string result = null;
                switch (command)
                {
                    case "VerifyVAT":
                        tracer.Trace("Check if VAT number is correct");
                        string vat = parameter.Split(';')[1];
                        if (MyFunctions.VerifyVAT(vat))
                            result = "ok";
                        else
                            result = "not ok";
                        break;
                    case "LookupZIP":
                        tracer.Trace("Lookup city etc from ZIP code");
                        string zip = parameter.Split(';')[1];
                        // Returns a semi-colon separated string with city;state;country
                        result = MyFunctions.GetZipInfo(zip);
                        break;
                    // **************************************
                    // ** Add more functions here as needed
                    // **************************************
                }
                if (result != null)
                {
                    tracer.Trace("Create resulting serverfunction entity with result: {0}", result);
                    Entity serverfunction = new Entity("jr_serverfunction");
                    Guid id = new Guid();
                    serverfunction.Id = id;
                    serverfunction.Attributes.Add("jr_serverfunctionid", id);
                    serverfunction.Attributes.Add("jr_parameter", parameter);
                    serverfunction.Attributes.Add("jr_result", result);
                    tracer.Trace("Replace contents of resulting entitycollection");
                    EntityCollection resultCollection = (EntityCollection)context.OutputParameters["BusinessEntityCollection"];
                    resultCollection.Entities.Clear();
                    resultCollection.Entities.Add(serverfunction);
                    context.OutputParameters["BusinessEntityCollection"] = resultCollection;
                }
            }
        }
    }
}

Note: The function MyFunctions.GetFilterConditionByAttribute is part of internally developed tools. Please contact me if you are interested in how we find specific conditions in a query.

The plugin can easily be tested by making an Advanced Find query on the Server Function entity in the CRM.

TestVAT TestZIP

Client-side

In the client-side JavaScript a method is registered for the onChange event of the VAT no field. The function will use the REST endpoint to query CRM for records of jr_serverfunction where jr_parameter equals "VerifyVAT;1234567890" (where the numbers should be the number entered on the form).

The result will contain one record, and the jr_result field will contain "ok" or "not ok", which the JavaScript can use to immediately instruct the user to correct the number.

Cinteros.Xrm.AccountServerFunction = {
    jr_vat_onChange: function () {
        try {
            var vatNo = Xrm.Page.getAttribute("jr_vat").getValue();
            if (vatNo != null) {
                var parameter = "VerifyVAT;" + vatNo;
                var result = this.ExecuteServerFunction(parameter);
                if (result === "not ok") {
                    window.alert("VAT number is not in a correct format");
                }
            }
        }
        catch (e) {
            alert("Error in jr_vat_onChange:\n\n" + e.description);
        }
    },

    ExecuteServerFunction: function (parameter) {
        var result = null;
        var serverFunctionResult = Cinteros.Xrm.REST.RetrieveMultiple("jr_serverfunction",
                "?$select=jr_result" +
                "&$filter=jr_parameter eq '" + parameter + "'");

        if (serverFunctionResult && serverFunctionResult.length === 1) {
            result = serverFunctionResult[0].jr_result;
        }
        return result;
    }
}

Note: The javascript-function Cinteros.Xrm.REST.RetrieveMultiple is part of our internally developed tools, it may well be replaced by similar functionality in the CrmRestKit (http://crmrestkit.codeplex.com/) or by other custom made code.

Registering this function as the onchange event of the VAT number field immediately executes the server-side functionality for validating a VAT number when the user changes the field in the CRM client.

VATeventhandler VATincorrectJS

Corresponding onChange event for the zip-code field can be implemented to get geographic information and automatically populate city/state etc. on the form.

address1_postalcode_onChange: function () {
    try {
        var zipCode = Xrm.Page.getAttribute("address1_postalcode").getValue();
        if (zipCode != null) {
            var parameter = "LookupZIP;" + zipCode;
            var result = this.ExecuteServerFunction(parameter);
            if (result) {
                var city = result.split(';')[0];
                var state = result.split(';')[1];
                var country = result.split(';')[2];
                Xrm.Page.getAttribute("address1_city").setValue(city);
                Xrm.Page.getAttribute("address1_stateorprovince").setValue(state);
                Xrm.Page.getAttribute("address1_country").setValue(country);
            } 
        }
    }
    catch (e) {
        alert("Error in address1_postalcode_onChange:\n\n" + e.description);
    }
}

2 comments:

  1. Great article Jonas. It works great!
    But recently we moved to CRM Online and the Plugin throws a serialization exception when sandboxed. The issue is described here: http://social.microsoft.com/Forums/en-US/0b4d4d0f-0428-4964-9ce8-b86c89817974/crm-2011-plugin-in-sandbox-mode-works-incorrectly-with-entitycollection

    TLDR; The solution was to change the line:

    resultCollection.Entities.Add(serverfunction);

    into:

    resultCollection.Entities.Add(serverfunction.ToEntity());

    Kind regards,

    Maarten Docter

    ReplyDelete
  2. Thanks Maarten!
    Great when readers both find problems and gives solutions :)

    /Jonas

    ReplyDelete