CH Upstream Integrations part 1: An Azure Function to Update or Create Content Hub Entity
Intro
The following post describes an example UpsertEntity Azure Function, which allows to update an existing or, when it doesn't exist, insert a new Entity into Sitecore Content Hub.
Intro
This post is the 1st part in a 4-piece series, describing an integration approach that allows to. Content Hub and connect Sitecore with pretty much any external system. I'm composing a number of Azure Functions, both provided by Azure and my custom-built ones to build integration flows using Azure Logic Apps. Upstream integrations pull data from one or more external systems, extracted, transform and process as needed, and pushed changes into Sitecore Content Hub via its APIs. I chose to use Content Hub Web Client SDK, which is a .NET abstraction on top of the Sitecore Content Hub REST API because it simplifies the development of .NET client code and helps to deal with CH API throttling, and does a few more helpful things.
I believe Logic Apps is a good way to visually orchestrate various building blocks (Azure Functions) together with very little to no code required: easy to build, and easy to change, but of course, this isn't the only way. I'm sharing all source code here, so others can use it for building the custom integration solutions for Sitecore Content Hub.
All posts in this series:
- Part 1 (this post): Describes the UpsertEntity function, which will update or create a new entity from the payload data
- Part 2: Describes the UpsertRelation Azure Function, which looks up two Entities to be associated in Content Hub and creates or updates a relation between them.
- Part 3: Example Azure Logic App, combining above functions into example integration flow, which updates and/or creates entities and relations between them in Content Hub as source data is changing (in close to the real-time manner)
- Part 4: Useful Azure Building Blocks for building Cloud Integrations with Sitecore Content Hub
It's worth noting Sitecore that recently announced Sitecore Connect along with other new great products, so consider using Sitecore Connect before implementing your custom solution.
An Example Entity in Content Hub
Here's an example schema of the target entity, which is to be updated or created in the Content Hub
The description_id field is a unique key, which identifies the source record in the source system - this is the field, by which the look-up will be performed.
Authentication: Setting OAuth Client in Content Hub
The code below relies on an "OAuth client", which needs to be configured in Content Hub prior to running. Refer to Sitecore Documentation on Authentication for steps to set up an OAuth client in your Content Hub instance(s).
An Azure Function to Update or Create Entity in Content Hub
How it works
Here's what the below-described function does, high level:
- The request to execute function includes all necessary details about the Content Hub entity to be updated or created. Content Hub instance information, such as its endpoint Url and authentication details are stored in settings, in this case in Function App settings.
- Deserialize the request to extract needed fields from the source content.
- Read Content Hub endpoint Url and Authentication details from Azure Function Settings. Using Azure Key Vault might be a better idea security-wise - I'm leaving this out, but thought this was worth mentioning.
- Search for a given entity in CH using its definition name and search key/value provided in the request. The idea is that the key/value pair is unique in the source system, which knows nothing about Content Hub - this data is saved in Content Hub, so the UpsertEntity function can look it up later.
- If a given entity already exists then it will be updated, otherwise, a new entity is created.
- For the Delete scenarios, I use the "soft delete" approach, where the Entity record, once created, is never deleted from Content Hub, but it can be marked as soft-deleted with a designated soft-delete field, for example, when is_deleted is set to true. The idea is to keep soft-deleted records for future reference (e.g. source content for some older orders, deleted pages, etc.). Hard delete is easy enough to implement by changing a few lines in the function code below.
Azure Logic Apps allow to perform basic retry and error-handling logic does logging, and can be integrated Service Bus queue to decouple the source and destination ends of the integration.
Configuration Settings (Parameters in Azure Logic App)
Refer to post 3 in this series for more details on Azure Logic App configuration settings. A total of five settings are required by this Azure Function to establish a connection to the Content Hub: they include Content Hub root Url ([https://domain_name], nothing after that) and four authentication settings for the above-mentioned OAuth Client.
Function Input: the Request Body
Here's an example payload request for the UpsertEntity function
And these fields should be used like so:
- entitySearch node contains lookup information: the name and the value of the field, by which an existing Entity will be looked up in Content Hub
- fieldName: name of the field as it appears in CH (actual field name, not the display name)
- fieldType: name of the field type as it appears in CH
- fieldValue: the actual value
- properties node contains a flat list of name-value pairs of all properties, as they should be saved into the target entity. Note that string values are surrounded in double quotes, whereas values types, such as Boolean, Number, etc. are not.
Azure Function Source Code
//#r "Newtonsoft.Json" using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Azure.WebJobs.Host; using Newtonsoft.Json; using Stylelabs.M.Framework.Essentials.LoadConfigurations; using Stylelabs.M.Sdk.Contracts.Base; using Stylelabs.M.Sdk.WebClient; using Stylelabs.M.Sdk.WebClient.Authentication; using SY.ContentHub.AzureFunctions.Models; using System; using System.Globalization; using System.Net; using System.Net.Http; using System.Reflection; using System.Threading.Tasks; namespace SY.ContentHub.AzureFunctions { /// /// Update an existing or create a new Entity in Content Hub /// public static partial class UpsertEntity { [FunctionName("UpsertEntity")] public static async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequestMessage req, TraceWriter log) { //Read and parse request payload log.Info("UpsertEntity invoked.", MethodBase.GetCurrentMethod().DeclaringType.Name); var content = req.Content; string requestBody = content.ReadAsStringAsync().Result; log?.Info($"Request body: {requestBody}", MethodBase.GetCurrentMethod().DeclaringType.Name); var requestObject = JsonConvert.DeserializeObject(requestBody); try { //Initialize CH Web SDK client var clientInfo = Utils.ExtractClientInfo(req.Headers); Uri endpoint = new Uri(clientInfo.baseUrl); OAuthPasswordGrant oauth = new OAuthPasswordGrant { ClientId = clientInfo.clientId, ClientSecret = clientInfo.clientSecret, UserName = clientInfo.userName, Password = clientInfo.password }; IWebMClient client = MClientFactory.CreateMClient(endpoint, oauth); //Query for single Entity that matches the search criteria IEntity entity = await Utils.SearchSingleEntity(client, requestObject.entitySearch.entitySearchField.fieldName, requestObject.entitySearch.entitySearchField.fieldValue, requestObject.entitySearch.entitySearchField.definitionName, EntityLoadConfiguration.Full, log); var isNewEntity = false; if (entity == null) { entity = await Utils.CreateEntity(client, requestObject.entitySearch.entitySearchField.definitionName); isNewEntity = true; } //Update property valeus with data from the request payload foreach (var property in requestObject.properties) { var key = property.Key; log?.Info($"Property Key: {key}, Value: {property.Value?.ToString()}", MethodBase.GetCurrentMethod().DeclaringType.Name); IProperty entityProperty = entity.GetProperty(key); if (entityProperty == null) { log?.Info($"Entity Property not found. Name: {property.Key}", MethodBase.GetCurrentMethod().DeclaringType.Name); return new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent($"Entity Property not found. Name: {property.Key}") }; } else { var type = entityProperty.DataType; log?.Info($"Entity Property Name: {entityProperty.Name}, DefinitionType: {entityProperty.DefinitionType} Type: {entityProperty.DataType.Name}", MethodBase.GetCurrentMethod().DeclaringType.Name); if (property.Value != null && !string.IsNullOrEmpty(property.Value.ToString())) { var value = property.Value.ToObject(entityProperty.DataType); try { entity.SetPropertyValue(entityProperty.Name, value); } catch (Exception ex) when (ex.Message == "Culture is required for culture sensitive properties.") { CultureInfo defaultCulture = await client.Cultures.GetDefaultCultureAsync(); entity.SetPropertyValue(entityProperty.Name, defaultCulture, value); } } } } //Save Entity changes back into Content Hub long id = await client.Entities.SaveAsync(entity); var changeKind = isNewEntity ? "created" : "updated"; log?.Info($"Successfully {changeKind} entity. ID: {id}", MethodBase.GetCurrentMethod().DeclaringType.Name); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent($"Successfully {changeKind} entity. ID: {id}") }; } catch (ArgumentException argEx) { var message = $"Invalid request body or missing required parameters. Error message: {argEx.Message}"; log.Info(message, MethodBase.GetCurrentMethod().DeclaringType.Name); return new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent($"Invalid request body or missing required parameters. Error message: {argEx.Message}") }; } catch (Exception ex) { var message = $"'Error message':{ex.Message}"; log.Info(message, MethodBase.GetCurrentMethod().DeclaringType.Name); return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent($"'Error message':{ex.Message}") }; } } } }