Before looking out at the implementation of webhook models in SharePoint, here let us look at how Serverless web hooks concept can be feasible.
What is Serverless architecture? It allows us to build the required services, without the need of servers. Some Azure services like Azure functions helps building such Serverless models.
The components like webhook service, storage queues, web jobs and SQL database are hosted on Azure. The following picture depicts the Serverless webhooks process, with the help of Serverless components.
The components present on the Azure box, depicts serverless architecture model.
Required Components for Building Serverless SharePoint Webhooks:
To implement Serverless webhooks for SharePoint online events, the following are the required components.
SharePoint Online Subscription
Azure SubscriptionAD tenant
– For enabling permissionsAzure Functions
– Building the system to receive the notificationsAzure Webjobs
– To process the data stored on queuesAzure Storage
– Queues, Databases
Postman client– For sending the requests (request test tool on desktops)Â
In the previous post, we have seen theoretically how the events can be processed from SharePoint services.
The previous posts might help in understanding the webbook services better.
Process the Notifications sent by SharePoint service
The webhook service requires two components.
Create the Azure Function,
Create the Azure storage queue.
The Azure function acts as webhook service, which will process the notifications sent by SharePoint service. And the notifications will be pushed to the Azure storage queue.
The following code snippet depicts the above flow.
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using System;
using Newtonsoft.Json;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Queue;
using System.Collections.Generic;
namespace spschennai.spwebhooks
{
public static class webhookserviceforstoragequeue
{
[FunctionName("webhookserviceforstoragequeue")]
public static async Task<HttpResponseMessage> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)]HttpRequestMessage req, TraceWriter log)
{
log.Info($"Webhook was triggered!");
// Grab the validationToken URL parameter
string validationToken = req.GetQueryNameValuePairs()
.FirstOrDefault(q => string.Compare(q.Key, "validationtoken", true) == 0)
.Value;
// If a validation token is present, we need to respond within 5 seconds by
// returning the given validation token. This only happens when a new
// web hook is being added
if (validationToken != null)
{
log. Info($"Validation token {validationToken} received");
var response = req.CreateResponse(HttpStatusCode.OK);
response.Content = new StringContent(validationToken);
return response;
}
log. Info($"SharePoint triggered our webhook...great :-)");
var content = await req.Content.ReadAsStringAsync();
log. Info($"Received following payload: {content}");
var notifications = JsonConvert.DeserializeObject<ResponseModel<NotificationModel>>(content).Value; log. Info($"Found {notifications.Count} notifications");
if (notifications.Count > 0)
{
log. Info($"Processing notifications...");
foreach (var notification in notifications)
{
CloudStorageAccount storageAccount = CloudStorageAccount.Parse("Azure Storage Account Connection String");
// Get queue... create if does not exist.
CloudQueueClient queueClient = storageAccount.CreateCloudQueueClient();
CloudQueue queue = queueClient.GetQueueReference("webhooksdata");
queue.CreateIfNotExists();
// add message to the queue
string message = JsonConvert.SerializeObject(notification);
log. Info($"Before adding a message to the queue. Message content: {message}"); queue.AddMessage(new CloudQueueMessage(message));
log. Info($"Message added :-)");
} }
// if we get here we assume the request was well received
return newHttpResponseMessage(HttpStatusCode.OK);
}
}
// supporting classes public class ResponseModel<T>
{
[JsonProperty(PropertyName = "value")]
public List<T> Value { get; set; }
}
public class NotificationModel
{
[JsonProperty(PropertyName = "subscriptionId")]
public string SubscriptionId { get; set; }
[JsonProperty(PropertyName = "clientState")]
public string ClientState { get; set; }
[JsonProperty(PropertyName = "expirationDateTime")]
public DateTime ExpirationDateTime { get; set; }
[JsonProperty(PropertyName = "resource")]
public string Resource { get; set; }
[JsonProperty(PropertyName = "tenantId")]
public string TenantId { get; set; }
[JsonProperty(PropertyName = "siteUrl")]
public string SiteUrl { get; set; }
[JsonProperty(PropertyName = "webId")]
public string WebId { get; set; }
}
public class SubscriptionModel
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty(PropertyName = "clientState", NullValueHandling = NullValueHandling.Ignore)]
public string ClientState { get; set; }
[JsonProperty(PropertyName = "expirationDateTime")]
public DateTime ExpirationDateTime { get; set; }
[JsonProperty(PropertyName = "notificationUrl")]
public string NotificationUrl { get; set; }
[JsonProperty(PropertyName = "resource", NullValueHandling = NullValueHandling.Ignore)]
public string Resource
{
get; set;
}
}
}
Subscribing to the Event Notifications
To access the SharePoint list item for subscription or from services related to webhooks, authentication token has to be generated from the Azure AD.Â
Register the application on Azure AD, with the necessary reply URL and providing read/write permissions on the SharePoint lists.
Or you need to hardcode the credentials for accessing the SharePoint list data.Â
We will go with the first option. So before hitting the subscription post query, we need to have the access token generated for oAuth authentications. Postman tool can be used for subscribing to the events. This step will help registering the webhook service (built in the previous step) on the SharePoint list for event changes.
Paste the subscription URL of the list and select the action as POST.https://nakkeerann.sharepoint.com/_api/web/lists('9AC9CF26-55E2-46DE-96B7-310714417132')/subscriptions
Get the access token, with the following parameters passed.
Auth URL - https://login.microsoftonline.com/common/oauth2/authorize?resource=https://nakkeerann.sharepoint.com
Access Token URL - https://login.microsoftonline.com/common/oauth2/token
Client ID and Client Secret – Copied from the Azure AD APP registered before.
Grant Type – Authorization code
Once you have the oAuth token, use it for subscription. Then using the post request with the following parameters, register the webhook service.
Headers – Accept : application/json;odata=nometadata
Content-Type : application/json
Request Body will contain theÂ
- Resource URL
- Notification URL
- Expiration date time andÂ
- Client state (optional).
{Â
"resource": "https://nakkeerann.sharepoint.com/_api/web/lists('9AC9CF26-55E2-46DE-96B7-310714417132')",Â
"notificationUrl": "https://azurefunctionurl/api/webhookservice",Â
"expirationDateTime": "2018-04-27T12:11:37+00:00",Â
"clientState": "GUID"Â Â Â Â Â Â Â
}
Once the request is posted using postman tool,Â
The SharePoint will send a request with the validation token to webhook service built using Azure functions.Â
The webhook service should respond back with the valid token within 5 seconds.Â
If that happens, SharePoint will respond back to postman tool with a success message.Â
This flow ensures the registration of webhook service on SharePoint list. The response will contain
Subscription ID
Expiration date
Resource ID
Tenant ID
Site URL andÂ
Web ID.
Processing the notifications using WebJobs
Next, let us see how to process the notifications present on Azure storage queues and extract the change information from the respective lists. Then the changes will be saved on SharePoint lists as history or logs.
The following code snippet shows the webjob which will process the notifications present on Azure storage queue and save the changes into the logs list present in the SharePoint.
using System;
using System.Collections.Generic;u
sing System. IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.SharePoint.Client;
using Microsoft. WindowsAzure. Storage;
using Microsoft.WindowsAzure.Storage.Table;
using Newtonsoft.Json;
namespace spschennai.spwebhooks.jobs
{
public class Functions
{
// This function will get triggered/executed when a new message is written
// on an Azure Queue called queue.
public static void ProcessQueueMessage([QueueTrigger("webhooksdata")] string message, TextWriter log)
{
log.WriteLine("Webjob Starts! ");
log.WriteLine(message);
Console.WriteLine("Webjob start " + message + " ends");
processMessage(message, log); log.WriteLine("Webjob Ends! ");
}
private static void processMessage(string message, TextWriter log)
{
var notification = JsonConvert.DeserializeObject<NotificationModel>(message); Console.WriteLine($"Found {notification.Resource} notifications");
# region usercreds
string siteUrl = "https://nakkeerann.sharepoint.com/";
string userName = "abc@nakkeerann.onmicrosoft.com";
string password = "password";
# endregion
OfficeDevPnP.Core.AuthenticationManager authManager = new OfficeDevPnP.Core.AuthenticationManager();
Console.WriteLine($"Processing notifications...");
var clientContext = authManager.GetSharePointOnlineAuthenticatedContextTenant(siteUrl, userName, password);
try
{
ListCollection lists = clientContext.Web.Lists;
Guid listId = new Guid(notification.Resource);
IEnumerable<List> results = clientContext.LoadQuery<List>(lists.Where(lst => lst.Id == listId)); clientContext.ExecuteQueryRetry();
List changeList = results.FirstOrDefault();
// Logs or history list
List historyList = clientContext.Web.GetListByTitle("SPSWebHookQueueLogHistory");
if (historyList == null)
{
// Create if it doesn't exist
historyList = clientContext.Web.CreateList(ListTemplateType.GenericList, "SPSWebHookQueueLogHistory", false);
}
// Query to retrieve the changes
ChangeQuery changeQuery = new ChangeQuery(false, true); changeQuery.Item = true; changeQuery.FetchLimit = 1000;
ChangeToken lastChangeToken = null;
Guid id = new Guid(notification.SubscriptionId); Console.WriteLine("Subscription ID: " + notification.SubscriptionId);
// Get the change token from the Azure storage (in this case Azure Tables)
var lastChangeTokenValue = GetLastChangeToken(id);
if(lastChangeTokenValue != null)
{
lastChangeToken = new ChangeToken();
lastChangeToken.StringValue = lastChangeTokenValue;
}
// Get the changes
bool allChangesRead = false; do
{
if (lastChangeToken == null)
{
// If change token is not present on the Azure table, build the token for getting last minute changes lastChangeToken = new ChangeToken();
lastChangeToken.StringValue = string.Format("1;3;{0};{1};-1", notification.Resource, DateTime.Now.AddMinutes(-1).ToUniversalTime().Ticks.ToString());
}
Console.WriteLine(lastChangeToken.StringValue);
// Change token
changeQuery.ChangeTokenStart = lastChangeToken;
// Execute the change query
var changes = changeList.GetChanges(changeQuery);
clientContext.Load(changes);
clientContext.ExecuteQueryRetry();
// Process the changes if (changes.Count > 0)
{
foreach (Change change in changes)
{
lastChangeToken = change.ChangeToken;
Console.WriteLine(lastChangeToken.StringValue);
if (change is ChangeItem)
{
// Add the changes to history list on SharePoint
ListItemCreationInformation newItem = new ListItemCreationInformation();
ListItem item = historyList.AddItem(newItem);
item["Title"] = string.Format("List {0} had a Change of type {1} on the item with Id {2}.", changeList.Title, change.ChangeType.ToString(), (change as ChangeItem).ItemId);
item.Update();
clientContext.ExecuteQueryRetry();
}
}
if (changes.Count < changeQuery.FetchLimit)
{
allChangesRead = true;
} }
else
{
allChangesRead = true;
}
// Are we done?
}
while (allChangesRead == false);
UpdateLastChangeToken(id, lastChangeToken.StringValue);
}
catch (Exception ex)
{
// Log error
Console.WriteLine(ex.ToString());
}
finally
{
if (clientContext != null)
{
clientContext.Dispose();
} }
Console.WriteLine("All Done");
}
private static CloudTable GetorCreateTable()
{
// Get or creates Azure Table for storing the change tokens
// Retrieve the storage account from the connection string.
CloudStorageAccount storageAccount = CloudStorageAccount.Parse("Azure Storage Account Connection String");
// Create the table client. CloudTable
Client tableClient = storageAccount.CreateCloudTableClient();
// Retrieve a reference to the table.
CloudTable table = tableClient.GetTableReference("webhookchangetokens");
// Create the table if it doesn't exist. table.
CreateIfNotExists(); return table;
}
private static void UpdateLastChangeToken(Guid id, string lastChangeTokenValue)
{
// Persist or Save the last change token value into Azure Table
CloudTable tokenTable = GetorCreateTable();
TableOperation retrieveOperation = TableOperation.Retrieve<CustomerEntity>(Convert.ToString(id), "tokenchanges");
TableResult retrievedResult = tokenTable.Execute(retrieveOperation);
CustomerEntity entity = (CustomerEntity)retrievedResult.Result;
if (entity != null)
{
//update
Console.WriteLine(entity.LastChangeToken + " == " + lastChangeTokenValue);
if (!entity.LastChangeToken.Equals(lastChangeTokenValue, StringComparison.InvariantCultureIgnoreCase))
{
entity.LastChangeToken = lastChangeTokenValue;
TableOperation updateOperation = TableOperation.Replace(entity); tokenTable.Execute(updateOperation);
Console.WriteLine("Table updated with lastChangeTokenValue UPDATE");
}
}
else
{
//add
CustomerEntity newEntity = new CustomerEntity(Convert.ToString(id), "tokenchanges"); newEntity.LastChangeToken = lastChangeTokenValue;
TableOperation newOperation = TableOperation.Insert(newEntity);
tokenTable.Execute(newOperation);
Console.WriteLine("Table updated with lastChangeTokenValue ADD");
}
}
private static string GetLastChangeToken(Guid id)
{
// Get the change token from Azure Table
CloudTable tokenTable = GetorCreateTable();
TableQuery<CustomerEntity> tableQuery = new TableQuery<CustomerEntity>().Where(TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, Convert.ToString(id)));
tableQuery.TakeCount = 1;
Console.WriteLine("Before execution");
var entities = tokenTable.ExecuteQuery(tableQuery);
string lastChangeToken = null;
if (entities.Count() > 0)
{
CustomerEntity entity = null;
entity = entities.ElementAt(0);
lastChangeToken = entity.LastChangeToken;
}
Console.WriteLine("After execution");
Console.WriteLine(lastChangeToken);
return lastChangeToken;
}
}
// supporting classes
public class CustomerEntity : TableEntity
{
public CustomerEntity(string lastName, string firstName)
{
this.PartitionKey = lastName;
this.RowKey = firstName;
}
public CustomerEntity() { }
public string LastChangeToken { get; set; }
}
public class ResponseModel<T>
{
[JsonProperty(PropertyName = "value")]
public List<T> Value { get; set; }
}
public class NotificationModel
{
[JsonProperty(PropertyName = "subscriptionId")]
public string SubscriptionId { get; set; }
[JsonProperty(PropertyName = "clientState")]
public string ClientState { get; set; }
[JsonProperty(PropertyName = "expirationDateTime")]
public DateTime ExpirationDateTime { get; set; }
[JsonProperty(PropertyName = "resource")]
public string Resource { get; set; }
[JsonProperty(PropertyName = "tenantId")]
public string TenantId { get; set; }
[JsonProperty(PropertyName = "siteUrl")]
public string SiteUrl { get; set; }
[JsonProperty(PropertyName = "webId")]
public string WebId { get; set; }
}
public class SubscriptionModel
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty(PropertyName = "clientState", NullValueHandling = NullValueHandling.Ignore)]
public string ClientState { get; set; }
[JsonProperty(PropertyName = "expirationDateTime")]
public DateTime ExpirationDateTime { get; set; }
[JsonProperty(PropertyName = "notificationUrl")]
public string NotificationUrl { get; set; }
[JsonProperty(PropertyName = "resource", NullValueHandling = NullValueHandling.Ignore)]
public string Resource { get; set; }
}} The following picture shows the webhook service notification changes being saved on SharePoint.
Comments