Blog post Technical, Tridion

Leveling up Aprimo and Tridion Integration Series (Part 3: Create Aprimo DAM record with previously uploaded video)

Previously in our series, we explained how to upload a media file to the /upload service and receive the response token as a result. This blog will focus on how to use that upload token to create a new Aprimo record.

What is a record?

All media in Aprimo are organized as records. A record is a collection of different binaries that represent the same resource along with metadata about the grouped media items. For example, you can have a homepage banner record that contains a master file -- which is in most cases, a full-resolution image that's over 100 megabytes -- as well as numerous variations of it like tablet and mobile-oriented versions of the master image. All of these are grouped together within the same record, and it's up to the caller to decide which media item from the record they would like to retrieve.

At least one binary media item must be added before you can successfully create a record using the API. The same is true within the Aprimo Portal. The flow expects users to upload a binary first, and then create a record from it.

The record itself contains a wide variety of properties that you can access or modify:

How do we retrieve a record?

The code below provides an example of how we would retrieve record:

public async Task<RecordEntity> GetRecordAsync(OAuthToken authToken, string recordId, KeyValuePair<string, string>[] extraParams = null, CancellationToken cancellationToken = default) 
{
    if (!authToken.IsValid()) 
        throw new ArgumentException($"{nameof(GetRecord)} failed; authToken is not valid."); 

    if (string.IsNullOrWhiteSpace(recordId)) 
        throw new ArgumentException($"{nameof(GetRecord)} failed; recordId is null or empty."); 

    try 
    {
        //NOTE: Always take care to properly build the Uri with path and querystring encoding... 
        //  or use the awesome Flurl library and simplify your life! UriBuilder isn't as robust but
        //  it will correctly handle the path separators and encoding for this use case.
        var uriBuilder = new UriBuilder(HostUrl); 
        uriBuilder.Path = Path.Combine(uriBuilder.Path, "api/core/record/", recordId); 
        
        //NOTE: This is a helper method that executes the GET request and returns the full response content as an in-memory string...
        //      See below for the source of this method.
        string recordResult = await GetHttpResponseStringAsync(authToken?.AccessToken, uriBuilder.ToString(), extraParams, cancellationToken); 
        
        //NOTE: For best performance use System.Text.Json instead...
        var recordEntity = JsonConvert.DeserializeObject<RecordEntity>(recordResult); 
        return recordEntity; 
    } 
    catch (Exception ex) 
    { 
        throw new Exception($"{nameof(GetRecord)}() failed.", ex); 
    }
}

This example above deserializes the response from the Aprimo API into a RecordEntity object, which is a custom class used to model the Json payload response from Aprimo.

TIP:

It is important to understand that you must explicitly identify the data being requested when calling the the Aprimo API; it will not provide a full Record model by default. Therefore, it is interesting to see what an actual call to our GetRecordAsync(...) method might actually look like as shown in the snippet below.

RecordEntity record = await GetRecordAsync(AuthenticationEntity.Token, copiedRecordId, extraParams: new KeyValuePair<string, string>[] { 
    new("Select-Record", "masterFile"), 
    new("Select-Record", "fields"),
    new("Select-Record", "files"), 
    new("Select-Record", "masterFileLatestVersion"), 
    new("Select-File", "latestversion")
});

In this example we are requesting the masterFile, fields, files, masterFileLatestVersion and latestversion properties, all of which are explained in the table at the start of this article.

With this approach, the Aprimo API allows the consumers (us) to really control the payload of the response. Which enables us to optimize the performance, memory and network bandwidth requirements for our application. By only requesting the data needed, we can prevent unnecessary over-fetching while simplifying our code and application logic. For those of you that might be thinking about GraphQL right now, well here we show that REST API's can also prevent over-fetching.

So how would we Create a new record, or update an existing record?

Let's continue with our example that is currently uploading several videos with very similar metadata. In this case it's often a good idea to follow a template record approach. This means that we can create a single template record in Aprimo which contains all the shared metadata and then copy/clone it as needed for each video. When doing so we generally only need to modify the masterFile property (aka the binary file), alongside any other metadata deviations each record may have.

This provides the flexibility to shift most of the heavy lifting onto the Aprimo API and have our application define only what's needed.

Of course, you could manually recreate all data on each upload, however, anytime we can leverage the platform to help us with some pseudo-automation then we can simplify our application and likely improve performance.

The workflow is explained best in the example code below further building on the above examples:

//First, we upload our binary file pre-requisite...
//NOTE: See below for source of this method.
string token = await aprimoDamService.UploadFileAsync(AuthenticationEntity.Token, fileName, content, cancellationToken);

//Seond we clone our template to create a new Record placeholder ready for us to update...
//NOTE: See below for source of this method.
string copiedRecordId = await aprimoDamService.CopyRecordAsync(AuthenticationEntity.Token, cancellationToken);

//Third we construct our Payload for the update which we've encapsulated in a helper method here...
//NOTE: This is a helper class used to encapsulate the (verbose) logic for creating valid Aprimo payloads.
//      See below for the source of this method.
UpdatePayload payloadForUpdate = RequestHelper.CreateRecordPayloadForUpdate(token, fileName, mediaItemTitle, mediaItemUrl);

//Finally, we send the payload to Aprimo to update the newly copied/duplicated record...
await aprimoDamService.UpdateRecordAsync(AuthenticationEntity.Token, copiedId, payloadForUpdate);

//Now, we can retrieve the record and see all details are populated from our template we cloned and our update call...
RecordEntity record = await aprimoDamService.GetRecordAsync(AuthenticationEntity.Token, copiedRecordId, extraParams: new KeyValuePair<string, string>[] { 
    new("Select-Record", "masterFile"), 
    new("Select-Record", "Title"),
    new("Select-Record", "fields"),
    new("Select-Record", "files"), 
    new("Select-Record", "masterFileLatestVersion"), 
    new("Select-File", "latestversion")
});

First, we must upload the file to Aprimo as explained in the previous article Part 2: Upload a video file to Aprimo DAM via the API. This is now nicely encapsulated in our AprimoDamService.UploadFileAsync() method. Then we need to copy the template record to create a new instance, after which we can proceed with creating the new payload for the update. Finally ,we can send the update payload to update our new record with final details.

In the payload I am using the upload token to specify that the new master file will be the newly uploaded file:

{
    	"files": {
        "master": "NzVmMDY5YTA4MGU1NDBhMDkyY2FmYzQ3YzQwMzNjMmQ=",
        "addOrUpdate": [{
                "versions": {
                    "addOrUpdate": [{
                            "id": "NzVmMDY5YTA4MGU1NDBhMDkyY2FmYzQ3YzQwMzNjMmQ=",
                            "fileName": null,
                            "versionLabel": "version 1",
                            "comment": "Uploaded by Tridion"
                        }
                    ]
                }
            }
        ]
    },
    "fields": {
        "addOrUpdate": [{
                "id": "13d145c75f03458db9f1ad71009075f4",
                "localizedValues": [{
                        "languageId": "c2bd4f9bbb954bcb80c31e924c9c26dc",
                        "values": ["tridion event"]
                    }
                ]
            }, {
                "id": "c60d2ac9d35e405286adad71009098e3",
                "localizedValues": [{
                        "languageId": "00000000000000000000000000000000",
                        "values": ["60aaf679647e483d85c9ad6300b99e3d"]
                    }
                ]
            }, {
                "id": "888ecfc4faff470c9358adab0123c93e",
                "localizedValues": [{
                        "languageId": "00000000000000000000000000000000",
                        "value": "https://myurl/home"
                    }
                ]
            }, {
                "id": "f288f10a499e415b9d6bad71008f8144",
                "localizedValues": [{
                        "languageId": "c2bd4f9bbb954bcb80c31e924c9c26dc",
                        "value": "網絡研討會:訪問我們並了解有關 Tridion 集成主題的更多信息"
                    }
                ]
            }, {
                "id": "f603aec30cb14e37bb71ad7f00c2e764",
                "localizedValues": [{
                        "languageId": "00000000000000000000000000000000",
                        "values": ["ca0279cff45140e08c4fad7f00c386ce"]
                    }
                ]
            }
        ]
    },
    "classifications": {
        "addOrUpdate": [{
                "id": "60aaf679647e483d85c9ad6300b99e3d",
                "sortIndex": 2147483647
            }, {
                "id": "a28cb0d3ee5e4e4aa142ad6300be030b",
                "sortIndex": 2147483647
            }
        ]
    }
}

In the payload Json above it's important to highlight that we are providing 3 sets of information:

  1. New Version of Master File:
    • Upload token ("NzVmMDY5YTA4MGU1NDBhMDkyY2FmYzQ3YzQwMzNjMmQ=") is set in both "master" and "id" property (on the addOrUpdate node)
    • "filename" property should be null which is the opposite of what the official documentation states -- the official docs are misleading in this regard.
  2. Fields:
    • Each field has its own id which you can retrieve from the Aprimo UI.
    • For each field I am supplying new values.
    • Most of the fields have default language set to "00000000000000000000000000000000", however, you can provide some values for other languages as well.
  3. Classifications are the tags in Aprimo. You also provide them using their id and sortIndex.

Once the record is updated, the next step is to retrieve the data from it to be used in Tridion. In Tridion we need data from the newly created/updated record to then construct the Component which uses the ECL item (aka an external content library item from our Aprimo DAM). We will cover this final topic in our next and final blog for the series.

If you have any questions or if you are interested in learning more from us about APRIMO and/or Tridion integrations, feel free to contact us.

Here is the additional source code for the utility methods and processing in the above examples (noted in code comments):

public partial class AprimoDamService
{
    public async Task<string> CopyRecordAsync(OAuthToken authToken, CancellationToken cancellationToken = default)
    {
        if (!authToken.IsValid())
            throw new Exception("Copy record failed. authToken is not valid.");

        try
        {
            //NOTE: Always take care to properly build the Uri with path and querystring encoding...  
            //  or use the awesome Flurl library and simplify your life! UriBuilder isn't as robust but 
            //  it will correctly handle the path separators and encoding for this use case. 
            var uriBuilder = new UriBuilder(HostUrl);
            uriBuilder.Path = Path.Combine(uriBuilder.Path, "api/core/records/");
 
            var extraHeaders = new KeyValuePair<string, string>[]
            {
                 new ("immediateSearchIndexUpdate", "true"),
                 new ("copyFrom", CopyFromRecord),
                 new ("recreateAutomaticPreviews", "true")
            };
 
            var copyRecordResponse = await PostHttpRequestAsync(authToken?.AccessToken, url, null, extraHeaders, cancellationToken);
            
            //NOTE: For best performance use System.Text.Json instead... 
            CopyRecordEntity result = JsonConvert.DeserializeObject<CopyRecordEntity>(copyRecordResponse);
            return result.Id;
        }
        catch (Exception ex)
        {
            throw new Exception("Create record draft failed.", ex);
        }
    }
 
    public async Task UpdateRecordAsync(OAuthToken authToken, string recordId, UpdatePayload payload, CancellationToken cancellationToken = default)
    {
        if (!authToken.IsValid())
            throw new Exception("UpdateRecord failed. authToken is not valid.");
        if (string.IsNullOrWhiteSpace(recordId))
            throw new Exception("UpdateRecord failed. recordId is null or empty.");
        if (payload == null)
            throw new Exception("UpdateRecord failed. payload is null.");
        
        try
        {
            //NOTE: Always take care to properly build the Uri with path and querystring encoding...  
            //  or use the awesome Flurl library and simplify your life! UriBuilder isn't as robust but 
            //  it will correctly handle the path separators and encoding for this use case. 
            var uriBuilder = new UriBuilder(HostUrl);
            uriBuilder.Path = Path.Combine(uriBuilder.Path, "api/core/record/", recordId);
 
            var extraHeaders = new KeyValuePair<string, string>[]
            {
                new ("set-immediateSearchIndexUpdate", "true"),
                new ("set-recreateAutomaticPreviews", "true")
            };
 
            await PutHttpRequestAsync(authToken?.AccessToken, uriBuilder.ToString(), payload, extraHeaders, cancellationToken);
        }
        catch (Exception ex)
        {
            throw new Exception("UpdateRecord failed.", ex);
        }
    }
 
    protected Task<string> GetHttpResponseStringAsync(string accessToken, string url, KeyValuePair<string, string>[] extraHeaders, CancellationToken cancellationToken = default)
        => SendHttpRequestInternalAsync(HttpMethod.Get, accessToken, url, extraHeaders, cancellationToken);
 
    protected Task<string> PostHttpRequestAsync<TContent>(string accessToken, string url, TContent data, KeyValuePair<string, string>[] extraHeaders, CancellationToken cancellationToken = default)
        => SendHttpRequestInternalAsync(HttpMethod.Post, accessToken, url, data, extraHeaders, cancellationToken);
 
    protected Task<string> PutHttpRequestAsync<TContent>(string accessToken, string url, TContent data, KeyValuePair<string, string>[] extraHeaders, CancellationToken cancellationToken = default)
        => SendHttpRequestInternalAsync(HttpMethod.Put, accessToken, url, data, extraHeaders, cancellationToken);
 
    //NOTE: This is an example of manually handling the Http Client, Requests, & Responses, but you can make your life  
    //  a whole lot easier by using the amazing Flurl library which would greatly simplify and streamline the code while
    //  also making it much more reliable (espeically when building Urls manually).
    protected async Task<string> SendHttpRequestInternalAsync<TContent>(HttpMethod httpMethod, string accessToken, string url, TContent data, KeyValuePair<string, string>[] extraHeaders, CancellationToken cancellationToken = default)
    {
        //NOTE: Always use HttpClient factory (do not Dispose of HttpClients)...   
        var httpClient = _httpClientFactory.CreateClient();
        httpClient.BaseAddress = new Uri(this.UploadServiceUrl);
 
        using (HttpRequestMessage httpRequestMessage = new HttpRequestMessage(httpMethod, new Uri(url)))
        {
            httpRequestMessage.Headers.Add("Authorization", "Bearer " + accessToken);
            if (extraHeaders != null)
                foreach (var kv in extraHeaders)
                    httpRequestMessage.Headers.Add(kv.Key, kv.Value);
 
            if (data != null)
            {
                //NOTE: For best performance use System.Text.Json instead... 
                var serializedJson = JsonConvert.SerializeObject(data);
                httpRequestMessage.Content = new StringContent(serializedJson, Encoding.UTF8, "application/json");
            }
 
            using (HttpResponseMessage responseMessage = await httpClient.SendAsync(httpRequestMessage, cancellationToken))
            {
                //Throw and Exception if the response is not Successful status (e.g. 200-OK)... 
                responseMessage.EnsureSuccessStatusCode();
                return await responseMessage.Content.ReadAsStringAsync();
            }
        }
    }
}
public partial class RequestHelper
{
    public static UpdatePayload CreateRecordPayloadForUpdate(string fileToken)
    {
        return new UpdatePayload()
        {
            Files = new FilesPayload()
            {
                Master = fileToken,
                AddOrUpdate = new List<FilePaylodAddOrUpdateItem>()
                {
                    new FilePaylodAddOrUpdateItem()
                    {
                        Versions = new FilePayloadVersionsItem()
                        {
                            AddOrUpdate = new List<FilePayloadVersionAddOrUpdateItem>()
                            {
                                new FilePayloadVersionAddOrUpdateItem()
                                {
                                    Id = fileToken,
                                    VersionLabel = "version 1",
                                    Comment = "Uploaded by Tridion Import tool"
                                }
                            }
                        }
                    }
                }
            },
            Classifications = new ClassificationsPayload()
            {
                AddOrUpdate = new List<ClassificationPayloadItem>()
                {
                    new ClassificationPayloadItem()
                    {
                        Id = "ID_PLACEHOLDER",
                        SortIndex = 2147483647
                    },
                    new ClassificationPayloadItem()
                    {
                        Id = "ID_PLACEHOLDER",
                        SortIndex = 2147483647
                    },
                    new ClassificationPayloadItem
                    {
                        Id = "ID_PLACEHOLDER",
                        SortIndex = 2147483647
                    }
                }
            },
            Fields = new FieldsPayload()
            {
                AddOrUpdate = new List<FieldPayloadAddOrUpdateItem>()
                {
                    new FieldPayloadAddOrUpdateItem()
                    {
                        //Security
                        Id = "ID_PLACEHOLDER",
                        LocalizedValues = new List<FieldPayloadLocalizedValueItem>()
                        {
                            new FieldPayloadLocalizedValueItem()
                            {
                                Values = new string[]{ "ID_PLACEHOLDER" },
                                LanguageId = "00000000000000000000000000000000"
                            }
                        }
                    },
                    new FieldPayloadAddOrUpdateItem()
                    {
                        Id = "ID_PLACEHOLDER",
                        LocalizedValues = new List<FieldPayloadLocalizedValueItem>()
                        {
                            new FieldPayloadLocalizedValueItem()
                            {
                                Values = new string[]{ "ID_PLACEHOLDER" },
                                LanguageId = "00000000000000000000000000000000"
                            }
                        }
                    }
                }
            }
        };
    }
}
Contact us to discuss your project.
We're ready to work with you.
Let's talk