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
Enter the address of your SharePoint development site and select the Provider-hosted hosting option.
Select the targeted SharePoint version (in my case, I use SharePoint Online, but this blog post is also valid for SharePoint 2016 FP2)
Select ASP.NET MVC Web Application
- Select this option if you are using SharePoint Online or SharePoint On-prem with a configured low trust
- 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
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
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 :
- When the appredirect.aspx SharePoint page redirects to the provider-hosted addin default page, save the created context in cache with a specific key.
- Add a cookie in the HTTP response that will contain the cache key. This cookie will be included in all subsequent calls
- 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...
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 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
- Right-click the Controllers folder
- Expand the Add menu
- Click Controller...
Select the Web API 2 Controller with read/write actions
And name if "BusinessDocumentsController"
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:
- 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.
- 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.
- 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
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.
_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"
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