Dec 29, 2017

SharePoint add-in, custom Web API and SPFx (v1.1)

Hi SharePoint guys !

It's been a while I haven't written a blog post mainly due to lack of free time. Thanks to the end of year holidays, I could spend a few hours to build a proof-of-concept which, IMHO, might be really interesting. Hope you will be interested in it as much as I am.

Introduction

The SharePoint Framework is the new promoted development model for SharePoint customizations. It follows the current web development trends (Asynchronous HTTP calls, Web/REST APIs, ...). In this article, we will then see how to implement a custom Web API based on the SharePoint add-in model we can call from our SPFx solutions. A few months ago, I wrote a post explaning how to call App Only proxy implemented as Azure Functions from SPFx. This previous blog post focuses on Azure Functions, and is relevant for Cloud or Hybrid environments, it relies on Azure Function, Azure AD and can easily be extended with Microsoft Graph. Unfortunately, in a 'pure' On-prem environment without any cloud components, the previous blog post is not relevant. At the time, SPFx was not available for SharePoint on-premises, Now it is ! I then decided to port this concept using the assets and techniques we can all use in a full on-prem environment. This post will leverage the SharePoint provider-hoster add-in model which is applicable to both SharePoint Online and SharePoint (full) On-prem.

The case study

In order to implement a relevant Proof-of-Concept, let's ellaborate a context and some requirements.

  • We want to manage some business documents from a custom SPFx WebParts
  • The SPFx WebPart will interact with SharePoint exclusively via a custom Web API
  • The custom Web API will be a simple CRUD for _Business Documents_stored in a SharePoint document library

To make sure to rely on the same library structure as I am, you can use the provisioning assets here. You will need to have the PnP PowerShell module installed and execute the Provision.ps1 script.

Let's create a SharePoint add-in

We will first create a new SharePoint provider-hosted add-in solution like any other one. Open Visual Studio and create your new project as usual

vs-newproj

Enter the address of your SharePoint development site and select the Provider-hosted hosting option.

vs-addin-type

Select the targeted SharePoint version (in my case, I use SharePoint Online, but this blog post is also valid for SharePoint 2016 FP2)

vs-choose-spversion

Select ASP.NET MVC Web Application

vs-choose-webapp-type vs-choose-trust-type

  1. Select this option if you are using SharePoint Online or SharePoint On-prem with a configured low trust
  2. Select this option if you are using Server-to-server authentication in an on-prem environement (You will have to configure the X.509 certificate if you do so). Check this for more accurate information

Now that we have the boilerplate code of our solution, let's configure the SharePoint add-in. Open the AppManifest.xml file Let's change the start page value

addin-appmanifest-startpage

Let's set the [your-project]/Home/Register value. On the permissions tab, select List as the Scope and select Full Control. Make sure to tick the checkbox to allow app-only calls

addin-appmanifest-perms

A custom Web API in a SharePoint add-in

The SharePoint provider-hosted add-in model and the featured boilerplate code in the corresponding Visual Studio project template allows to use CSOM either in App+User or in App-Only mode.

How does it work ?

It relies on an authentication flow (OAuth in low-trust or direct in high-trust). To initiate this authentication flow, when you click the add-in icon in SharePoint, you reach a SharePoint system page (appredirect.aspx) that redirects you to your provider hosted application default page. This HTTP call issued for the redirection contains all the information of the current context (SharePoint and User context), this context will be persisted in the user session on the server side. It will later be used to create a CSOM ClientContext object instance that is the base of any CSOM operation. At each time a page (or ASP.NET MVC action) is reached, the current user context is fetched from his session state or created if it does not exist yet. If no context can be fetched or created on the server (for example, because needed information is missing), the user is redirected to an error page. because he cannot be properly authenticated, he won't be able to go further.

With a stateless Web API ?

By nature, a Web/REST API is stateless, it means that the common implementation will not handle user sessions. In the code, you won't have access to any session state for the current Request object. Anyway, the SharePoint add-in model needs some kind of session mechanism to work properly, we can implement our own session mechanism with the following steps :

  1. When the appredirect.aspx SharePoint page redirects to the provider-hosted addin default page, save the created context in cache with a specific key.
  2. Add a cookie in the HTTP response that will contain the cache key.  This cookie will be included in all subsequent calls
  3. In the next HTTP calls, get the cached context using the key contained in the cookie of the current request.

Hum...OK. That's a lot of work for such a basic thing...

pnp-superhero

Exactly ! We might need some help! Let's call our friend the PnP super-hero ! Actually, once again, the community has done great job and already implemented such a mechanism in the PnP Core library. All the stuff we need are here: https://github.com/SharePoint/PnP-Sites-Core/tree/master/Core/OfficeDevPnP.Core/WebAPI

OK, let's install the PnP Core library package in our solution. In the Package Manager Console of Visual Studio, make sure you select your Web application project and type the following command vs-packagemanager-installpnp2016 There is a module for each SharePoint currently supported version.

  • SharePointPnPCore2013
  • SharePointPnPCore2016
  • SharePointPnPCoreOnline

Choose the one that suits for you. While you're in the Package Manager console, enter also the command:

Install-Package Microsoft.AspNet.WebApi.Cors

As you understood above, we will need to register the Web API to make sure the subsequent calls will be able to fetch the SharePoint context stored in the cache. In the HomeController, we will then create a dedicated action (make sure to include the needed using clause)

using OfficeDevPnP.Core.WebAPI;  
//...  
[SharePointContextFilter] public ActionResult Register() 
{    
  try    
  {        
    // Register the BusinessDocuments API        
    WebAPIHelper.RegisterWebAPIService(this.HttpContext, "/api/BusinessDocuments");        
    return Json(new { message = "Web API registered" });    
  }    
  catch (Exception ex)    
  {
    return Json(ex);    
  } 
}

Let's then create a ViewModel class that will represent our business document entity. In the empty Models folder, add a new class name BusinessDocumentViewModel

https://gist.github.com/ypcode/662c2ae7a4607886614f93eb979ee671

We will then add our Web API controller

  1. Right-click the Controllers folder
  2. Expand the Add menu
  3. Click Controller...

vs-add-apicontroller-01

Select the Web API 2 Controller with read/write actions

vs-add-apicontroller-02

And name if "BusinessDocumentsController"

vs-name-apicontroller

Replace the code of the scaffolded class by this one

using Microsoft.SharePoint.Client; 
using OfficeDevPnP.Core.WebAPI; 
using spaddin_webapiWeb.Models; 
using System; 
using System.Collections.Generic; 
using System.Linq; 
using System.Net; 
using System.Net.Http; 
using System.Web.Http; 
using System.Web.Http.Cors;  

namespace spaddin_webapiWeb.Controllers 
{     
  [EnableCors(origins: "https://ypcode.sharepoint.com,https://localhost:4321",          
  headers: "*",
  methods: "*",         
  SupportsCredentials = true)]     
  [WebAPIContextFilter]     
  public class BusinessDocumentsController : ApiController     
  {         
    public const string FileLeafRefField = "FileLeafRef";         
    public const string InChargeField = "InCharge";         
    public const string DocumentPurposeField = "DocumentPurpose";          
    public static string[] ValidDocumentPurposes = new string[]         
    {             
      "Agreement project",             
      "Offer project",             
      "Purchase project",             
      "Research document"         
    };                  
    
    private static BusinessDocumentViewModel ListItemToViewModel(ListItem businessDocListItem)         
    {             
      FieldUserValue inChargeUserValue = businessDocListItem[InChargeField] as FieldUserValue;             
      string inChargeValue = inChargeUserValue != null ? inChargeUserValue.LookupValue : string.Empty;              return new BusinessDocumentViewModel()             
      {                 
        Id = businessDocListItem.Id,                 
        Name = (string)businessDocListItem[FileLeafRefField],                 
        Purpose = (string)businessDocListItem[DocumentPurposeField],                 
        InCharge = inChargeValue             
      };         
    }          
    
    private static ListItem MapToListItem(BusinessDocumentViewModel viewModel, ListItem targetListItem)         {             
      targetListItem[FileLeafRefField] = viewModel.Name;             
      targetListItem[DocumentPurposeField] = viewModel.Purpose;             
      targetListItem[InChargeField] = FieldUserValue.FromUser(viewModel.InCharge);             
      return targetListItem;         
    }          
    
    private static ListItem TryGetListItemById(List list, int id)         
    {             
      try             
      {                 
        ListItem item = list.GetItemById(id);                 
        list.Context.Load(item, i => i.Id);                 
        list.Context.ExecuteQuery();                 
        return item;             
      }             
      catch (Exception)             
      {                 
        return null;             
      }         
    }          
    
    private static bool ValidateModel(BusinessDocumentViewModel viewModel, out string message)         
    {             
      // Validate the purpose is a valid value             
      if (!ValidDocumentPurposes.Contains(viewModel.Purpose))             
      {                 
        message = "The specified document purpose is invalid";                 
        return false;             
      }              
      message = string.Empty;             
      return true;         
    }          
    
    // GET: api/BusinessDocuments         
    public IEnumerable<BusinessDocumentViewModel> Get()         
    {             
      using (var clientContext = WebAPIHelper.GetClientContext(this.ControllerContext))             
      {                 
        // Get the documents from the Business Documents library                 
        List businessDocsLib = clientContext.Web.GetListByUrl("/BusinessDocs");                 
        ListItemCollection businessDocItems = businessDocsLib.GetItems(CamlQuery.CreateAllItemsQuery();
        clientContext.Load(businessDocItems, 
          items => items.Include(item => item.Id,                     
                                 item => item[FileLeafRefField],
                                 item => item[InChargeField],
                                 item => item[DocumentPurposeField]));
        clientContext.ExecuteQuery();
        // Create collection of view models from list item collection
        List<BusinessDocumentViewModel> viewModels = businessDocItems.Select(ListItemToViewModel).ToList();
        return viewModels;
      }
    }
    
    // GET: api/MyBusinessDocuments
    [HttpGet]
    [Route("api/MyBusinessDocuments")]
    public IEnumerable<BusinessDocumentViewModel> MyBusinessDocuments()
    {
      using (var clientContext = WebAPIHelper.GetClientContext(this.ControllerContext))
      {
        // Get the documents from the Business Documents library
        List businessDocsLib = clientContext.Web.GetListByUrl("/BusinessDocs");
        var camlQuery = new CamlQuery {
          ViewXml = $@"<View><Query><Where>
            <Eq>
              <FieldRef Name='{InChargeField}' LookupId='TRUE' />
              <Value Type = 'Integer'><UserID /></Value>
            </Eq>
          </Where>
        </Query>
      </View>"
        };
        ListItemCollection businessDocItems = businessDocsLib.GetItems(camlQuery);                                 
        clientContext.Load(businessDocItems, items => items.Include(item => item.Id,
                                                                  item => item[FileLeafRefField],
                                                                  item => item[InChargeField],
                                                                  item => item[DocumentPurposeField]));                 
        clientContext.ExecuteQuery();
        // Create collection of view models from list item collection
        List<BusinessDocumentViewModel> viewModels = businessDocItems.Select(ListItemToViewModel).ToList();
        return viewModels;
      }
    }
    
    // GET: api/BusinessDocuments/5
    public IHttpActionResult Get(int id)         
    {
      using (var clientContext = WebAPIHelper.GetClientContext(this.ControllerContext))
      {
        // Get the documents from the Business Documents library
        List businessDocsLib = clientContext.Web.GetListByUrl("/BusinessDocs");
        ListItem businessDocItem = TryGetListItemById(businessDocsLib, id);
        if (businessDocItem == null)
          return NotFound();
        // Ensure the needed metadata are loaded
        clientContext.Load(businessDocItem, item => item.Id,
                                            item => item[FileLeafRefField],
                                            item => item[InChargeField],
                                            item => item[DocumentPurposeField]);                 
        clientContext.ExecuteQuery();
        // Create a view model object from the list item                 
        BusinessDocumentViewModel viewModel = ListItemToViewModel(businessDocItem);
        return Ok(viewModel);
      }
    }
    
    // POST: api/BusinessDocuments         
    public IHttpActionResult Post([FromBody]BusinessDocumentViewModel value)
    {
      string validationError = null;
      if (!ValidateModel(value, out validationError))
      {
        return BadRequest(validationError);
      }
      using (var clientContext = WebAPIHelper.GetClientContext(this.ControllerContext))
      {
        // Get the documents from the Business Documents library
        List businessDocsLib = clientContext.Web.GetListByUrl("/BusinessDocs");
        // Ensure the root folder is loaded
        Folder rootFolder = businessDocsLib.EnsureProperty(l => l.RootFolder);
        ListItem newItem = businessDocsLib.CreateDocument(value.Name, rootFolder, DocumentTemplateType.Word);
        // Update the new document metadata
        newItem[DocumentPurposeField] = value.Purpose;
        newItem[InChargeField] = FieldUserValue.FromUser(value.InCharge);
        newItem.Update();
        // Ensure the needed metadata are loaded
        clientContext.Load(newItem, item => item.Id, item => item[FileLeafRefField],                     
                                                     item => item[InChargeField],                     
                                                     item => item[DocumentPurposeField]);                  
        newItem.File.CheckIn("", CheckinType.MajorCheckIn);
        clientContext.ExecuteQuery();
        BusinessDocumentViewModel viewModel = ListItemToViewModel(newItem);
        return Created($"/api/BusinessDocuments/{viewModel.Id}", viewModel);             
      }
    }
    
    // PUT: api/BusinessDocuments/5
    public IHttpActionResult Put(int id, [FromBody]BusinessDocumentViewModel value)
    {
      string validationError = null;
      if (!ValidateModel(value, out validationError))
      {
        return BadRequest(validationError);
      }
      
      using (var clientContext = WebAPIHelper.GetClientContext(this.ControllerContext))
      {
        // Get the documents from the Business Documents library
        List businessDocsLib = clientContext.Web.GetListByUrl("/BusinessDocs");
        ListItem businessDocItem = TryGetListItemById(businessDocsLib, id);
        // If not found, return the appropriate status code
        if (businessDocItem == null)
          return NotFound();
        // Update the list item properties
        MapToListItem(value, businessDocItem);
        businessDocItem.Update();
        clientContext.ExecuteQuery();
        return Ok();
      }
    }
    
    // DELETE: api/BusinessDocuments/5
    public IHttpActionResult Delete(int id)
    {
      using (var clientContext = WebAPIHelper.GetClientContext(this.ControllerContext))             
      {
        // Get the document from the Business Documents library
        List businessDocsLib = clientContext.Web.GetListByUrl("/BusinessDocs");
        ListItem businessDocItem = TryGetListItemById(businessDocsLib, id);
        // If not found, return the appropriate status code                 
        if (businessDocItem == null)
          return NotFound();
        
        // Delete the list item
        businessDocItem.DeleteObject();                 
        clientContext.ExecuteQuery();                  
        return Ok();             
      }         
    }     
  } 
}

It is the complete implementation of our custom Web API. We have to notice several important parts:

  1. The use of [EnableCors] attribute that will allow our Web API to be called from JavaScript executed outside of our domain (JavaScript of the SPFx WebPart. You will have to change the value of the origins parameter to match your own SharePoint domain.
  2. The use of [WebAPIContextFilter] attribute that will make sure the call is issued by an authenticated user. Will actually fetch the context in cache from the key stored in the request cookie.
  3. The use of varclientContext = WebAPIHelper.GetClientContext(this.ControllerContext) instead of the classic **SharePointContextProvider**to instantiate the ClientContext object.

In order for everything to work, you will also have to make some changes in the Global.asax file

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Http;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;

namespace spaddin_webapiWeb
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            // Make sure to include this before the RouteConfig.RegisterRoutes() call
            GlobalConfiguration.Configure(WebApiConfig.Register);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
}

as well as in the WebApiConfig class

using System.Web.Http;

namespace spaddin_webapiWeb
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            config.EnableCors();

            config.MapHttpAttributeRoutes();

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );
        }
    }
}

At this step, we are done with our Web API. you can hit F5 to deploy the addin of our dev site. You can leave it running. Don't forget to select the right library when trusting the add-in

trust-webapi

If you want to see the complete Web API solution code, you can go to the GitHub repo

The SPFx WebPart

Let's create a new SPFx WebPart project with the famous yo @microsoft/sharepoint. Let's add a service class that will be responsible to issue the calls the our Web API

import { IApiConfigService, ApiConfigServiceKey } from './ApiConfigService';
import HttpClient from '@microsoft/sp-http/lib/httpClient/HttpClient';
import { IBusinessDocument } from '../entities/IBusinessDocument';
import { ServiceScope, ServiceKey } from '@microsoft/sp-core-library';

export interface IBusinessDocumentsService {
	getAllBusinessDocuments(): Promise<IBusinessDocument[]>;
	getMyBusinessDocuments(): Promise<IBusinessDocument[]>;
	getBusinessDocument(id: number): Promise<IBusinessDocument>;
	createBusinessDocument(businessDocument: IBusinessDocument): Promise<any>;
	updateBusinessDocument(id: number, update: IBusinessDocument): Promise<any>;
	removeBusinessDocument(id: number): Promise<any>;
}

export class BusinessDocumentsService implements IBusinessDocumentsService {
  private httpClient: HttpClient;
  private apiConfig: IApiConfigService;


	constructor(private serviceScope: ServiceScope) {
		serviceScope.whenFinished(() => {
      this.httpClient = serviceScope.consume(HttpClient.serviceKey);
      this.apiConfig = serviceScope.consume(ApiConfigServiceKey);
		});
	}

	public getAllBusinessDocuments(): Promise<IBusinessDocument[]> {
		return this.httpClient.get(this.apiConfig.apiUrl, HttpClient.configurations.v1, {
      mode: 'cors',
      credentials: 'include'
    }).then((resp) => resp.json());
	}

	public getMyBusinessDocuments(): Promise<IBusinessDocument[]> {
		return this.httpClient.get(this.apiConfig.apiMyDocumentsUrl, HttpClient.configurations.v1, {
      mode: 'cors',
      credentials: 'include'
    }).then((resp) => resp.json());
	}

	public getBusinessDocument(id: number): Promise<IBusinessDocument> {
		return this.httpClient.get(`${this.apiConfig.apiUrl}/${id}`, HttpClient.configurations.v1,{
      mode: 'cors',
      credentials: 'include'
    }).then((resp) => resp.json());
	}

	public createBusinessDocument(businessDocument: IBusinessDocument): Promise<any> {
		return this.httpClient
			.post(`${this.apiConfig.apiUrl}`, HttpClient.configurations.v1, {
        body: JSON.stringify(businessDocument),
        headers: [
          ['Content-Type','application/json']
        ],
				mode: 'cors',
				credentials: 'include'
			})
			.then((resp) => resp.json());
	}

	public updateBusinessDocument(id: number, update: IBusinessDocument): Promise<any> {
		return this.httpClient
			.fetch(`${this.apiConfig.apiUrl}/${id}`, HttpClient.configurations.v1, {
        body: JSON.stringify(update),
        headers: [
          ['Content-Type','application/json']
        ],
				mode: 'cors',
				credentials: 'include',
				method: 'PUT'
			});
	}

	public removeBusinessDocument(id: number): Promise<any> {
		return this.httpClient
			.fetch(`${this.apiConfig.apiUrl}/${id}`, HttpClient.configurations.v1, {
				mode: 'cors',
        credentials: 'include',
        method:'DELETE'
			});
	}
}

export const BusinessDocumentsServiceKey = ServiceKey.create<IBusinessDocumentsService>(
	'ypcode:bizdocs-service',
	BusinessDocumentsService
);

and let's create our main React component for our WebPart

import * as React from 'react';
import styles from './WebApiClient.module.scss';
import { IWebApiClientProps } from './IWebApiClientProps';
import { escape } from '@microsoft/sp-lodash-subset';

import {
	CommandBar,
	DetailsList,
	ISelection,
	Selection,
	SelectionMode,
	Panel,
	TextField,
	PrimaryButton,
	DefaultButton
} from 'office-ui-fabric-react';
import { IBusinessDocument } from '../../../entities/IBusinessDocument';
import { BusinessDocumentsServiceKey, IBusinessDocumentsService } from '../../../services/BusinessDocumentsService';
import { ApiConfigServiceKey, IApiConfigService } from '../../../services/ApiConfigService';

export interface IWebApiClientState {
	businessDocuments?: IBusinessDocument[];
	selectedDocument?: IBusinessDocument;
	selection?: ISelection;
	isAdding?: boolean;
	isEditing?: boolean;
	selectedView?: 'All' | 'My';
}

export default class WebApiClient extends React.Component<IWebApiClientProps, IWebApiClientState> {
	private businessDocsService: IBusinessDocumentsService;
	private apiConfig: IApiConfigService;
	private authenticated: boolean;

	constructor(props: IWebApiClientProps) {
		super(props);
		this.state = {
			businessDocuments: [],
			selectedDocument: null,
			isAdding: false,
			isEditing: false,
			selectedView: 'All',
			selection: new Selection({
				onSelectionChanged: this._onSelectionChanged.bind(this)
			})
		};
	}

	public componentWillMount() {
		this.props.serviceScope.whenFinished(() => {
			this.businessDocsService = this.props.serviceScope.consume(BusinessDocumentsServiceKey);
			this.apiConfig = this.props.serviceScope.consume(ApiConfigServiceKey);
			this._loadDocuments();
		});
	}

	private _loadDocuments(stateRefresh?: IWebApiClientState, forceView?: 'All' | 'My') {
		let { selectedView } = this.state;

		let effectiveView = forceView || selectedView;
		// After being authenticated
		this._executeOrDelayUntilAuthenticated(() => {
			switch (effectiveView) {
				case 'All':
					// Load all business documents when component is being mounted
					this.businessDocsService.getAllBusinessDocuments().then((docs) => {
						let state = stateRefresh || {};
						state.businessDocuments = docs;
						this.setState(state);
					});
					break;
				case 'My':
					// Load My business documents when component is being mounted
					this.businessDocsService.getMyBusinessDocuments().then((docs) => {
						let state = stateRefresh || {};
						state.businessDocuments = docs;
						this.setState(state);
					});
					break;
			}
		});
	}

	private _executeOrDelayUntilAuthenticated(action: Function): void {
		if (this.authenticated) {
			console.log('Is authenticated');
			action();
		} else {
			console.log('Still not authenticated');
			setTimeout(() => {
				this._executeOrDelayUntilAuthenticated(action);
			}, 1000);
		}
	}

	private _onSelectionChanged() {
		let { selection } = this.state;
		let selectedDocuments = selection.getSelection() as IBusinessDocument[];

		let selectedDocument = selectedDocuments && selectedDocuments.length == 1 && selectedDocuments[0];

		console.log('SELECTED DOCUMENT: ', selectedDocument);
		this.setState({
			selectedDocument: selectedDocument || null
		});
	}

	private _buildCommands() {
		let { selectedDocument } = this.state;

		const add = {
			key: 'add',
			name: 'Create',
			icon: 'Add',
			onClick: () => this.addNewBusinessDocument()
		};

		const edit = {
			key: 'edit',
			name: 'Edit',
			icon: 'Edit',
			onClick: () => this.editCurrentBusinessDocument()
		};

		const remove = {
			key: 'remove',
			name: 'Remove',
			icon: 'Remove',
			onClick: () => this.removeCurrentBusinessDocument()
		};

		let commands = [ add ];

		if (selectedDocument) {
			commands.push(edit, remove);
		}

		return commands;
	}

	private _buildFarCommands() {
		let { selectedDocument, selectedView } = this.state;

		const views = {
			key: 'views',
			name: selectedView == 'All' ? 'All' : "I'm in charge of",
			icon: 'View',
			subMenuProps: {
				items: [
					{
						key: 'viewAll',
						name: 'All',
						icon: 'ViewAll',
						onClick: () => this.selectView('All')
					},
					{
						key: 'inChargeOf',
						name: "I'm in charge of",
						icon: 'AccountManagement',
						onClick: () => this.selectView('My')
					}
				]
			}
		};

		let commands = [ views ];

		return commands;
	}

	public selectView(view: 'All' | 'My') {
		this.setState({
			selectedView: view
		});

		this._loadDocuments(null, view);
	}

	public addNewBusinessDocument() {
		this.setState({
			isAdding: true,
			selectedDocument: {
				Id: 0,
				Name: 'New document.docx',
				Purpose: '',
				InCharge: ''
			}
		});
	}

	public editCurrentBusinessDocument() {
		let { selectedDocument } = this.state;
		if (!selectedDocument) {
			return;
		}

		this.setState({
			isEditing: true
		});
	}

	public removeCurrentBusinessDocument() {
		let { selectedDocument } = this.state;
		if (!selectedDocument) {
			return;
		}

		if (confirm('Are you sure ?')) {
			this._executeOrDelayUntilAuthenticated(() => {
				this.businessDocsService
					.removeBusinessDocument(selectedDocument.Id)
					.then(() => {
						alert('Document is removed !');
						this._loadDocuments();
					})
					.catch((error) => {
						console.log(error);
						alert('Document CANNOT be removed !');
					});
			});
		}
	}

	private onValueChange(property: string, value: string) {
		let { selectedDocument } = this.state;
		if (!selectedDocument) {
			return;
		}

		selectedDocument[property] = value;
	}

	private onApply() {
		let { selectedDocument, isAdding, isEditing } = this.state;

		if (isAdding) {
			this._executeOrDelayUntilAuthenticated(() => {
				this.businessDocsService
					.createBusinessDocument(selectedDocument)
					.then(() => {
						alert('Document is created !');
						this._loadDocuments({
							selectedDocument: null,
							isAdding: false,
							isEditing: false
						});
					})
					.catch((error) => {
						console.log(error);
						alert('Document CANNOT be created !');
					});
			});
		} else if (isEditing) {
			this._executeOrDelayUntilAuthenticated(() => {
				this.businessDocsService
					.updateBusinessDocument(selectedDocument.Id, selectedDocument)
					.then(() => {
						alert('Document is updated !');
						this._loadDocuments({
							selectedDocument: null,
							isAdding: false,
							isEditing: false
						});
					})
					.catch((error) => {
						console.log(error);
						alert('Document CANNOT be updated !');
					});
			});
		}
	}

	private onCancel() {
		this.setState({
			selectedDocument: null,
			isAdding: false,
			isEditing: false
		});
	}

	public render(): React.ReactElement<IWebApiClientProps> {
		let { businessDocuments, selection, selectedDocument, isAdding, isEditing } = this.state;
		return (
			<div className={styles.webApiClient}>
				<div className={styles.container}>
					<iframe
						src={this.apiConfig.appRedirectUri}
						style={{ display: 'none' }}
						onLoad={() => (this.authenticated = true)}
					/>
					<CommandBar items={this._buildCommands()} farItems={this._buildFarCommands()} />
					<DetailsList
						items={businessDocuments}
						columns={[
							{
								key: 'id',
								name: 'Id',
								fieldName: 'Id',
								minWidth: 15,
								maxWidth: 30
							},
							{
								key: 'docName',
								name: 'Name',
								fieldName: 'Name',
								minWidth: 100,
								maxWidth: 200
							},
							{
								key: 'docPurpose',
								name: 'Purpose',
								fieldName: 'Purpose',
								minWidth: 100,
								maxWidth: 200
							},
							{
								key: 'inChargeOf',
								name: "Who's in charge",
								fieldName: 'InCharge',
								minWidth: 100,
								maxWidth: 200
							}
						]}
						selectionMode={SelectionMode.single}
						selection={selection}
					/>
					{selectedDocument &&
					(isAdding || isEditing) && (
						<Panel isOpen={true}>
							<TextField
								label="Name"
								value={selectedDocument.Name}
								onChanged={(v) => this.onValueChange('Name', v)}
							/>
							<TextField
								label="Purpose"
								value={selectedDocument.Purpose}
								onChanged={(v) => this.onValueChange('Purpose', v)}
							/>
							<TextField
								label="InCharge"
								value={selectedDocument.InCharge}
								onChanged={(v) => this.onValueChange('InCharge', v)}
							/>
							<PrimaryButton text="Apply" onClick={() => this.onApply()} />
							<DefaultButton text="Cancel" onClick={() => this.onCancel()} />
						</Panel>
					)}
				</div>
			</div>
		);
	}
}

The most important parts in this code are the following :

  • The hidden IFrame in the render() method, it will reach the default page of the add-in to initiate the context on the server-side <iframe src= style={ { display: 'none' }} onLoad={() => (this.authenticated = true)} />
  • The _executeOrDelayUntilAuthenticated() method that will execute the function as argument only after the IFrame content is loaded. This will make sure the the Web API calls are done only after the user is properly authenticated.

Another service class is used as the configuration holder and will take and compute its values from the WebPart properties.

webpart-properties

  • _remoteApiHost _: must contains the base URL of the provider hosted add-in (In this case, I use the address of my IIS Express instance launched by Visual Studio)

  • _appInstanceId _: must contains the GUID of the add-in instance. This GUID is found as the value of the query string parameter of the appredirect.aspx page when you click your add-in icon in SharePoint.

  The App Instance ID can also be easily found using PnP PowerShell, just use the following PowerShell cmdlet:

Get-PnPAppInstance -Identity "your app name"

ps-appinstanceid

Just copy/paste the Id as the value of the "App Instance ID" WebPart property You will find the whole SPFx solution in the GitHub repo

Result

We have now a custom WebPart able to interact with SharePoint data via our custom Web API. In this API we can implement any business logic, use App-Only mode or stick to the User permissions context. This solution is usable on SharePoint Online as well as on SharePoint On-prem ! Now that I have this boilerplate and PoC running, I think it will become my favorite approach when I need to build a customization that needs server-side custom code :)

Leave your comments and feedback and please share this blog around you ! I wish you all the best for the upcoming new year and you can expect plenty other blog posts in 2018 !

Best regards,

Yannick

Other posts