Blog / Development

Using an Azure Function Webhook with Kentico Cloud


by Bryan Soltis

Aug 16, 2017

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. With Kentico Cloud’s new webhook support, the platform just got a lot more attractive to the development community. In this article, I’ll show you an example of how to use this new capability 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 6 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 Cloud, 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 Cloud’s newly announced webhook support, your worries are over!

Don’t believe me? Let me show you how I used this new functionality to automate my Azure Search index updates with webhooks and Kentico Cloud.

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 Cloud 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 Cloud 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 KenticoCloud.Delivery and Azure Search NuGet packages.


Any changes to the project.json file executes the NuGet package restore.

Add a hash generator

Every Kentico Cloud notification includes a system-generated hash signature in the header. This is to help you validate that the request came from Kentico Cloud. 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 Cloud interface. This value gets created when the webhook is enabled within Kentico Cloud. 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["KenticoCloudWebhookSecret"]);

    // 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 Cloud. 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 Cloud 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 Cloud about my new webhook. In my Azure Function, I copied the Function URL.


 In the Kentico Cloud Webhooks utility, 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 Cloud 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 Cloud.


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 Cloud is an extremely powerful addition to 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 to check out the full Kentico Cloud WebHook documentation to see what possibilities are available for your project. Good luck!

You can read more about Kentico Cloud Webhooks here.

Here is the complete Azure Function code.

#r "Newtonsoft.Json"

using System;
using System.Net;
using System.Text;
using Newtonsoft.Json;
using KenticoCloud.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["KenticoCloudWebhookSecret"]);

    // 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 Cloud
        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);
    }
}