Implementing Dataverse Custom APIs (a.k.a new Custom Actions)

Implementing Dataverse Custom APIs (a.k.a new Custom Actions)

It’s been a while since we heard Microsoft’s desire to make Custom Actions and Workflows obsolete.

  • For Workflows this seems to be quite logic since the availability of Power Automate and even if they are not synchronous we can always find a workaround by creating a plug-in.
  • For Custom Actions this was more problematic because in this case not all scenarios can be replaced, for example if we want to call a consistent logic from a button added via RibbonWorkBench we would have to call a Power Automate with an HTTP trigger which is not at all secure. 

These Custom Actions can also be used for integration scenarios to add a message to the Dataverse API to be consumed at our convenience. Another good point was to create reusable logic available within components that do not need to be developed, such as Workflows and Power Automates, and thus make complex logic available to the makers/customizers.

It is only recently that Microsoft announced the notion of  Custom API to both replace and extend Custom Actions.

What is a Custom API ?

The best way to talk about a Custom API is to consider it as a simple plug-in that will potentially take input and output parameters. Rather than developing a class implementing the CodeActivity abstract class we will simply create a plug-in that will be registered for the Main Operation stage of the Event Pipeline, so it means we do not have to register a step to trigger the logic for this new message!

When we talk about input and output parameters, many types of parameters are supported (you will notice that some of them are quite new) :

  • Boolean
  • DateTime
  • Decimal
  • Entity (new)
  • EntityCollection (new)
  • EntityReference
  • Float
  • Integer
  • Money
  • Picklist
  • String
  • StringArray (new)
  • Guid (new)

These new Custom APIs also bring several new features in addition to the new supported types, here are a few examples:

  • Is Function: Ability to specify whether it is a Function or an Action (as described in OData standard).
    • In the case of a function, a GET request must be made and there must be at least one output parameter.
      •  In general, it is recommended to make an Action when we want to modify the data within the application. If we just want to get information, we must implement a Function.
  • Allowed Custom Processing Step Type: Possibility to restrict or not other plugins to be registered on this new message with a choice of the mode (sync and async, async only or none).
  • Execute Privilege Name: Possibility to limit the execution according to a Dataverse security layer privilege.

    Note that there is also another parameter “IsPrivate” that cannot yet be initialized/modified using the form but only by modifying the solution files or updating the metadatas. This parameter defines whether this message will be exposed, and if not, its use by someone other than the publisher of the solution is not supported.

Our First Custom API

In this part we will therefore focus on building step by step an Action and a Function. In order to do this, we will first focus on the initialization part of the Custom API component and then the code implementation!

  • AddUserToTeam: The action that we are going to implement will aim to add a specific user, if he is specified otherwise we will take the initiating user, to a team passed in parameter either by his name or by his EntityReference.
  • CheckUserInTeam: The function will allow us to know if a user is in a specific team or not, so we will have an output parameter.

Custom API Creation

To create a Custom API, you can go through the dedicated forms by adding a new component from your solution (I recommend that you create a dedicated solution that will contain your Custom APIs.).
You can also do it by using web services or by creating a solution file, but you’ll understand that it’s harder πŸ™‚
Note that a designer will be available in the future!

CUstom api components

Once the creation form is opened, we can specify our Custom API.

  • In our case, the message that will be added to the SDK will have the name “dtv_AddUserToTeam” (dtv = Dataverse) and it’s mentioned in the “UniqueName” field.
  • The “Name” will simply allow you to see the name of the custom API in the solution, so I prefer to keep the same name as the SDK message to make it easier to distinguish between Microsoft’s custom APIs and those of other developers.
  • The “DisplayName” and “Description” fields are translatable fields and can be updated after.
  • In our case this message will not be linked to a particular Entity or EntityCollection, so we will stay on a global binding type.
  • We can forbid the possibility to create plug-ins on this new message by specifying the value “none” in the field “Allowed Custom Processing Step Type” and of course we leave the value to “No” in the field “IsFunction” because we want to create an action here!

    I leave for the moment the field “Plugin Type” empty because we haven’t created the associated plug-in yet, we’ll update it after coding these custom APIs! For the notion of privilege, I leave that empty because I don’t want any particular restriction at this level.

    Note that all read-only fields are no longer editable after creation, so you’ll have to recreate the record if you made a mistake!
CUSTOM API COMPONENT CREATION “ACTION”

We now need to add several “Custom API Request Parameter” records, linked to the Custom API created previously, to add the following parameters:

  • TeamName: It will therefore be a string type that will not be mandatory.
  • Team: It will be an EntityReference for the “team” table and will not be mandatory.
  • User: It will be an EntityReference for the “systemuser” table and will not be mandatory either.

    Note that for the “Name” field Microsoft recommends using a naming convention including the name of the Custom API for obvious reasons of visibility and understanding!
    You’ll notice that I’ve decided to set the “Team” and “TeamName” parameters as not mandatory because I want to let the choice, but we’ll do a test within the code to make sure we have one of them!

    Below is the configuration of each of them:

Now we will do the same for ourΒ CheckUserInTeam function which is quite similar as the input parameters are the same (in fact you will see that the EntityReference type is not supported for a Function, so we will have to change slightly the different parameters even if they represent the same thing in our case, it’s a known issue), but we add an output parameter and specify that this is a Function and not an Action.
Here is the definition of the Custom API:

CUSTOM API COMPONENT CREATION “FUNCTION”

And here is the definition of the input parameters:

As mentioned above, the purpose of a Function is that there must be a response because a GET method is used here, so we will add a Custom API Response Property!
Note that we could build this Function as an Action, the purpose here is to see both possibilities but with an Action you can also add a Response Property πŸ™‚

CUSTOM API RESPONSE PROPERTY CREATION

In case you do not create a Response Property your “Function” Custom API will not be valid and therefore you will not be able to use it, you will get a 404 error with the following message:

{"error":{"code":"0x8006088a","message":"Resource not found for the segment 'dtv_CheckUserInTeam'."}}

If you have created a dedicated solution like me for these two actions, you should get something like this (we notice the usefulness of keeping a good name for the different names of the custom APIs and input and output parameters, especially when they have the same name πŸ™‚ ):

Coding Part

The first thing to know is that the plugin must be registered on the MainOperation stage (=30) and the Mode (Synchronous/Asynchronous) must not be taken into consideration, so you will have to modify your PluginBase and make sure that this does not cause any problems with your existing plugins.
When we have finished coding the plug-in, we will need to update the “Plugin Type” field in our custom APIs to make sure the logic will be executed on the called message!

So we will create a new class “AddUserToTeam“. For my part I use a PluginBase, so I adapt accordingly by registering the new Message on the Stage MainOperation (=30) for the message “dtv_AddUserToTeam” which once triggered will execute the “Execute” function:

        public AddUserToTeam() : base(typeof(AddUserToTeam))
        {
            RegisteredEvents.Add(
                  new Tuple
                  <MessageProcessingStepStage, string, Action<LocalContext>>(
                       MessageProcessingStepStage.MainOperation, MessageName.dtv_AddUserToTeam, Execute));
        }

Now we can focus on the logic to be implemented, which should contain the following steps:

  • Getting the InputParameters
  • Checking the consistency of the InputParameters
    • You must have the input parameter “Team” or “TeamName” not empty, otherwise an error message must be returned.
  • Retrieving the corresponding Team based on the EntityReference if it’s defined or using the Name
    • If the team does not exist, an error message must be returned.
  • Adding the user passed in parameter or the initiating user (in case the parameter User is not filled in) to the team.
        /// <summary>
        /// Method executed when the Custom API is triggered.
        /// </summary>
        /// <param name="localContext"></param>
        public void Execute(LocalContext localContext)
        {
            string teamName = localContext.PluginExecutionContext.InputParameters["TeamName"] as string;
            EntityReference teamReference = localContext.PluginExecutionContext.InputParameters["Team"] as EntityReference;
            if (!string.IsNullOrEmpty(teamName)
                || teamReference != null)
            {

                QueryExpression teamQuery = new QueryExpression()
                {
                    EntityName = "team",
                    ColumnSet = new ColumnSet("teamid", "name"),
                };
                if (teamReference != null)
                    teamQuery.Criteria.AddCondition(new ConditionExpression("teamid", ConditionOperator.Equal, teamReference.Id));
                else
                    teamQuery.Criteria.AddCondition(new ConditionExpression("name", ConditionOperator.Equal, teamName));
                Entity teamEntity = localContext.OrganizationService.RetrieveMultiple(teamQuery).Entities.FirstOrDefault();
                teamReference = teamEntity == null
                    ? throw new InvalidPluginExecutionException(OperationStatus.Failed, $"No team exists with the name: {teamName}.")
                    : teamEntity.ToEntityReference();

                AddMembersTeamRequest req = new AddMembersTeamRequest
                {
                    TeamId = teamReference.Id,
                    MemberIds = new[] { localContext.PluginExecutionContext.InputParameters["User"] is EntityReference userReference
                        ? userReference.Id
                        : localContext.PluginExecutionContext.InitiatingUserId }
                };
                localContext.OrganizationService.Execute(req);
            }
            else
                throw new InvalidPluginExecutionException(OperationStatus.Failed, "InputParameters 'TeamName' or 'Team' is null or empty. At least one of them must be defined.");
        }

Now we can start to develop the “CheckUserInTeam” Function, on the same principle as the first one, we register our event on the right message and stage:

        /// <summary>
        /// Initializes a new instance of the <see cref="CheckUserInTeam" /> class
        /// </summary>
        public CheckUserInTeam() : base(typeof(CheckUserInTeam))
        {
            RegisteredEvents.Add(
                  new Tuple
                  <MessageProcessingStepStage, string, Action<LocalContext>>(
                       MessageProcessingStepStage.MainOperation, MessageName.dtv_CheckUserInTeam, Execute));
        }

Now we can focus on the logic to be implemented, which should contain the following steps:

  • Getting the InputParameters
  • Checking the consistency of the InputParameters
    • You must have the input parameter “TeamGuid” or “TeamName” not empty, otherwise an error message must be returned.
  • Retrieving the corresponding Team, based on the Guid if it’s defined or using the Name, and adding a LinkEntity on the systemuser table using the user identifier passed in parameter or the initiating user (in case the parameter User is not filled in).
        /// <summary>
        /// Method executed when the Custom API is triggered.
        /// </summary>
        /// <param name="localContext"></param>
        public void Execute(LocalContext localContext)
        {
            string teamName = localContext.PluginExecutionContext.InputParameters.Contains("TeamName") ? localContext.PluginExecutionContext.InputParameters["TeamName"] as string : string.Empty;
            Guid teamId = localContext.PluginExecutionContext.InputParameters.Contains("TeamGuid") ? (Guid)localContext.PluginExecutionContext.InputParameters["TeamGuid"] : Guid.Empty;
            if (!string.IsNullOrEmpty(teamName)
            || teamId != Guid.Empty)
            {
                Guid userId = localContext.PluginExecutionContext.InputParameters.Contains("UserGuid") ? (Guid)localContext.PluginExecutionContext.InputParameters["UserGuid"] : localContext.PluginExecutionContext.InitiatingUserId;

                QueryExpression teamQuery = new QueryExpression()
                {
                    EntityName = "team",
                    ColumnSet = new ColumnSet("teamid", "name"),
                };
                if (teamId != Guid.Empty)
                    teamQuery.Criteria.AddCondition(new ConditionExpression("teamid", ConditionOperator.Equal, teamId));
                else
                    teamQuery.Criteria.AddCondition(new ConditionExpression("name", ConditionOperator.Equal, teamName));

                LinkEntity memberShipLink = new LinkEntity("team", "teammembership", "teamid", "teamid", JoinOperator.Inner);
                LinkEntity systemUserLink = new LinkEntity("teammembership", "systemuser", "systemuserid", "systemuserid", JoinOperator.Inner);
                systemUserLink.LinkCriteria.Conditions.Add(new ConditionExpression("systemuserid", ConditionOperator.Equal, userId));
                memberShipLink.LinkEntities.Add(systemUserLink);
                teamQuery.LinkEntities.Add(memberShipLink);

                Entity matchEntity = localContext.OrganizationService.RetrieveMultiple(teamQuery).Entities.FirstOrDefault();
                if (matchEntity != null)
                    localContext.PluginExecutionContext.OutputParameters["IsUserInTeam"] = true;
                else
                    localContext.PluginExecutionContext.OutputParameters["IsUserInTeam"] = false;

            }
            else
                throw new InvalidPluginExecutionException(OperationStatus.Failed, $"InputParameters 'TeamName' or 'TeamGuid' is null or empty. At least one of them must be defined.");

        }

Now we have to publish our assembly and update the “Plugin Type” field of our two Custom APIs to indicate which plugin should run:

Testing phase

Now that we’ve been able to finalize the development part, we still have to do some tests πŸ™‚
I’m going to use a user and create a new Team “TestAddUserToTeam“, the goal will be to test if he belongs to this team using the Function “dtv_CheckUserInTeam“, then to associate him to the team using the Action “dtv_AddUserToTeam” and finally to check that the Function returns a positive result!

You can simply use PostMan for this (you need to replace the {{WebApiUrl}} parameter with something like this “https://yourorg.crm.dynamics.com/api/data/v9.1”) :

Calling the Custom API Function “dtv_CheckUserInTeam” (you can see that the information are sent as Query Parameters and not in a body because this is an HTTP GET method):

{{WebAPIUrl}}/dtv_CheckUserInTeam(TeamName='TestAddUserToTeam',UserGuid=8ff9ca3f-f506-eb11-a812-0022480497bc)

Response from Custom API Function “dtv_CheckUserInTeam“:

{
    "@odata.context": "https://yourorg.crm.dynamics.com/api/data/v9.0/$metadata#Microsoft.Dynamics.CRM.dtv_CheckUserInTeamResponse",
    "IsUserInTeam": false
}

Note that if we try to call the function without at least one of the required parameters we get an error throwed by our code:

{
    "error": {
        "code": "0x80040265",
        "message": "InputParameters 'TeamName' or 'TeamGuid' is null or empty. At least one of them must be defined."
    }
}

Calling the Custom API Action “dtv_AddUserToTeam“:

{{WebAPIUrl}}/dtv_AddUserToTeam

Here is the body of the request where you can see the “User” property which is an EntityReference:

{
    "TeamName": "TestAddUserToTeam",
    "User": {
        "systemuserid": "8ff9ca3f-f506-eb11-a812-0022480497bc",
        "@odata.type": "Microsoft.Dynamics.CRM.systemuser"
    }
}

Response from Custom API Action “dtv_AddUserToTeam” (You can see that there is no content because we don’t have a Custom API Response Property!):

CUSTOM API ACTION RESPONSE

Now, if we check the team we can see that the user is present in the team and if we execute again the Function “dtv_CheckUserInTeam” we have our output property with the value “true“:

User added to a team
{
    "@odata.context": "https://yourorg.crm.dynamics.com/api/data/v9.0/$metadata#Microsoft.Dynamics.CRM.dtv_CheckUserInTeamResponse",
    "IsUserInTeam": false
}

 

As a video is much better than a long speech πŸ™‚ :

I hope you enjoyed this article, even though it is quite huge ! πŸ™‚

Leave a Reply

Your email address will not be published.