diff --git a/Core/OfficeDevPnP.Core.Tests/App.config.sample b/Core/OfficeDevPnP.Core.Tests/App.config.sample index 2c7db283f3..cf997c35f6 100644 --- a/Core/OfficeDevPnP.Core.Tests/App.config.sample +++ b/Core/OfficeDevPnP.Core.Tests/App.config.sample @@ -61,7 +61,7 @@ - + @@ -76,6 +76,9 @@ + + + diff --git a/Core/OfficeDevPnP.Core.Tests/Extensions/ListExtensionsTests.cs b/Core/OfficeDevPnP.Core.Tests/Extensions/ListExtensionsTests.cs index 327a2e0798..47ab519d00 100644 --- a/Core/OfficeDevPnP.Core.Tests/Extensions/ListExtensionsTests.cs +++ b/Core/OfficeDevPnP.Core.Tests/Extensions/ListExtensionsTests.cs @@ -62,7 +62,7 @@ public void Initialize() // List - _textFieldId = Guid.NewGuid(); + _textFieldId = Guid.NewGuid(); var fieldCI = new FieldCreationInformation(FieldType.Text) { @@ -240,7 +240,7 @@ public void ListExistsByUrlPathParamTest() false); Assert.IsNotNull(list); - Assert.IsTrue(clientContext.Web.ListExists(new Uri(siteRelativePath,UriKind.Relative))); + Assert.IsTrue(clientContext.Web.ListExists(new Uri(siteRelativePath, UriKind.Relative))); //Delete List list.DeleteObject(); @@ -306,5 +306,104 @@ public void SetDefaultColumnValuesTest() } #endregion + #region Webhooks tests + [TestMethod] + public void SubscribeWebhookTest() + { + using (var clientContext = TestCommon.CreateClientContext()) + { + var testList = clientContext.Web.Lists.GetById(_listId); + clientContext.Load(testList); + clientContext.ExecuteQueryRetry(); + + WebhookSubscription expectedSubscription = new WebhookSubscription() + { + ExpirationDateTime = DateTime.Today.AddMonths(3), + NotificationUrl = TestCommon.TestWebhookUrl, + Resource = TestCommon.DevSiteUrl + string.Format("/_api/lists('{0}')", _listId) + }; + WebhookSubscription actualSubscription = testList.AddWebhookSubscription(TestCommon.TestWebhookUrl, 3); + + // Compare properties of expected and actual + Assert.IsTrue(Equals(expectedSubscription.ClientState, actualSubscription.ClientState) + && Equals(expectedSubscription.ExpirationDateTime, actualSubscription.ExpirationDateTime) + && Equals(expectedSubscription.NotificationUrl, actualSubscription.NotificationUrl) + && Equals(expectedSubscription.Resource, actualSubscription.Resource)); + } + } + + [TestMethod] + public void UnsubscribeWebhookTestFromGuid() + { + using (var clientContext = TestCommon.CreateClientContext()) + { + var testList = clientContext.Web.Lists.GetById(_listId); + clientContext.Load(testList); + clientContext.ExecuteQueryRetry(); + + + WebhookSubscription actualSubscription = testList.AddWebhookSubscription(TestCommon.TestWebhookUrl, 3); + + bool result = testList.RemoveWebhookSubscription(Guid.Parse(actualSubscription.Id)); + + Assert.IsTrue(result); + } + } + + [TestMethod] + public void UnsubscribeWebhookTestFromEntity() + { + using (var clientContext = TestCommon.CreateClientContext()) + { + var testList = clientContext.Web.Lists.GetById(_listId); + clientContext.Load(testList); + clientContext.ExecuteQueryRetry(); + + + WebhookSubscription actualSubscription = testList.AddWebhookSubscription(TestCommon.TestWebhookUrl, 3); + + bool result = testList.RemoveWebhookSubscription(actualSubscription); + + Assert.IsTrue(result); + } + } + + [TestMethod] + public void UnsubscribeWebhookTestFromString() + { + using (var clientContext = TestCommon.CreateClientContext()) + { + var testList = clientContext.Web.Lists.GetById(_listId); + clientContext.Load(testList); + clientContext.ExecuteQueryRetry(); + + + WebhookSubscription actualSubscription = testList.AddWebhookSubscription(TestCommon.TestWebhookUrl, 3); + + bool result = testList.RemoveWebhookSubscription(actualSubscription.Id); + + Assert.IsTrue(result); + } + } + + [TestMethod] + public void GetAllWebhookSubscriptionsTest() + { + using (var clientContext = TestCommon.CreateClientContext()) + { + var testList = clientContext.Web.Lists.GetById(_listId); + clientContext.Load(testList); + clientContext.ExecuteQueryRetry(); + + + WebhookSubscription createdSubscription = testList.AddWebhookSubscription(TestCommon.TestWebhookUrl, 3); + + IList actualSubscriptions = testList.GetAllWebhookSubscriptions(); + + Assert.IsTrue(actualSubscriptions.Any(s => s == createdSubscription)); + } + } + #endregion + } } diff --git a/Core/OfficeDevPnP.Core.Tests/TestCommon.cs b/Core/OfficeDevPnP.Core.Tests/TestCommon.cs index 05a750150c..af43f8f643 100644 --- a/Core/OfficeDevPnP.Core.Tests/TestCommon.cs +++ b/Core/OfficeDevPnP.Core.Tests/TestCommon.cs @@ -18,6 +18,7 @@ static TestCommon() // Read configuration data TenantUrl = ConfigurationManager.AppSettings["SPOTenantUrl"]; DevSiteUrl = ConfigurationManager.AppSettings["SPODevSiteUrl"]; + TestWebhookUrl = ConfigurationManager.AppSettings["TestWebhookUrl"]; #if !ONPREMISES if (string.IsNullOrEmpty(TenantUrl)) @@ -30,6 +31,8 @@ static TestCommon() throw new ConfigurationErrorsException("Dev site url in App.config are not set up."); } + + // Trim trailing slashes TenantUrl = TenantUrl.TrimEnd(new[] { '/' }); DevSiteUrl = DevSiteUrl.TrimEnd(new[] { '/' }); @@ -146,6 +149,8 @@ static TestCommon() /// public static string HighTrustCertificateStoreThumbprint { get; set; } + public static string TestWebhookUrl { get; set; } + public static String AzureStorageKey { get @@ -188,9 +193,9 @@ public static String ScriptSite return ConfigurationManager.AppSettings["ScriptSite"]; } } -#endregion + #endregion -#region Methods + #region Methods public static ClientContext CreateClientContext() { return CreateContext(DevSiteUrl, Credentials); @@ -291,7 +296,7 @@ private static ClientContext CreateContext(string contextUrl, ICredentials crede if (new Uri(DevSiteUrl).DnsSafeHost.Contains("spoppe.com")) { - context = am.GetAppOnlyAuthenticatedContext(contextUrl, Core.Utilities.TokenHelper.GetRealmFromTargetUrl(new Uri(DevSiteUrl)), AppId, AppSecret, acsHostUrl: "windows-ppe.net", globalEndPointPrefix:"login"); + context = am.GetAppOnlyAuthenticatedContext(contextUrl, Core.Utilities.TokenHelper.GetRealmFromTargetUrl(new Uri(DevSiteUrl)), AppId, AppSecret, acsHostUrl: "windows-ppe.net", globalEndPointPrefix: "login"); } else { @@ -319,6 +324,6 @@ private static SecureString GetSecureString(string input) return secureString; } -#endregion + #endregion } } diff --git a/Core/OfficeDevPnP.Core/Entities/WebhookSubscription.cs b/Core/OfficeDevPnP.Core/Entities/WebhookSubscription.cs new file mode 100644 index 0000000000..7537f2e05c --- /dev/null +++ b/Core/OfficeDevPnP.Core/Entities/WebhookSubscription.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace OfficeDevPnP.Core.Entities +{ + /// + /// Represents the payload of a Http message + /// + public class WebhookSubscription + { + [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; } + + } +} diff --git a/Core/OfficeDevPnP.Core/Extensions/ListExtensions.cs b/Core/OfficeDevPnP.Core/Extensions/ListExtensions.cs index 146a82389c..8b685566c2 100644 --- a/Core/OfficeDevPnP.Core/Extensions/ListExtensions.cs +++ b/Core/OfficeDevPnP.Core/Extensions/ListExtensions.cs @@ -14,6 +14,8 @@ using OfficeDevPnP.Core.Diagnostics; using OfficeDevPnP.Core.Utilities; using System.Text.RegularExpressions; +using System.Threading.Tasks; +using OfficeDevPnP.Core.Utilities.Webhooks; namespace Microsoft.SharePoint.Client { @@ -138,6 +140,115 @@ in list.EventReceivers #endregion + #region Webhooks + /// + /// Add the a Webhook subscription to a list + /// Note: If the access token is not specified, it will cost a dummy request to retrieve it + /// + /// The list to add a Webhook subscription to + /// The Webhook endpoint URL + /// The expiration date of the subscription + /// The client state to use in the Webhook subscription + /// (optional) The access token to SharePoint + /// The added subscription object + public static WebhookSubscription AddWebhookSubscription(this List list, string notificationUrl, + DateTime expirationDate, string clientState = null, string accessToken = null) + { + // Get the access from the client context if not specified. + accessToken = accessToken ?? Utility.GetAccessTokenFromClientContext(list.Context); + + return WebhookUtility.AddWebhookSubscriptionAsync(list.Context.Url, + EHookableResourceType.List, accessToken, new WebhookSubscription() + { + Resource = list.Id.ToString(), + ExpirationDateTime = expirationDate, + NotificationUrl = notificationUrl, + ClientState = clientState + }).Result; + } + + /// + /// Add the a Webhook subscription to a list + /// Note: If the access token is not specified, it will cost a dummy request to retrieve it + /// + /// The list to add a Webhook subscription to + /// The Webhook endpoint URL + /// The validity of the subscriptions in months + /// The client state to use in the Webhook subscription + /// (optional) The access token to SharePoint + /// The added subscription object + public static WebhookSubscription AddWebhookSubscription(this List list, string notificationUrl, + int validityInMonths = 3, string clientState = null, string accessToken = null) + { + // Get the access from the client context if not specified. + accessToken = accessToken ?? Utility.GetAccessTokenFromClientContext(list.Context); + + return WebhookUtility.AddWebhookSubscriptionAsync(list.Context.Url, + EHookableResourceType.List, accessToken, list.Id.ToString(), + notificationUrl, clientState, validityInMonths).Result; + } + + /// + /// Remove a Webhook subscription from the list + /// Note: If the access token is not specified, it will cost a dummy request to retrieve it + /// + /// The list to remove the Webhook subscription from + /// The id of the subscription to remove + /// (optional) The access token to SharePoint + /// true if the removal succeeded, false otherwise + public static bool RemoveWebhookSubscription(this List list, string subscriptionId, string accessToken = null) + { + // Get the access from the client context if not specified. + accessToken = accessToken ?? Utility.GetAccessTokenFromClientContext(list.Context); + + return WebhookUtility.DeleteWebhookSubscriptionAsync(list.Context.Url, EHookableResourceType.List, list.Id.ToString(), + subscriptionId, accessToken).Result; + } + + /// + /// Remove a Webhook subscription from the list + /// Note: If the access token is not specified, it will cost a dummy request to retrieve it + /// + /// The list to remove the Webhook subscription from + /// The id of the subscription to remove + /// (optional) The access token to SharePoint + /// true if the removal succeeded, false otherwise + public static bool RemoveWebhookSubscription(this List list, Guid subscriptionId, string accessToken = null) + { + return RemoveWebhookSubscription(list, subscriptionId.ToString(), accessToken); + } + + /// + /// Remove a Webhook subscription from the list + /// Note: If the access token is not specified, it will cost a dummy request to retrieve it + /// + /// The list to remove the Webhook subscription from + /// The subscription to remove + /// (optional) The access token to SharePoint + /// true if the removal succeeded, false otherwise + public static bool RemoveWebhookSubscription(this List list, WebhookSubscription subscription, string accessToken = null) + { + return RemoveWebhookSubscription(list, subscription.Id, accessToken); + } + + /// + /// Get all the existing Webhooks subscriptions of the list + /// Note: If the access token is not specified, it will cost a dummy request to retrieve it + /// + /// The list to get the subscriptions of + /// (optional) The access token to SharePoint + /// The collection of Webhooks subscriptions of the list + public static IList GetAllWebhookSubscriptions(this List list, string accessToken = null) + { + // Get the access from the client context if not specified. + accessToken = accessToken ?? Utility.GetAccessTokenFromClientContext(list.Context); + + return WebhookUtility.GetWebhooksSubscriptionsAsync(list.Context.Url, + EHookableResourceType.List, list.Id.ToString(), accessToken).Result.Value; + } + + #endregion + #region List Properties /// @@ -615,7 +726,7 @@ private static void SetJSLinkCustomizationsImplementation(List list, File file, var wp = wpd.WebPart; if (wp.Properties.FieldValues.Keys.Contains("JSLink")) - { + { wp.Properties["JSLink"] = jslink; wpd.SaveWebPartChanges(); @@ -903,7 +1014,7 @@ public static void SetListPermission(this List list, Principal principal, RoleTy // Get role type var roleDefinition = web.RoleDefinitions.GetByType(roleType); - var rdbColl = new RoleDefinitionBindingCollection(web.Context) {roleDefinition}; + var rdbColl = new RoleDefinitionBindingCollection(web.Context) { roleDefinition }; // Set custom permission to the list list.RoleAssignments.Add(principal, rdbColl); diff --git a/Core/OfficeDevPnP.Core/OfficeDevPnP.Core.csproj b/Core/OfficeDevPnP.Core/OfficeDevPnP.Core.csproj index 40412af2da..a31fb4f555 100644 --- a/Core/OfficeDevPnP.Core/OfficeDevPnP.Core.csproj +++ b/Core/OfficeDevPnP.Core/OfficeDevPnP.Core.csproj @@ -378,6 +378,7 @@ + @@ -830,6 +831,8 @@ + + diff --git a/Core/OfficeDevPnP.Core/Utilities/Utility.cs b/Core/OfficeDevPnP.Core/Utilities/Utility.cs index 6e22b4007d..a4448e4cf5 100644 --- a/Core/OfficeDevPnP.Core/Utilities/Utility.cs +++ b/Core/OfficeDevPnP.Core/Utilities/Utility.cs @@ -7,8 +7,19 @@ namespace OfficeDevPnP.Core.Utilities { public static partial class Utility { - - + + public static string GetAccessTokenFromClientContext(ClientRuntimeContext clientContext) + { + string accessToken = null; + // Issue a dummy request to get it from the Authorization header + clientContext.ExecutingWebRequest += (s, e) => + { + string authorization = e.WebRequestExecutor.RequestHeaders["Authorization"]; + accessToken = authorization.Replace("Bearer ", string.Empty); + }; + clientContext.ExecuteQueryRetry(); + return accessToken; + } /// /// Returns the healthscore for a SharePoint Server diff --git a/Core/OfficeDevPnP.Core/Utilities/Webhooks/ResponseModel.cs b/Core/OfficeDevPnP.Core/Utilities/Webhooks/ResponseModel.cs new file mode 100644 index 0000000000..56eb85d503 --- /dev/null +++ b/Core/OfficeDevPnP.Core/Utilities/Webhooks/ResponseModel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace OfficeDevPnP.Core.Utilities.Webhooks +{ + + /// + /// + /// + internal class ResponseModel + { + + [JsonProperty(PropertyName = "value")] + public List Value { get; set; } + } +} diff --git a/Core/OfficeDevPnP.Core/Utilities/Webhooks/WebhookUtility.cs b/Core/OfficeDevPnP.Core/Utilities/Webhooks/WebhookUtility.cs new file mode 100644 index 0000000000..c5a173fb29 --- /dev/null +++ b/Core/OfficeDevPnP.Core/Utilities/Webhooks/WebhookUtility.cs @@ -0,0 +1,210 @@ +using Newtonsoft.Json; +using OfficeDevPnP.Core.Entities; +using OfficeDevPnP.Core.Utilities.Webhooks; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; + +namespace OfficeDevPnP.Core.Utilities +{ + /// + /// The list of Hookable Resource T^ypes + /// + public enum EHookableResourceType + { + List, + // TODO Implement with upcoming support of other types + //Site, + //... + } + + /// + /// Class containing utility methods to manage Webhook on a SharePoint resource + /// Adapted from https://github.com/SharePoint/sp-dev-samples/blob/master/Samples/WebHooks.List/SharePoint.WebHooks.Common/WebHookManager.cs + /// + internal class WebhookUtility + { + + private const string SubscriptionsUrlPart = "subscriptions"; + private const string ListIdentifierFormat = @"{0}/_api/web/lists('{1}')"; + // TODO Implement with upcoming support of other types + //private const string WebIdentifierFormat = @"{0}/_api/web('{1}')"; + + /// + /// Add a Webhook subscription to a SharePoint resource + /// + /// Url of the site holding the list + /// The type of Hookable SharePoint resource + /// Access token to authenticate against SharePoint + /// The Webhook subscription to add + /// The added subscription object + public static async Task AddWebhookSubscriptionAsync(string webUrl, + EHookableResourceType resourceType, + string accessToken, WebhookSubscription subscription) + { + string responseString = null; + using (var httpClient = new HttpClient()) + { + string identifierUrl = GetResourceIdentifier(resourceType, webUrl, subscription.Resource); + if (string.IsNullOrEmpty(identifierUrl)) + throw new Exception("Identifier of the resource cannot be determined"); + + string requestUrl = identifierUrl + "/" + SubscriptionsUrlPart; + + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + request.Content = new StringContent(JsonConvert.SerializeObject(subscription), + Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + responseString = await response.Content.ReadAsStringAsync(); + } + else + { + // Something went wrong... + throw new Exception(await response.Content.ReadAsStringAsync()); + } + } + + return JsonConvert.DeserializeObject(responseString); + } + + /// + /// Add a Webhook subscription to a SharePoint resource + /// + /// Url of the site holding the list + /// The type of Hookable SharePoint resource + /// Access token to authenticate against SharePoint + /// The Unique Identifier of the resource + /// The Webhook endpoint URL + /// The client state to use in the Webhook subscription + /// The validity of the subscriptions in months + /// The added subscription object + public static async Task AddWebhookSubscriptionAsync(string webUrl, + EHookableResourceType resourceType, string accessToken, + string resourceId, string notificationUrl, string clientState = null, int validityInMonths = 3) + { + var subscription = new WebhookSubscription() + { + Resource = resourceId, + NotificationUrl = notificationUrl, + ExpirationDateTime = DateTime.Now.AddMonths(validityInMonths).ToUniversalTime(), + ClientState = clientState + }; + + return await AddWebhookSubscriptionAsync(webUrl, resourceType, accessToken, subscription); + } + + + /// + /// Deletes an existing SharePoint list web hook + /// + /// Url of the site holding the list + /// The type of Hookable SharePoint resource + /// Id of the list + /// Id of the web hook subscription that we need to delete + /// Access token to authenticate against SharePoint + /// true if succesful, exception in case something went wrong + public static async Task DeleteWebhookSubscriptionAsync(string webUrl, EHookableResourceType resourceType, + string resourceId, string subscriptionId, string accessToken) + { + using (var httpClient = new HttpClient()) + { + string identifierUrl = GetResourceIdentifier(resourceType, webUrl, resourceId); + if (string.IsNullOrEmpty(identifierUrl)) + throw new Exception("Identifier of the resource cannot be determined"); + + string requestUrl = string.Format("{0}/{1}('{2}')", identifierUrl, SubscriptionsUrlPart, subscriptionId); + + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, requestUrl); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + HttpResponseMessage response = await httpClient.SendAsync(request); + + if (response.StatusCode != System.Net.HttpStatusCode.NoContent) + { + // oops...something went wrong, maybe the web hook does not exist? + throw new Exception(await response.Content.ReadAsStringAsync()); + } + else + { + return true; + } + } + } + + /// + /// Get all webhook subscriptions on a given SharePoint resource + /// + /// Url of the site holding the list + /// The type of Hookable SharePoint resource + /// The Unique Identifier of the resource + /// Access token to authenticate against SharePoint + /// Collection of instances, one per returned web hook + public static async Task> GetWebhooksSubscriptionsAsync(string webUrl, EHookableResourceType resourceType, string resourceId, string accessToken) + { + string responseString = null; + using (var httpClient = new HttpClient()) + { + string identifierUrl = GetResourceIdentifier(resourceType, webUrl, resourceId); + if (string.IsNullOrEmpty(identifierUrl)) + throw new Exception("Identifier of the resource cannot be determined"); + + string requestUrl = identifierUrl + "/" + SubscriptionsUrlPart; + + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + HttpResponseMessage response = await httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + responseString = await response.Content.ReadAsStringAsync(); + } + else + { + // oops...something went wrong + throw new Exception(await response.Content.ReadAsStringAsync()); + } + } + + return JsonConvert.DeserializeObject>(responseString); + } + + /// + /// Get the proper identifier Url according to the resource type + /// (No great utility currently with the support for lists only, + /// but will be later with the support for other resources) + /// + /// The type of resource + /// The URL of the SharePoint web + /// The id part of the resource + /// The well forned resource identifier URL + private static string GetResourceIdentifier(EHookableResourceType resourceType, string webUrl, string id) + { + switch (resourceType) + { + case EHookableResourceType.List: + return string.Format(ListIdentifierFormat, webUrl, id); + //case EHookableResourceType.Site: + default: + return null; + } + } + } + + + + +}