Showing posts with label SharePoint. Show all posts
Showing posts with label SharePoint. Show all posts

Friday, October 14, 2022

Getting Started with SharePoint Syntex in 5 Minutes

 


SharePoint is a tool that empowers business users to setup and design sites with little or no code knowledge. Using tools like SharePoint Designer to create workflows, or the new Power Automate and Power Apps tools, the barriers for creating robust applications have been removed. With everyone looking to incorporate AI into their business, SharePoint has come and provided several low code solutions with their Power Platform tools, such as users can create sentiment analysis tools, language detection, and even text translation apps using the platform. There is still a barrier into the Power Platform that requires some logic to design and query resources, but those with an understanding of Excel formulas should find the process similar.

Any SharePoint architect will tell you that metadata is important for creating well structure search schemas, but sometimes the amount of meta data needed is cumbersome to end users. What if a way to extract and auto tag documents is needed, there must be an easier way than a power app to achieve this, which is where SharePoint Syntex comes in. SharePoint Syntex can be thought of as a content type hub that allows for auto tagging of documents by just uploading the document to a library.

It is important to know before using SharePoint Syntex, you must purchase an additional license for each user using the service and Power Automate credits. 

Setting up a Content Center

To begin using SharePoint Syntex, you must setup a content center to hold and host your Syntex content types. Just like the old content type hubs, this is done by creating a new site collection. In the SharePoint admin screen, create a new site collection and select the template "Content Center". If you do not see this option, make sure you have activated the service from the admin portal under setup then activating Automate Content Understanding. On a developer tenant the service is already available, however you will not be able to publish the content type as Microsoft will not sell you the license needed to do that on the developer tenant. 


Creating a model

Once the site is created, navigate to the site collection. To create the first understanding model, at the top you have a list of options, selecting the "Document understanding model" 



Since we are creating this in 5 minutes, I am going to use one of the preexisting models provided by Microsoft. There are 2 models; Invoices and Receipts. To train our model, we must have samples. The more samples the better the training model, your samples should also include documents that are NOT invoices to make sure it doesn't recognize them. Since we are using the prebuilt model our content type is setup with invoice fields (invoice number, date, amount, etc). To use a custom understanding model you will create columns and then highlight on the document where the data is found for the extractor to learn what to look for. This will be covered in a later blog post.





The screen for our model is pretty straight forward, the first step in the process is to analyze our files. To do this, you will upload the samples mentioned above.



In the analyze section we will see a document library to hold the files used for analyzing and training, only upload the documents that are invoices as the analyzing step is confirming data is being found correctly. Click add at the top upload your documents.







With your samples loaded, highlight the ones you want to analyze and click add.








Now at the bottom of your library, click next to start the analyzation of the documents.






Clicking next will start the analyzing process. Once complete, a screen with the document and the properties found show up. This is where you tell Syntex if it found the correct items and that they should be extracted. clicking each item under extractor Syntex will ask you if it is the correct extractor. Saying yes can happen two ways either selecting yes for each extractor, or clicking the extract check box. clicking no will flag the extractor as wrong so anything that is not found correctly select no from the popup. Once complete hit next at the bottom of the screen



The invoice SharePoint Syntex Extractor is complete.  The final step would be to apply the extractor to a library.




To make sure you model works on other documents and does not work on documents it shouldn't additional files can be added to the "Training Files" library, and when we run the extractor we can see the prebuilt model only finds the business name, which to me shows the model is ready for production. if dates or items were found that should not match, more training items are needed.









SharePoint, SharePoint Syntex, AI, Artificial Intelligence, NLP, Natural Language Processing, Syntex, supervised learning, Microsoft, O365, Office 365, SharePoint Online

Tuesday, April 20, 2021

Securing a Microsoft Teams Tab using Azure Active Directory

Custom Teams Tabs

Microsoft O365 has expanded the ecosystem of the traditional office suite with many niche tools. Teams is a tool that has evolved out of that niche into a system that helps pull the office apps into one easy to use area. Teams replaces Skype as the primary chat tool, but it also lets you access SharePoint sites, your OneDrive, planner, and other apps. 

The primary way of accessing apps is through tabs. Tabs are just fancy I Frames that let you embed web pages into Teams. One of the benefits of embedding the page into Teams is the ability to access the user's profile data from Azure. We can get a user's UPN and pull general information about a user and the Team where the tab is opened.

Since we can embed custom pages into the tab, we can now have full blown web applications embedded into the O365 environment. This poses a challenge for security; how do I authenticate the user seamlessly with O365 and my web application? The answer is Azure AD. We can secure our application using Azure AD and then use the Microsoft Teams Client SDK to authenticate to our web application using a supplied id token that can be requested in the Teams Tab using the Microsft Teams SDK.

Authentication using Teams

The Microsoft Teams SDK will allow us to request an ID token that can be passed to our web application to secure our application. There are some caveats such as the Microsoft Teams SDK still uses ADAL to authenticate. This means, we are limited to what we can access in Office 365 with the access token that is also provided. This seems par for the course as the SharePoint client object model and REST services use ADAL with no news on upgrading. ADAL also poses an issue with some more modern browsers, as it requires 3rd party cookies to be active. Luckily Teams is in a corporate environment and can be easily controlled.

Another thing to take into account, is that in order to display a page in Teams it must be anonymous. The page needs to load so that it can call the APIs that provide the login information. This means that any secure data must be behind an API call instead of the standard view you would get with an MVC site. You might be able to use a Challenge Result to sign in the user, but from my experience that usually pops up the Microsoft Login page again which can cause issues in the Teams app because you are redirecting in an I Frame which will cause cross scripting errors.


Configure Azure for Authentication

In order to use Azure AD to authenticate our system, we need to register our application with Azure AD. In your Azure portal, go to the Azure AD section and find App Registrations. I will be creating an application called Teams Authentication.






In the new registration screen I will name my app Teams Authentication. Supported accounts will vary based on your requirements for the app. For this example I will choose my organization's directory only. Next I will add a redirect URI. This is the sign in URI for our application, it will be "https://localhost:5001/signin/signinend". This URI will need to be updated once we test as it is where Microsoft will redirect our tokens. Reminder: spelling and casing count! If your URL is all lowercase here it must be all lowercase in the call to access the token.



With your newly registered app, in the overview screen you will see a client ID. This is needed to create a call to authenticate our application. You can also get your tenant ID here as well to make calls directly to the tenant and create a tenant specific token as opposed to using the common login URL. Also, to authenticate to our web application we will need our appplication to return an Access Token and ID token. This can be selected in the authentication section of the app.





For this example, we will also be authenticating with the Graph API. Our token generated from Teams is limited to what it can access from graph, so we will want to create another app that interacts with Microsoft graph itself. We just want to show how you can authenticate to the web application and make a secure call so our application will be using application permissions to access graph. We will only need User.Read.All for this. The process is similar to above, but will require a secret to be generated for the application. I have done this in a previous post here:  https://www.fiveminutecoder.com/2021/03/creating-azure-document-queue-for.html


Create our tab in 5 minutes

With our app registered with Azure AD we can start to setup our application. First thing we need to do is create our MVC application

dotnet new mvc --name OfficeEmployeeDirectory


Our application will use JWT to authenticate, so we need to install the Microsoft identity model, along with the JWT packages. In order to do this, we will need to install the following DLLs from Nuget.

  1. Microsoft.Identity.Web
  2. Microsoft.Identity.Web.UI
  3. Microsoft.IdentityModel.Clients.ActiveDirectory
  4. Microsoft.AspNetCore.Authentication.JwtBearer
  5. Microsoft.AspNetCore.Authentication.AzureAD.UI
  6. Microsoft.Graph

Next, we will setup or app settings file with our app settings from Azure. We will create 2 sections for our apps. In the AzureAD section notice for the tenant I have common setup. This is used to allow multiple tenants access to your site, if you want only one you can put your tenant ID here, which can be found in the app registration overview. Also, the client id begins with api:// this is because the default auth method is 1.0 if you are using a later authentication method this might not be necessary. Audience in this section is the same as your client id.



{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "SignInUrl": "https://localhost:5001/Signin/SigninStart",
  "ReturnUrl": "https://localhost:5001/signin/signinend",
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "https://localhost:5001",
    "ClientId": "api://{client id}",
    "TenantId": "common",
    "CallbackPath": "/signin/signinend",
    "Audience": "{client id}",
    "Scopes" : "access_as_user access_as_admin",
    "AllowWebApiToBeAuthorizedByACL" : true
  },
  "DirectoryApp":{
    "TenantId": "{tenant id}",
    "ClientId": "{client id for graph app}",
    "clientSecret": "{client secret for graph app}"
  }
}

With our configurations setup, we can update our Startup.cs file to now allow an id token to be passed. We will be using JWTBearer authentication to validate our token.


public void ConfigureServices(IServiceCollection services)
{
	
	services.AddControllersWithViews();


	//authentication starts here
	services.AddAuthentication(options =>{
		options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; //using JWT token auth
		
	})
	  .AddMicrosoftIdentityWebApi(Configuration.GetSection("AzureAd")) //auth will act like a web api, otherwise our app tries to popup another login screen which is blocked
	  .EnableTokenAcquisitionToCallDownstreamApi()
	  .AddInMemoryTokenCaches();

	  

	services.AddControllersWithViews(options =>
	{
	  //add authenticated user to secure the app
	  var policy = new AuthorizationPolicyBuilder()
		  .RequireAuthenticatedUser()
		  .Build();
	  options.Filters.Add(new AuthorizeFilter(policy));
	});
	services.AddRazorPages()
	  .AddMicrosoftIdentityUI();
}

Microsoft wants our apps to be transparent, in other words apps require consent. Most of the time this can be granted by the admin, but user consent is required when making calls on the behalf of the users. So in order to login using Azure AD we need to setup login and logout pages that can make calls to Azure AD. There are settings to make this request silently, but takes time to configure and catch those types of calls. So in this case, we will show the login window. To do this we will create a controller called "Signin". In a production setting this would be your authenticate controller but for this blog I changed it so we could understand more what is happening. 

To sign in we have 2 pages SigninStart and SigninEnd, so in order to do this we will need to add some actions to our controller. Please note, these need to be anonymous to make the call.


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using OfficeEmployeeDirectory.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;

namespace OfficeEmployeeDirectory.Controllers
{
    //just controls the login views routing
    [AllowAnonymous]
    public class SigninController: Controller
    {
        private IConfiguration configuration;
        public SigninController(IConfiguration Configuration)
        {
            configuration = Configuration;
        }

        public ActionResult SigninStart()
        {
            ViewBag.ReturnUrl = configuration.GetValue("ReturnUrl");
            ViewBag.ClientId = configuration.GetSection("AzureAd").GetValue("Audience");
            return View();
        }

        public ActionResult SigninEnd()
        {
            return View();
        }
    }
}


The authentication happens client side so our page will juse the Microsft Teams SDK to call out to Azure.


//calls the teams login
//javascript
	let clientId = "@ViewBag.ClientId";
	if (clientId != undefined && clientId != null && clientId !== '') {
		microsoftTeams.initialize();
			let state = _guid();
			localStorage.setItem("simple.state", state);
			localStorage.removeItem("simple.error");
			// See https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-implicit
			// for documentation on these query parameters

			let queryParams = {
				client_id: clientId,
				response_type: "id_token token", //what we want returned
				response_mode: "fragment",
				resource: "https://graph.microsoft.com/", //resource we need access to
				redirect_uri: "@ViewBag.ReturnUrl", //return url
				nonce: _guid(),//unique value to reference in callback so our app can validate it was us making the call
				state: state
			};

			let authorizeEndpoint =
				"https://login.microsoftonline.com/common/oauth2/authorize?" +
					toQueryString(queryParams);
			window.location.assign(authorizeEndpoint);

	}
	// Build query string from map of query parameter
	function toQueryString(queryParams) {
		let encodedQueryParams = [];
		for (let key in queryParams) {
			encodedQueryParams.push(
				key + "=" + encodeURIComponent(queryParams[key])
			);
		}
		return encodedQueryParams.join("&");
	}
	
	//Create a unique identifier to validate the callback
	function _guid() {
			let guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
			let hex = "0123456789abcdef";
			let r = 0;
			let guidResponse = "";
			for (let i = 0; i < 36; i++) {
				if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
					// each x and y needs to be random
					r = (Math.random() * 16) | 0;
				}
				if (guidHolder[i] === "x") {
					guidResponse += hex[r];
				} else if (guidHolder[i] === "y") {
					// clock-seq-and-reserved first hex is filtered and remaining hex values are random
					r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
					r |= 0x8; // set pos 3 to 1 as 1???
					guidResponse += hex[r];
				} else {
					guidResponse += guidHolder[i];
				}
			}
			return guidResponse;
	}


The call will then return a URL for our site that can is used to either show an error or return our token. We will use the Microsoft Teams SDK to return our tokens back to the calling page.


//sign in attempt finished, will parse query strings and save tokens to object
//javascript
	microsoftTeams.initialize();
	
	localStorage.removeItem("simple.error");
	
	let hashParams = getHashParameters();
	
	if (hashParams["error"]) {
		// Authentication/authorization failed
		localStorage.setItem("simple.error", JSON.stringify(hashParams));
		microsoftTeams.authentication.notifyFailure(hashParams["error"]); //notifies our main page of an issue
	} else if (hashParams["access_token"]) {
		// Get the stored state parameter and compare with incoming state
		let expectedState = localStorage.getItem("simple.state");
		if (expectedState !== hashParams["state"]) {
			// State does not match, report error
			localStorage.setItem("simple.error", JSON.stringify(hashParams));
			microsoftTeams.authentication.notifyFailure("StateDoesNotMatch");
		} else {
			// Success -- return token information to the parent page
			microsoftTeams.authentication.notifySuccess({
				idToken: hashParams["id_token"],
				accessToken: hashParams["access_token"],
				tokenType: hashParams["token_type"],
				expiresIn: hashParams["expires_in"]
			});
		}
	} else {
		// Unexpected condition: hash does not contain error or access_token parameter
		localStorage.setItem("simple.error", JSON.stringify(hashParams));
		microsoftTeams.authentication.notifyFailure("UnexpectedFailure");
	}


	// Parse hash parameters into key-value pairs
	function getHashParameters() {
		let hashParams = {};
		location.hash.substr(1).split("&").forEach(function (item) {
			let s = item.split("="),
				k = s[0],
				v = s[1] && decodeURIComponent(s[1]);
			hashParams[k] = v;
		});
		return hashParams;
	}


We have our authentication ready, so the next step is to create an secure end point to call. I just added this to the Home index controller for brevity, this should be on a sperate controller with a /api route.


//our secure endpoint
[HttpGet]
public async Task GetDirectory()
{
	try
	{
		//pulls config data for our graph api
		string tenantId = configuration.GetSection("DirectoryApp").GetValue("TenantId"); //realm
		//some service account with graph api permissions
		string clientId = configuration.GetSection("DirectoryApp").GetValue("ClientId"); 
		//service account password
		string clientSecret = configuration.GetSection("DirectoryApp").GetValue("ClientSecret");; 
		string[] scopes = new string[] {"https://graph.microsoft.com/.default" };

		//creates a header for accessing our graph api
		IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(clientId)
				.WithClientSecret(clientSecret)
				.WithAuthority(new Uri("https://login.microsoftonline.com/" + tenantId))
				.Build();

		//gets token
		AuthenticationResult result = await app.AcquireTokenForClient(scopes).ExecuteAsync();

		//creates graph client
		GraphServiceClient client = new GraphServiceClient("https://graph.microsoft.com/v1.0", new DelegateAuthenticationProvider(
		async (requestMessage) =>
		{
			//adds token to header
			requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", result.AccessToken);
		}
		));


		//pulls all users who's account is enabled
		var users = await client.Users.Request().Filter("AccountEnabled eq true").GetAsync();

		return Json(users);

	}
	catch(Exception ex)
	{
		return BadRequest(ex.Message);
	}
}

Finally, our app is ready to call our endpoint, on our home page, we will use the Microsoft Teams SDK to call our start page, then we will take the results and call our secured endpoint using the token.


@{
    ViewData["Title"] = "Employee Directory";
}

//styling
    .card{
        width:400px;
        height:200px;
        border:1px solid black;
        margin-bottom: 20px;
        padding: 5px;
    }

Employee Directory

label id="jsonResponse" label //script //run our script when window loads window.addEventListener("load", function(){ //load the microsoft teams SDK microsoftTeams.initialize(); //params for our login method authenticateParams = { successCallback: function(result){ var token = result["idToken"]; var access_token = result["accessToken"]; GetEmployeeDirectory(token); }, failureCallback: function(reason){ alert("failed: " + reason); }, height: 200, width: 200, url: "@ViewBag.SignInUrl" } //start authentication process microsoftTeams.authentication.authenticate(authenticateParams); }); //success callback to our secure api function GetEmployeeDirectory(token){ fetch("/Home/GetDirectory", { method: "GET", headers: { "content-type": "application/json;odata=verbose", "Accept": "application/json; odata=verbose", "Authorization": "Bearer " + token } }) .then(response =>response.json()) .then(results =>{ // creates an html object to be rendered in our app for(var i = 0; i < results.length; i++){ let div = document.createElement("div") div.className = "card" div.append(results[i].displayName); div.append(document.createElement("br")) div.append( results[i].mail) div.append(document.createElement("br")) div.append(results[i].businessPhones.length > 0 ? results[i].businessPhones[0] : document.createElement("label")) document.getElementById("jsonResponse").append(div); } }); }

Testing our Tab in Microsoft Teams

If everything is setup correctly and you run the application you will not get the expected results. You will see the page pop up to login, close, and get an error stating "failed: CancelledByUser". This is because the application is not running in Microsoft Teams. In order to test our application we need to run it in Teams so we can pull the user context.




To test this in Teams, we need a public URL. We could publish our site to Azure, but that makes debugging difficult. So we will use a tool called NGROK, which can be found here, to create a tunnel to our local application. To get a public URL run the command below in the NGROK window.



ngrok http --host-header=rewrite 5000







With our public URL, we need to go back into Azure and update our application return URL. Remember casing counts here if it doesn't match what is in our application it will give an error. 





Next we need to update the application Return URL to match our new NGROK URL.






With our application ready, we can now open Teams and configure our app. Teams has an app called "App Studio" that allows us to create an XML config file for our app. 





Walking through the App config details page and fill in your information, it is straight forward. You can find mine in the code project. Next click the tab capabilities, we will add our URL's here. Click the new personal tab button and fill in the fields, for the Content URL use the NGROK URL. Everytime you make a change to the NGROK URL, you will need to update this manifest.






Now we can install the application, I am using the web client so I will install it using Publish. If you have the desktop client, you can side load applications. To do so, click Test and Distribute in app studio install or publish in your tenant.

You should now be able to use your app, click the ellipse below files to find your application. If it is not there, at the bottom of teams you will find Apps, search for the app there and install it.

That's It! We now have a secure Teams tab using Azure AD.





Clone the project


You can find the full project on my GitHub site here https://github.com/fiveminutecoder/blogs/tree/master/OfficeEmployeeDirectory

Microsoft, Teams, Microsoft Teams, C#,C Sharp, Authentication, JWT, id token, access token, token, Teams Authentication, Microsoft teams SDK, teams sdk, Teams App, Teams Tab, five minute coder, Microsoft Graph, Azure AD, Azure Active Directory, Azure App, application,

Tuesday, February 9, 2021

Create an Azure Document Queue for Loading and Tagging SharePoint Documents - Part 2

 

Previously...

In my last post, we created a client application that reads our file systems and creates an Azure queue item for uploading our data to SharePoint which can be found here: https://fiveminutecoder.blogspot.com/2020/10/creating-azure-document-queue-for.html 

We did this to get around limitations in SharePoint Online that cause throttling when trying to create large batches of document uploads. The second part will cover creating a web job that continuously checks the queue for new items and when it discovers these items the job will perform the upload to SharePoint.

**It is worth noting, that .NET 5 has recently came out and this post uses it. Unfortunately .NET standard and .NET 5 do not support cookie based authentication, username/password, anymore and require Azure AD integrations. So this solution turned from a 5 minute demo to a 10 minute demo. Mostly because of the configuration in Azure, and switching from CSOM . The code for Graph adds additional steps because you need site and list ids instead of using the name which CSOM supported. This is the way Microsoft recommends so I updated the code to reflect those changes**

Prepping SharePoint

Before we can create our job we need a destination. If you do not have a SharePoint environment please sign up for a free account here: https://developer.microsoft.com/en-us/microsoft-365/dev-program . Once you have an account, we need to create our library. To do this go to site contents -> new -> document library and create a new document library called "CustomerDocs". With our library created, we need to create our 3 columns"State, City, AccountNumber". Please make sure to use list settings to create the columns. If you use the new column button on the library home page the internal column names will not match and you will need to find them. Creating the columns in library settings our internal columns names will match what we type in. It is also important to point out to not use spaces when creating the columns. This will cause some URL encoding in the column name making it harder to develop. Once the column is created you can change the name without affecting the internal name.



Prepping Azure

Azure AD comes free with your O365 subscription, and this is where we will register our app. Login to Azure and go to Azure Active Directory. From there you will find "App Registrations". This is where we will register our new app that will upload our documents.






we will name our app something we will remember, I chose Graph API Document Upload. We will then set our supported account type. This setting will not matter for us since we will use a token to access the API, so choose "Accounts in this organizational directory only" so that it is secure. You can leave the web blank, we will set that up next.



Once your app is created note the Application ID and Directory ID in the overview section, we will need these for our app to authenticate. Moving down the left hand navigation click Authentication. If you don't have a platform, click add platform, and select web.



It will then ask for a redirect URL. We can put anything in here since our app will not be using Azure AD credentials to login, we want the token. So I just put https://localhost. What we want form our platfomr is the implicit grant token. Find the section that says Implicit grant and check the boxes for access tokens and ID tokens, and save.



Continuing down the left navigation click on Certificates and secrets. From here, click click New Client Secret. This is a one time code, if you do not capture it when creating you will need to create another one.




Finally we can give our app permissions to SharePoint. In the left navigation, go to API permissions. You should see the default permission for graph "User.Read" we will click add permission. Find Graph API. 

We want Application permission and then search Sites. Expand and give Sites.ReadWrite.All. Manage all will also work if you do not want to use the preview.

Finally, we need to grant the app permission. At the top next to "Add a permission" click Grant admin consent for...


Azure is now configured for our application.


 Creating a Web Job in 5 minutes

A web job is just a console application that runs on a background process of an azure web application. These jobs can be set to run in intervals or continuously. Jobs will also scale with the azure web service, giving you more instances as you scale out your web application.

Since we will be creating a web job in 5 minutes, this blog post will not go into details about configuring the job using code, we will focus on configuration using the portal instead.

To start, we will create a new console application and name is "SharePointMIgration_WebJob". Our web job will require several nuget packages. We will use O365 Graph API to perform our document upload operations. 

Packages to install:

  • Microsoft.Graph.Core
  • Microsoft.Graph
  • Microsoft.Identity.Client
  • Azure.Storage.Blobs
  • Azure.Storage.Queues


Now that we have our nuget packages installed, we can start developing our web job. For the most part, our process will just be the reverse of adding items to the queue. We will need to manage the queue by popping items when finished uploading to SharePoint. We will need to bring over our CustomerMetadata model we created in the first part of this blog since it represents our queue data.


namespace SharePointMigration_WebJob
{
    public class CustomerMetadata
    {
        public string State {get; set;}
        public string City {get;set;}
        public string AccountNumber {get;set;}
        public string FileName {get;set;}
    }
}


Now that we have our data model covered, we will want to pull our data from our queue. The Azure queue has some interesting features, we can peek or receive an item in the queue. Peek allows us to look without a locking the item, while receive will put a temporary hold on the item so other jobs cannot receive or pop the item from the queue. The queue also allows us to do batch requests, this will help with throttling the queue. Our queue function will take two parameters one for the number of messages we want to receive from the queue and how long we want to lock the queue. In our example we will want to load a document every 10 seconds, which comes to approximately 30 documents every 5 minutes. In our function the parameters will be set to the defaults of 1 message and a lock of 30 seconds. When we call the function, it will pull a larger batch.



public static async Task GetFileMetaDataFromQueue(int MessageCount=1, int QueueLock=60)
{
	//calls the queue and pulls messages for processing
	QueueMessage[] queueMessages = await queue.ReceiveMessagesAsync(MessageCount, TimeSpan.FromSeconds(QueueLock));
	
	return queueMessages;
}


Now that we have our queue items, we will need to get the document that is associated with the queue. Documents are returned as streams, and the Graph API expects a stream to write to SharePoint. So we will just return the stream from our blob storage.



public static async Task GetFileFromAzureBlob(string FileName)
{
	BlobClient blobClient = new BlobClient(cs, "customer", FileName);

	using (BlobDownloadInfo downloadInfo = await blobClient.DownloadAsync())
	{
		MemoryStream stream = new MemoryStream();
		
		downloadInfo.Content.CopyTo(stream);
		return stream;
		
	}
}


Pulling metadata and documents from Azure queue is straight forward, the Graph API is not. In older SharePoint libraries (SSOM, CSOM, JSOM) you could get sites and libraries by title. With Graph everything is ID based. That means we need to get our site, library, and folder ids before we can add/update SharePoint. These do not result in additional calls, CSOM required us to pull them first as well, but it does make it more challenging to code. Before we can get our IDs we need to setup Graph to use our new app credentials. To do this we will create a function that authenticates to Azure and returns our token.



public static async Task GetGraphAPIToken()
{
	string tenantId = ""; //realm
	//some service account to upload docs. Documents cannot use app
	string clientId = ""; 
	//service account password
	string clientSecret = ""; 
	string[] scopes = new string[] {"https://graph.microsoft.com/.default" };
	IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(clientId)
				.WithClientSecret(clientSecret)
				.WithAuthority(new Uri("https://login.microsoftonline.com/" + tenantId))
				.Build();

		AuthenticationResult result = await app.AcquireTokenForClient(scopes).ExecuteAsync();

		//result contains an ExpiresOn property which can be used to cache token
		return result.AccessToken;
}


Now that we can pull our token, we will create a function that pulls our Ids. We only need to do this once and will call it at the beginning our our main function to reduce the amount of calls to the Graph API. The Ids are stored in fields so they can be accessed by other functions.



public static async Task GetSiteandListIDs()
{
	//Crate our graph client
	GraphServiceClient graphClient = new GraphServiceClient("https://graph.microsoft.com/v1.0", new DelegateAuthenticationProvider(
		async(requestMessage) =>{
			string token = await GetGraphAPIToken();
			requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
		}
	)); 

	//Gets the root site, if your library lives somewhere else you will need the collection and find it.
	var sites = await graphClient.Sites.Root.Request().GetAsync();
	siteId = sites.Id;

	//gets all libraries. Since our app is written in 5 minutes it is easier to filter the entire collection
	var libraries = await graphClient.Sites[siteId].Drives.Request().GetAsync();
	var library = libraries.First(f => f.Name == "CustomerDocs");

	libraryId = library.Id;

	// gets root folder of our library
	var rootFolder = await graphClient.Sites[siteId].Drives[libraryId].Root.Request().GetAsync();

	rootFolderId = rootFolder.Id;
}


We are ready to start uploading documents to SharePoint. We will follow similar steps for uploading documents as we did for getting our Ids. It is important to note that O365 now refers to libraries as drives. This is because the same functionality can be applied to OneDrive and Teams, which are all backed by SharePoint libraries.



public static async Task UploadDocumentToSharePoint(Stream FileStream, CustomerMetadata Metadata)
{
	//Gets graph client. We get this each time to make sure our token is not expired
	GraphServiceClient graphClient = new GraphServiceClient("https://graph.microsoft.com/v1.0", new DelegateAuthenticationProvider(
		async(requestMessage) =>{
			string token = await GetGraphAPIToken();
			requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
		}
	)); 

	//Uploads our file to our library
	DriveItem createDocument = await graphClient.Sites[siteId].Drives[libraryId].Items[rootFolderId].ItemWithPath(Metadata.FileName).Content.Request().PutAsync(FileStream);
	
	//Our metadata for our document
	FieldValueSet custData = new FieldValueSet{
		AdditionalData = new Dictionary()
		{
			{"State", Metadata.State},
			{"City", Metadata.City},
			{"AccountNumber", Metadata.AccountNumber}
		}
	};

	//sets the metada properites for our item
	await graphClient.Sites[siteId].Drives[libraryId].Items[rootFolderId].ItemWithPath(Metadata.FileName).ListItem.Fields.Request().UpdateAsync(custData);

	//try checking in file, some libraries it is required 
	try
	{
		await graphClient.Sites[siteId].Drives[libraryId].Items[createDocument.Id].Checkin().Request().PostAsync();
	}
	catch(Exception ex){
		//ignoring this error becuase library is not set for checkin/out
		if(!ex.Message.Contains("The file is not checked out"))
		{
			throw ex;
		}
	}
}


Our documents now sit in SharePoint with metadata. The last thing to do is clean up our queues. Remember Microsoft charges an average amount that is in our blob storage, so keeping it with minimal documents as possible will keep costs down. We will use our PopReceipt and MessageId to remove any queue items. We dont want them back in the queue looking for items again, that will break our loop.



public static async Task RemoveItemFromQueue(string MessageId, string Receipt)
{
	await queue.DeleteMessageAsync(MessageId, Receipt);
}



public static async Task RemoveDocumentFromQueue(string FileName)
{
	BlobClient blobClient = new BlobClient(cs, "customer", FileName);
	await blobClient.DeleteAsync();
}



Now that we have all our functions we can put our main loop together. This is a continuous job, so all of our logic will sit in a while loop to keep it running forever. We will get our Ids from the Graph API, and then pull from the queue. Once we have the queue we loop through our items and upload to SharePoint. We will sleep every once and awhile to make sure our app runs at an appropriate speed for SharePoint and eliminate any throttling.


//making this a property so we do not have to keep recreating the object
static QueueClient queue; 
//connection string to our storage account, it is shared between blobs and queues.
static string cs = "";

//The site ID our library lives in
static string siteId = "";
//The ID of the library
static string libraryId = "";
//The ID of the root folder in the library
static string rootFolderId = "";

static async Task Main(string[] args)
{
	queue = new QueueClient(cs, "customer");
	int batchMessageCount = 30; //number of items to pull from queue at once
	int queueLock = batchMessageCount * 10; //number of batches time 10 since each message will take 10 seconds to process.

	//
	await GetSiteandListIDs();

	//Creating an infinite loop for our continuous job
	while(true)
	{
		DateTime startTime = DateTime.Now;
		try
		{
			Console.WriteLine("Getting queue");
			QueueMessage[] messages = await GetFileMetaDataFromQueue(batchMessageCount, queueLock);
			Console.WriteLine("Found {0} items in the queue", messages.Length);
			foreach(QueueMessage message in messages)
			{
				//our cleint job encoded the message, this will decode it
				string data = HttpUtility.UrlDecode(message.MessageText);
				CustomerMetadata customer = JsonConvert.DeserializeObject(data);

				Console.WriteLine("Pulling document {0}", customer.FileName);
				using(MemoryStream document = await GetFileFromAzureBlob(customer.FileName))
				{
					Console.WriteLine("Uploading document {0}", customer.FileName);
					await UploadDocumentToSharePoint(document, customer);
				}

				Console.WriteLine("Upload was successful, removing {0} from the queue", customer.FileName);
				//remove message from queue so it doesnt get pulled again since we were successful
				await RemoveItemFromQueue(message.MessageId, message.PopReceipt);
				//remove document from storage
				await RemoveDocumentFromQueue(customer.FileName);

				//sleep 10 seconds before next call to sharepoint to prevent throttling.
				System.Threading.Thread.Sleep(10000);
			}
		}
		catch(Exception ex)
		{
			Console.WriteLine("Error writing queue to SharePoint: " + ex.Message);
		}

		Console.WriteLine("Finished with current queue list, will wait 5 minutes from last call");
		//we want our job to sleep if it takes less than 5 minutes to process the queue. This is to prevent throttling
		DateTime endTime = DateTime.Now;
		double totalMinutes = endTime.Subtract(startTime).TotalMinutes;

		if(totalMinutes < 5)
		{
			double sleepTime = (5-totalMinutes) * 60000;
			System.Threading.Thread.Sleep(Convert.ToInt32(sleepTime));
		}
	}
	
}


Deploying our Web Job

With our web job complete we need to publish it to Azure. To do do this, we just need to create a release and zip the contents. To create a realease, run the command below:


dotnet publish -c Release



Next we need to add our web job to our Azure web app. If you do not have a web app in Azure you can create one for free. One thing to note, is a web job runs on a schedule so for this demo a free account will work to test our job. A web job will not run in a production environment unless your app is set to be "always on". Which is not allowed in a free version.




Once your application is created, find the web jobs section under setting in the left navigation.


Select "Add" at the top and enter a friendly name; I chose SPSync. Next select the file to upload. From the upload screen go to where you published the .NET project, which should be the bin folder of your web job project if you followed this tutorial. In the bin folder you will see a release folder. Click in and depending on your dotnet core version you should see another file, mine says ".net 5.0.Zip". Zip this file up and upload the zip.





Lastly, let us set the job to continuous so it will continuously check our queue for new documents.




Once our job publishes, you will see its status changed to running. Click into the logs to review that items are loading. Go back to your queue and the documents will be gone!



Clone the project


You can find the full project on my GitHub site here https://github.com/fiveminutecoder/blogs/tree/master/SharePointMigration_WebJob






Azure, SharePoint, O365, SP, C#, Storage, Storage Account, Blob Storage, Azure Blob, Azure Blob Storage, Azure Storage Account, Azure Blob Storage Account, Azure Queue, Azure Queue Account, Queue, Serverless development, devleopment c sharp, 429, throttling, SharePoint Online, Office 365

Tuesday, January 5, 2021

Create an Azure Document Queue for Loading and Tagging SharePoint Documents - Part 1

 

Why do we need a document queue?

SharePoint Online limitations


SharePoint is not known for being the most developer friendly platform, in fact the Stack Overflow survey indicates it being the most dreaded platform by developers https://insights.stackoverflow.com/survey/2018 . With so many companies moving to O365 and SharePoint online, developers have had even more struggles designing custom solutions on top of SharePoint.

One of the most frustrating of those is the dreaded 429 error, or when SharePoint throttles you. It is expected that using a software as a service product will come with some throttling. Microsoft does not want us bombarding their servers with requests, but there is no clear point on what throttles their system. This is a guess for all, and Microsoft determines this at random times. The best way to reduce throttling is to limit your calls. When dealing with list items this can be done by batching requests either using the REST or Graph API batch calls, but uploading documents does not have this luxury.  

Documents require several steps to upload a document with it's metadata which makes it very prone to throttling if you have several documents to upload. Looking at a typical CSOM call for a SharePoint upload we see about 5 calls  to upload and tag a document:

1. Call to get the list
2. Call to add document
3. Call to get the document after uploaded for list item properties
4. update the list item properties.
5. call to check in document

We can reduce these calls down to 3 using the REST API, but it still requires more calls than a list item. Uploading 1 or 2 documents at a time will not throttle your requests, but what if you need to migrate a shared drive, or you have an application that compiles refunds into a document that can be sent back to your customers? This is when throttling will begin. The solution: limit the number of calls to SharePoint. From experience, it seems 30 documents a minute is the maximum before you start to get throttled, this of course depends on how many total documents you are uploading. 30 documents once a day is fine, but if you are doing batches of 500, 30 documents continuously over a 20 minute period will start to throttle your user account. 

Getting performance out of my SharePoint app while limiting calls to SharePoint.


Knowing I need to make at least 3 calls a document how can I create an app that is performant but does not cause a system to throttle? The answer is Azure. Azure does have throttling limits but they are significantly higher thresholds than SharePoint. Using a storage account, we can create an intermediary between our application and SharePoint. Blob storage is fairly cheap in Azure. At the time of this writing, Azure charges for the average storage used not the total. What this means if  I am storing 2 GB worth of documents at a time I am charged for 2GB worth of storage. Even that means I load 2 GB's a day and remove it. 

A storage account also has several other features like creating a queue which can be used to hold our metadata. We can push and pop into the queue to reference our document,  and metadata by creating a simple JSON string. 

Once our documents are in Azure, we don't care how long it takes. Our users can continue on while we trickle load our documents into SharePoint using a web job.

Creating a SharePoint migration tool in 5 minutes - Part 1

Since our application is going to be split up into two parts, this post will be split up into two parts as well. Part one will cover creating a tool that will upload our documents to Azure. While Part 2 will cover the job that uploads and tags our documents in SharePoint.

The Data

Our data is simple, we are going to assume we have a file share that is storing our customer records. Our folder structure will be setup as so:

\\customers\{state}\{city}\{account number}

With this file folder setup I can extract my tags for state, city and account number for my documents.


Setup Azure Storage Account

If you do not have an Azure account please create a free one before continuing. To get started click Create Resource and search for "Storage Account". Create the resource and follow the prompts to create a new resource group, and storage account name. For the purposes of this blog, you can leave the rest as the default values.



Once our account is created we can see the options for blob and queues.



First click into containers, and at the top click "+ Container". We will name our container "customer". Repeat the same step for Queue but click "+ Queue" and name it "customer". Azure is now ready for us to start loading documents.

In order to authenticate to Azure, we will need a connection string. In the left hand navigation find Access Keys. Click into Access Keys and select "Show Keys" to expose your connection string. Once we have our connection string we can setup our application.




Creating the Project

For our migration tool, we will create 2 project. The first project covered in this blog is a console application that will run on our local machine and upload the documents to Azure. Our second project, will be a web job that runs in Azure and will continuously look for objects in the queue and try to upload them to SharePoint.

Let us start off by creating our first project called "SharePointMigration_Client". Once our console app is created we need to install 3 nuget packages (CTRL+ALT+P in VSCode). The 3 packages we need are:

  • Azure.Storage.Queues 
  • Azure.Storage.Blobs
  • Newtonsoft.JSON

The firsts two will give us references for accessing azure storage, and Newtonsoft is just a convienent way to serialize our object for the queue. This project will also require a reference System.Threading.Tasks to allow for async calls to Azure storage. Now that are project and references are created, we need to add a new class object to represent our SharePoint metadata. I called this file simply "CustomerMetadata.cs"


namespace SharePointMigration_Client
{
    public class CustomerMetadata
    {
        public string State {get; set;}
        public string City {get;set;}
        public string AccountNumber {get;set;}
        public string FileName {get;set;}
    }
}

Now that we have our customer object, we can define to properties; one for our queue, and another for our connection string to Azure. I define the queue as a property just so we can open the queue once, the blob container needs to be opened per document. The connection string is the same for our blob container and our queue so referecing it as property allows me to define it once and move on.


//making this a property so we do not have to keep recreating the object
static QueueClient queue; 
//connection string to our storage account, it is shared between blobs and queues.
static string cs = "Connection string from Azure";

The first function to define is our directory reader. This is a recursive function that will read all of the documents and sub directories found in our root directory "customers". When it finds a file it will upload it to Azure otherwise it will continue onto the next directory.


//Recurssive function for iterating directory and sub directories
public static async Task GetFilesInDirectory(string FileDirectory)
{
	Console.WriteLine("Looking for files in " + FileDirectory);
	string[] files = Directory.GetFiles(FileDirectory);

	foreach(string file in files)
	{
		Console.WriteLine(file);
		await UploadFileToAzureBlob(file);
		await UploadFileMetaDataToQueue(file);
	}

	string[] subdirectories = Directory.GetDirectories(FileDirectory);

	foreach(string subdirectory in subdirectories)
	{
		//Recursion for going thorugh all directories
		await GetFilesInDirectory(subdirectory);
	}
}

Once a file is found, we will need to upload the file to Azure. I prefer to upload the file before adding it to our queue since file uploads have more of a chance of breaking due to file size. The call to Azure is pretty straight forward:


public static async Task UploadFileToAzureBlob(string FilePath)
{
	string fileName = Path.GetFileName(FilePath);
	Console.WriteLine("File name {0}", fileName);
	//customer is the name of our blob container where we can view documents in Azure
	//blobs require us to create a connection each time we want to upload a file
	BlobClient blob  = new BlobClient(cs, "customer", fileName); 

	//Gets a file stream to upload to Azure
	using(FileStream stream = File.Open(FilePath, FileMode.Open))
	{
		await blob.UploadAsync(stream);
	}
}

Our file is now loaded into Azure, you can go back to your Azure storage account and view it in our customers container. Next we will upload our metadata. Since this is a five minute tutorial we can assume our directory is setup correctly and we do not need to parse or check our directory for the metadata. We will just split the directory by '\' and reference the position in the array. In a production environment, we would want to check to make sure our array is filled correctly. Also, note in this function I use "HTTPUtility.UrlEncode". I have found that Azure is picky about characters and can break if we do not encode the string. Specifically around non-UTC-8 characters.


public static async Task UploadFileMetaDataToQueue(string FilePath)
{
	string[] metadata = FilePath.Split('\\');

	//we know our metadata position because of our file structure. 
	CustomerMetadata customerMetadata = new CustomerMetadata()
	{
		State = metadata[2],
		City = metadata[3],
		AccountNumber = metadata[4],
		FileName = metadata[5]
	};

	//create a string for our queue
	string data = JsonConvert.SerializeObject(customerMetadata); 
	//We do this to ensure any non UTC-8 characters are safe for the web service
	data = HttpUtility.UrlEncode(data); 

	//adds message to back of the queue
	await queue.SendMessageAsync(data);
}

That's it, we just need to await our file directory sync and watch as files are switfly uploaded to Azure.


static async Task Main(string[] args)
{
	
	//create our queue client
	queue = new QueueClient(cs, "customer"); //customer is the name of our queue where we can view the items uploaded in Azure
	
	await GetFilesInDirectory("c:\\customers");
	Console.Read();
}

Results

After a successful run, you should see your files in your blob and metadata in your queue.

Continue to Part 2

In the next part, we will create a web job that reads the Azure Queue and uploads the documents to SharePoint.


Clone the project

You can find the full project on my GitHub site here https://github.com/fiveminutecoder/blogs/tree/master/SharePointMigration_Client




Azure, SharePoint, O365, SP, C#, Storage, Storage Account, Blob Storage, Azure Blob, Azure Blob Storage, Azure Storage Account, Azure Blob Storage Account, Azure Queue, Azure Queue Account, Queue, Serverless development, devleopment c sharp, 429, throttling, SharePoint Online, Office 365
C#, C sharp, machine learning, ML.NET, dotnet core, dotnet, O365, Office 365, developer, development, Azure, Supervised Learning, Unsupervised Learning, NLP, Natural Language Programming, Microsoft, SharePoint, Teams, custom software development, sharepoint specialist, chat GPT,artificial intelligence, AI

Cookie Alert

This blog was created and hosted using Google's platform Blogspot (blogger.com). In accordance to privacy policy and GDPR please note the following: Third party vendors, including Google, use cookies to serve ads based on a user's prior visits to your website or other websites. Google's use of advertising cookies enables it and its partners to serve ads to your users based on their visit to your sites and/or other sites on the Internet. Users may opt out of personalized advertising by visiting Ads Settings. (Alternatively, you can opt out of a third-party vendor's use of cookies for personalized advertising by visiting www.aboutads.info.) Google analytics is also used, for more details please refer to Google Analytics privacy policy here: Google Analytics Privacy Policy Any information collected or given during sign up or sign is through Google's blogger platform and is stored by Google. The only Information collected outside of Google's platform is consent that the site uses cookies.