Creating Actions
Creating Actions
When a user in the Xchange platform wants to perform some action against a target system, that request is called an Action
. These Actions that can be performed against a system are defined by the connector builders.
The service that contains all of the Action Handlers defined in the connector is called Action Processor. The Action Processor, using a path-based factory, is the one that gets the correct Handler to instantiate when a user creates an action payload, and it passes the action payload.
About Action Handlers
The Action Handler decides what should happen in response to the event and is used to change data in your system.
When Action Processing is required, the Xchange platform will create an event that passes the data that needs to be changed to your Handler in the form of an ActionInput.
A Handler uses an Action to define the ActionInput
, ActionOutput
and ActionFailure
models, and it uses them in a request to your system.
Putting Action Handlers into action
We’ll start using the CLI to create your first Action, using PascalCase for the name.
The CLI command:
xchange action new --module-id app-1 --object-name Employees --name Create
Options for this command
- --module-id
- A module allows for the grouping of various functionality in your Connector code. In this specific instance, we’re using a generic default group called:
app-1
. - Required: True
- A module allows for the grouping of various functionality in your Connector code. In this specific instance, we’re using a generic default group called:
- --object-name (aliases: -o)
- The name of the data object to create an Action Handler for. PascalCase is preferred. In this instance we are using
Employees
as a placeholder for your object's name. - Required: True
- The name of the data object to create an Action Handler for. PascalCase is preferred. In this instance we are using
NOTE! The object-name you use here must match the name of the data object you previously created.
-
--name (aliases: -n, --action-name)
- Create a name for this Action. In this instance we’ll call this Action Create.
- Required: True
-
--connector-path
- The path of the folder in which the connector project lives. Defaults to the current working directory if not set.
- Required: False
- Default: Current working directory
This command will create a new folder in your repository, within the App/v1/Employees
folder, named Create
(or whatever the actual action name is that you provided).
In the CreateEmployeesAction.cs
file there will be three new classes for data transfer:
CreateEmployeesAction
, CreateEmployeesActionInput
, CreateEmployeesActionOutput
.
CreateEmployeesActionInput
will need to be modified to include all the properties required for the Handler. These properties are based on what you want your Handler to do, since they are the input for your connector. The ActionInput
property is an instance of this class.
CreateEmployeesActionOutput
will need to be modified to include all the properties you want to return to the Xchange platform. These properties will usually match the properties of the data object so that the Xchange cache can be updated for your Connector. The ActionOutput
property is an instance of this class.
StandardActionFailure
is a Connector SDK defined class that also makes an appearance on CreateEmployeesAction.cs
, since the ActionFailure
property is an instance of it, and it's a class for error handling. The properties of this class provide details of an error, its message and its source.
For more guidance on how to add properties to your input and output data objects follow our object schema guide.
Modify the ActionHandler placeholder class
With your CLI command a placeholder class file was created called CreateEmployeesHandler.cs
. We now want to modify that file so that it does what you want.
We will want to inject the client into CreateEmployeesHandler
Example of a client injection into CreateEmployeesHandler
public class CreateEmployeesHandler : IActionHandler<CreateEmployeesAction>
{
ILogger<CreateEmployeesHandler> _logger;
private readonly ApiClient _apiClient;
public CreateEmployeesHandler(
ILogger<CreateEmployeesHandler> logger,
ApiClient apiClient)
{
_logger = logger;
_apiClient = apiClient;
}
}
Below are additional basic modifications to the CreateEmployeesHandler
with comments inline.
Change the HandleQueuedActionAsync
method so that it can be used to write changes back to your system.
Example of a basic HandleQueuedActionAsync
implementation
In summary what is being done is:
- Given the input for the action, make a call to your API/system.
- Build sync operations to update the local cache as well as the Xchange cache system (if the data type is cached).
- If an error occurs, we want to create a failure result for the action that matches the failure type for the action. Common to create extension methods to map to
StandardActionFailure
.
public async Task<ActionHandlerOutcome> HandleQueuedActionAsync(ActionInstance actionInstance, CancellationToken cancellationToken)
{
var input = JsonSerializer.Deserialize<CreateEmployeesActionInput> (actionInstance.InputJson)!;
try
{
// (1)
var response = await _apiClient.CreateEmployee(input, cancellationToken).ConfigureAwait(false);
// The full record is needed for SyncOperations. If the endpoint used for the action returns a partial record (such as only returning the ID) then you can either:
// - Make a GET call using the ID that was returned
// - Add the ID property to your action input (Assuming this results in the proper data object shape)
// var resource = await _apiClient.GetEmployee(response.Data.id, cancellationToken);
// var resource = new CreateEmployeesActionOutput
// {
// TODO : map
// };
// If the response is already the output object for the action, you can use the response directly
if (!response.IsSuccessful || response.Data == default)
return ActionHandlerOutcome.Failed(new StandardActionFailure
{
Code = response.StatusCode.ToString(),
Errors = new []
{
new Error
{
Source = new [] { nameof(CreateEmployeesHandler) },
Text = $"Failed to create employee with status code {response.StatusCode}"
}
}
});
// (2)
// Build sync operations to update the local cache as well as the Xchange cache system (if the data type is cached)
// For more information on SyncOperations and the KeyResolver, check: https://trimble-xchange.github.io/connector-docs/guides/creating-actions/#keyresolver-and-the-sync-cache-operations
var operations = new List<SyncOperation>();
var keyResolver = new DefaultDataObjectKey();
var key = keyResolver.BuildKeyResolver()(response.Data);
operations.Add(SyncOperation.CreateSyncOperation(UpdateOperation.Upsert.ToString(), key.UrlPart, key.PropertyNames, response.Data));
var resultList = new List<CacheSyncCollection>
{
new CacheSyncCollection() { DataObjectType = typeof(EmployeesDataObject), CacheChanges = operations.ToArray() }
};
return ActionHandlerOutcome.Successful(response.Data, resultList);
}
catch (ApiException exception)
{
// (3)
var errorSource = new List<string> { nameof(CreateEmployeesHandler) };
if (string.IsNullOrEmpty(exception.Source)) errorSource.Add(exception.Source!);
return ActionHandlerOutcome.Failed(new StandardActionFailure
{
Code = exception.StatusCode?.ToString() ?? "500",
Errors = new []
{
new Error
{
Source = errorSource.ToArray(),
Text = exception.Message
}
}
});
}
}
Sync Operations in Detail
The sync operations update the Connector's current cache. When new records are created, or existing records are updated, the cache needs to be updated to reflect the changes. The DefaultDataObjectKey
object is used to derive the key from the record.
Take the following as an example when using the DefaultDataObjectKey
for resolving
var operations = new List<SyncOperation>();
var keyResolver = new DefaultDataObjectKey();
var key = keyResolver.BuildKeyResolver()(response.Data); // Where `response.Data` is the new record
operations.Add(SyncOperation.CreateSyncOperation(
UpdateOperation.Upsert.ToString(),
key.UrlPart,
key.PropertyNames,
response.Data));
var resultList = new List<CacheSyncCollection>
{
new CacheSyncCollection()
{
DataObjectType = typeof(EmployeeDataObject),
CacheChanges = operations.ToArray()
}
};
return ActionHandlerOutcome.Successful(response.Data, resultList);
By default DefaultDataObjectKey
will attempt to create a key using id
as its name and id
as the property to retrieve the value from. For example if given a record that looks as seen below the key will resolve as id/123
.
{
"id": 123,
"firstName": "John",
"lastName": "Smith"
}
This should match what was defined on the data object's key you are acting on for this sync operation.
If your record does not fit what is done by default then you can change it by supplying different parameters to DefaultDataObjectKey
on its constructor.
var keyResolver = new DefaultDataObjectKey("employeeId", "primaryKey");
Using the same example provided about the key would resolve as employeeId/123
. This also means that the data object should be defined as:
[PrimaryKey("employeeId", "primaryKey")]
[Description("The representation of an employee")]
public class EmployeeDataObject
{
[Required]
public required long PrimaryKey { get; init; }
}
A similar pattern is also done if trying to resolve a key that is composed of multiple properties, this is known as Compose Keys. Using the example defined in the Compose Keys we can construct a key resolver as follows:
var keyResolver = new DefaultDataObjectKey("id", "division", "id");
Testing
To test your connector locally, check out our guide for local testing here.