Using an Azure Function Webhook with Kentico Kontent
Developers want to automate everything. Whether it’s moving code or testing a function, enslaving a machine to do your dirty work is the best way to exert your authority over computers and to simplify your coding life.
Bryan SoltisPublished on Mar 4, 2020
NOTE: we created a more complete and up-to-date example of this in "How to integrate Azure Cognitive Search with Kentico Kontent".
In this article, I’ll show you an example of how to use Kentico Kontent’s webhook support to streamline your applications.
When it comes to programming, it’s all about making things as optimized as you can. Maybe it’s for performance improvements. Maybe it’s just so you can understand what you did six years ago. Regardless of your motives, you should always be thinking about how to implement functionality in the best possible way. And that’s why the world has webhooks.
By standing up these programmatic sentinels, developers can leverage automation in their architecture easily. When it comes to a Headless CMS like Kentico Kontent, this capability is even more important. Because the content will be managed in a central location, knowing when it changes and updating other systems can be a bit of a challenge. With Kentico Kontent’s webhook support, your worries are over!
Don’t believe me? Let me show you how I used this functionality to automate my Azure Search index updates with webhooks and Kentico Kontent.
Create the Azure Function
The first step of the process was to create a new Azure Function. Because the integration would now be webhook-a-fied, I technically could have just made a new function in my site for Kentico Kontent to call. But where’s the fun in that?!? I opted to use a new Azure Generic Webhook Function for the job.
In my Azure Function utility, I created a new function. I selected C# and the Generic Webhook flavor. This function is already wired up to accept an HttpRequestMessage, which Kentico Kontent will be posting.
Here’s the default function code, which accepts the HttpRequestMessage and does some basic validation.
#r "Newtonsoft.Json"
using System;
using System.Net;
using Newtonsoft.Json;
public static async Task<object> Run(HttpRequestMessage req, TraceWriter log)
{
log.Info($"Webhook was triggered!");
string jsonContent = await req.Content.ReadAsStringAsync();
dynamic data = JsonConvert.DeserializeObject(jsonContent);
if (data.first == null || data.last == null)
{
return req.CreateResponse(HttpStatusCode.BadRequest, new
{
error = "Please pass first/last properties in the input object"
});
}
return req.CreateResponse(HttpStatusCode.OK, new
{
greeting = $"Hello {data.first} {data.last}!"
});
}
Next, I created a project.json file to bring in the Kentico Kontent Delivery and Azure Search NuGet packages.
Any changes to the project.json file execute the NuGet package restore.
Add a Hash Generator
Every Kentico Kontent notification includes a system-generated hash signature in the header. This is to help you validate that the request came from Kentico Kontent. In my Azure Function, I created a new function to generate a hash to validate the notifications.
private static string GenerateHash(string message, string secret)
{
secret = secret ?? "";
var encoding = new System.Text.UTF8Encoding();
byte[] keyByte = encoding.GetBytes(secret);
byte[] messageBytes = encoding.GetBytes(message);
using (var hmacsha256 = new HMACSHA256(keyByte))
{
byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
return Convert.ToBase64String(hashmessage);
}
}
Review the JSON Post
The next step of the process was to validate the request. I read the X-KC-Signature header from the request.
// Get the signature for validation
IEnumerable<string> headerValues = req.Headers.GetValues("X-KC-Signature");
var sig = headerValues.FirstOrDefault();
Next, I read in the HttpRequestMessage.Content.
var content = req.Content;
string jsonContent = content.ReadAsStringAsync().Result;
I then called my GenerateHash function to validate the request. Note that I used the WebHookSecret value from the Kentico Kontent interface. This value gets created when the webhook is enabled within Kentico Kontent. For now, I used an ApplicationSetting value as a placeholder. I then compared the generated hash to the X-KC-Signature header value.
// Generate a hash using the content and the webhook secret
var hash = GenerateHash(jsonContent, ConfigurationManager.AppSettings["KenticoKontentWebhookSecret"]);
// Verify the notification is valid
if(sig != hash)
{
return req.CreateResponse(HttpStatusCode.Unauthorized, new
{
error = "Unauthorized!"
});
}
Next, I needed to read in my JSON data in the HttpRequestMessage. I leveraged some of the existing function code, as well as adding some JsonSerializerSettings values.
var settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
MissingMemberHandling = MissingMemberHandling.Ignore
};
dynamic data = JsonConvert.DeserializeObject(jsonContent, settings);
if (data == null)
{
return req.CreateResponse(HttpStatusCode.BadRequest, new
{
error = "Please pass data properties in the input object"
});
}
Next, I determined what type of operation had been completed in Kentico Kontent. I only wanted to process publish/unpublish actions, so I created a call to determine the action.
// Determine the operation
// Only process if it is publish or unpublish
string strOperation = data.message.operation.ToString().ToLower();
switch(strOperation)
{
case "publish":
blnValid = true;
blnPublish = true;
break;
case "unpublish":
blnValid = true;
blnPublish = false;
break;
}
Next, I looped through the items list to retrieve the affected content items.
// Make sure it's a valid operation
if(blnValid)
{
List<string> lstCodeNames = new List<string>();
foreach(var item in data.data.items)
{
lstCodeNames.Add(item.codename.ToString());
}
…
Once I had my list of updated content items, I was ready to process them.
Get Item Details
For each content item updated, I needed to retrieve the details to update my search index. I created a new UpdateIndex function, and created my DeliveryClient.
public async static Task UpdateIndex(List<string> lstCodeNames, TraceWriter log)
{
List<IndexAction> lstActions = new List<IndexAction>();
DeliveryClient client = new DeliveryClient(ConfigurationManager.AppSettings["SoltiswebProjectID"], ConfigurationManager.AppSettings["SoltiswebPreviewAPIKey"]);
// Loop through each updated content item
foreach(string codename in lstCodeNames)
{
…
Note that I am specifying the PreviewAPI as part of my client creation. Because the webhook will be called for publish and unpublish events, I always need to be able to retrieve the content item details.
Next, I called the Delivery API to retrieve the details, specifying the content item code name.
DeliveryItemResponse response = await client.GetItemAsync(codename);
If the call returned a result, I created a new Azure Search Index Action for the record. For published items, this meant updating my search index with the new data. For unpublished items, this meant removing the record from my index.
if(response != null)
{
var item = response.Item;
var doc = new Document();
log.Info(item.GetString("name"));
doc.Add("CodeName", item.System.Id);
doc.Add("Type", item.System.Type);
doc.Add("Name", item.GetString("name"));
doc.Add("PageAlias", item.GetString("pagealias"));
doc.Add("Location", item.GetString("eventlocation"));
doc.Add("Date", item.GetDateTime("date"));
if(blnPublish)
{
lstActions.Add(IndexAction.MergeOrUpload(doc));
}
else
{
lstActions.Add(IndexAction.Delete(doc));
}
}
This functionality is called for each content item in my list, ensuring that an Azure Search Index action is created for each item. These actions were added to my list of actions to send to my Azure Search service.
NOTE
In this blog, I am using a generic object type for each item updated. Another option would be to leverage the Kentico Kontent Code Generator project to create a strongly-typed class for each content item type. Because I am using an Azure Function, I elected to minimize the code and use a generic type. Depending on your implementation, you should consider using the code generators to take advantage of that functionality.
Update Azure Search
The next step was to update my Azure Search index with the actions. I created a new SearchServiceClient and ISearchIndexClient for my index. I then passed the list of SearchIndexActions to my ISearchIndexClient. I also added some messaging within the Azure Function to tell me how many documents were added/updated or deleted.
if(lstActions.Count > 0)
{
// Get the search client
SearchServiceClient serviceClient = new SearchServiceClient(ConfigurationManager.AppSettings["AzureSearchServiceName"], new SearchCredentials(ConfigurationManager.AppSettings["AzureSearchAPIKey"]));
ISearchIndexClient indexClient = serviceClient.Indexes.GetClient(ConfigurationManager.AppSettings["AzureSearchIndexName"]);
indexClient.Documents.Index(new IndexBatch(lstActions));
if(blnPublish)
{
log.Info(lstActions.Count.ToString() + " documents added/updated!");
}
else
{
log.Info(lstActions.Count.ToString() + " documents deleted!");
}
}
else
{
log.Info("No document updated!");
}
Enable Webhooks
The last step of the process was to tell Kentico Kontent about my new webhook. In my Azure Function, I copied the Function URL.
In the Kentico Kontent's Project Settings -> Webhooks section, I added a new webhook for my Azure Function, using the copied URL. I also copied the Secret value and updated my Azure Application Setting.
Test
With all the plumbing in place, I was ready to test. First, I queried my Azure Search Index to confirm the event was not part of the index.
Next, I accessed my Kentico Kontent project and created a new SpeakingEngagement.
With the event created, I published it to execute the webhook.
In my Azure Function, I confirmed the webhook was executed and my item was updated.
I then queried my index to confirm the content item was added.
Then, I unpublished my content item in Kentico Kontent.
In my Azure Function, I confirmed the item was processed.
Lastly, I queried my Azure Search Index to confirm the item was removed.
Here is the published event appearing in my search on my live site.
Moving Forward
As you can see, webhook support in Kentico Kontent is an extremely helpful functionality within the platform. By leveraging this capability, developers can automate their content update process within other systems effortlessly. This can lead to better performance, less code, and more streamlined applications. I encourage you to check out the full Kentico Kontent Webhook documentation to see what possibilities are available for your project. Good luck!
You can read more about Kentico Kontent Webhooks here.
Here is the complete Azure Function code:
#r "Newtonsoft.Json"
using System;
using System.Net;
using System.Text;
using Newtonsoft.Json;
using Kentico.Kontent.Delivery;
using Microsoft.Azure.Search;
using Microsoft.Azure.Search.Models;
using System.Configuration;
using System.Security.Cryptography;
private static bool blnValid = false;
private static bool blnPublish = false;
public static async Task<object> Run(HttpRequestMessage req, TraceWriter log)
{
System.Net.ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
// Get the signature for validation
IEnumerable<string> headerValues = req.Headers.GetValues("X-KC-Signature");
var sig = headerValues.FirstOrDefault();
// Get the content
var content = req.Content;
string jsonContent = content.ReadAsStringAsync().Result;
// Generate a hash using the content and the webhook secret
var hash = GenerateHash(jsonContent, ConfigurationManager.AppSettings["KenticoKontentWebhookSecret"]);
// Verify the notification is valid
if(sig != hash)
{
return req.CreateResponse(HttpStatusCode.Unauthorized, new
{
error = "Unauthorized!"
});
}
var settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
MissingMemberHandling = MissingMemberHandling.Ignore
};
dynamic data = JsonConvert.DeserializeObject(jsonContent, settings);
if (data == null)
{
return req.CreateResponse(HttpStatusCode.BadRequest, new
{
error = "Please pass data properties in the input object"
});
}
// Determine the operation
// Only process if it is publish or unpublish
string strOperation = data.message.operation.ToString().ToLower();
switch(strOperation)
{
case "publish":
blnValid = true;
blnPublish = true;
break;
case "unpublish":
blnValid = true;
blnPublish = false;
break;
}
// Make sure it's a valid operation
if(blnValid)
{
List<string> lstCodeNames = new List<string>();
foreach(var item in data.data.items)
{
lstCodeNames.Add(item.codename.ToString());
}
// Update the search index
if(lstCodeNames.Count > 0)
{
await UpdateIndex(lstCodeNames, log);
}
return req.CreateResponse(HttpStatusCode.OK, new
{
greeting = $"Success!"
});
}
else
{
return req.CreateResponse(HttpStatusCode.NotImplemented, new
{
greeting = $"Not Supported!"
});
}
}
public async static Task UpdateIndex(List<string> lstCodeNames, TraceWriter log)
{
List<IndexAction> lstActions = new List<IndexAction>();
DeliveryClient client = new DeliveryClient(ConfigurationManager.AppSettings["SoltiswebProjectID"], ConfigurationManager.AppSettings["SoltiswebPreviewAPIKey"]);
// Loop through each updated content item
foreach(string codename in lstCodeNames)
{
log.Info($"Processing " + codename);
// Get the details from Kentico Kontent
DeliveryItemResponse response = await client.GetItemAsync(codename);
if(response != null)
{
var item = response.Item;
var doc = new Document();
log.Info(item.GetString("name"));
doc.Add("CodeName", item.System.Id);
doc.Add("Type", item.System.Type);
doc.Add("Name", item.GetString("name"));
doc.Add("PageAlias", item.GetString("pagealias"));
doc.Add("Location", item.GetString("eventlocation"));
doc.Add("Date", item.GetDateTime("date"));
// Determine the index action
if(blnPublish)
{
lstActions.Add(IndexAction.MergeOrUpload(doc));
}
else
{
lstActions.Add(IndexAction.Delete(doc));
}
}
else
{
log.Info($"Item not found!");
}
}
try
{
if(lstActions.Count > 0)
{
// Get the search client
SearchServiceClient serviceClient = new SearchServiceClient(ConfigurationManager.AppSettings["AzureSearchServiceName"], new SearchCredentials(ConfigurationManager.AppSettings["AzureSearchAPIKey"]));
ISearchIndexClient indexClient = serviceClient.Indexes.GetClient(ConfigurationManager.AppSettings["AzureSearchIndexName"]);
indexClient.Documents.Index(new IndexBatch(lstActions));
if(blnPublish)
{
log.Info(lstActions.Count.ToString() + " documents added/updated!");
}
else
{
log.Info(lstActions.Count.ToString() + " documents deleted!");
}
}
else
{
log.Info("No document updated!");
}
}
catch (IndexBatchException e)
{
log.Info(e.Message);
}
}
private static string GenerateHash(string message, string secret)
{
secret = secret ?? "";
var encoding = new System.Text.UTF8Encoding();
byte[] keyByte = encoding.GetBytes(secret);
byte[] messageBytes = encoding.GetBytes(message);
using (var hmacsha256 = new HMACSHA256(keyByte))
{
byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
return Convert.ToBase64String(hashmessage);
}
}