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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace spaddin_webapiWeb.Models | |
{ | |
public class BusinessDocumentViewModel | |
{ | |
public int Id { get; set; } | |
public string Name { get; set; } | |
public string Purpose { get; set; } | |
public string InCharge { get; set; } | |
} | |
} |
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 actionsAnd name if “BusinessDocumentsController“
Replace the code of the scaffolded class by this one
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 SharePointContextProviderto instantiate the ClientContext object.
In order for everything to work, you will also have to make some changes in the Global.asax file
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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={this.apiConfig.appRedirectUri}
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
Great post, quick question though. i have a slightly different solution in that I am using the aspnet mvc without the pnp/api piece. I am receving the CORs issue. I am getting origin(null) issue.
I’ve set the provider hosted url and using httpClient this is where I am getting the issue. I think it may be because of the redirect. The provider host is returning to my sp tenant? I think? and thats where the issue is occurring. Do you have any Ideas?
Thanks,
Gary
LikeLike
Hi Gary, do you allow the CORS on your MVC app ? Do you also set the appropriate headers in the httpClient call?
LikeLike
Hey,
yes I do it through the web.config:
Using the httpClient I use:
let url = `https://mvcappservice.azurewebsites.net/Something/Get?SPHostURL=https://garystenant.sharepoint.com/sites/addins&SPLanguage=en-US&SPClientTag=0&SPProductNumber=16.0.7911.1206`;
this.props.context.httpClient.get(url, HttpClient.configurations.v1, { mode: ‘cors’, credentials: ‘include’ })
.then((response: any) => {
response.json().then((responseJSON: any) => {
debugger;
console.log(responseJSON);
})
.catch((error) => {
debugger;
alert(error);
});
});
Should the URL be the app service? I’m not sure about it.
Thanks,
Gary
LikeLike
sorry my xml in my first reply didnt post:
<customHeaders>
<add name="Access-Control-Allow-Origin" value="https://garystenant.sharepoint.com" />
<add name="Access-Control-Allow-Methods" value="GET, POST, PUT, DELETE, OPTIONS" />
<add name="Access-Control-Allow-Headers" value="*" />
<add name="Access-Control-Allow-Credentials" value="true" />
</customHeaders>
LikeLike
Hi Gary,
What do you get as an error ? can you give the detailed error message ?
I suspect somehow, it cannot fetch the context from session, so it tries to redirect you to SharePoint to get a proper user context. I cannot really be sure as is.
What didn’t you use the PnP WebAPI helpers with a WebAPI controller ? Can you please share the code of your controller? or maybe put it on GitHub so I can try to reproduce and give my best advices ?
Technically I am sure it is possible to use a MVC controller with the Addin helpers, but it is designed for a context of a whole page to where you get redirected, so it can be tricky to make it work…
LikeLike
Hi! I’ve followed your guide but skipped the spfx part. Want to try the api with another add-in. I’ve added the webapi-project to a high trust sharepoint add-in also.
I’ve dployed everything and when I try to go to the add-in (api) I get this error on WebAPIHelper.RegisterWEBAPIService: “The context token cannot be validated.”.
In the browser I get this error: A circular reference was detected while serializing an object of type ‘System.Reflection.RuntimeModule’.
If I debug I can se that the RedirectStatus is OK and a context is created.
Do you have any idea on this?
LikeLike
Hi Robin, it’s hard to say without seeing the code but the circular reference thing makes me thing the object you expose on the web api has deep references, (complex structure object), that is not related to SP addin but I bet you might find out one issue over there…
LikeLike
Hello,
I tried step by step your solution, but when I arrived to the step when I need to press F5 from Visual Studio then I have the same problem than Robin.
Indeed, in the HomeController, the method “RegisterWebAPIService” fails with the error: “the context token cannot be validated”
I am trying with a Provided-hosted-add-in in high trust configuration.
Do you have any idea about it?
Thank you very much.
LikeLike
Hello Fran,
First of all, thanks for your interest in this blog post.
You should have an inner exception as a property of the exception you get, can you give that inner exception message and stacktrace ?
On a on-prem environment, you must make sure the app management service and subscription settings are properly configured and running
( https://docs.microsoft.com/en-us/sharepoint/administration/configure-an-environment-for-apps-for-sharepoint ) and trust the servers (the SharePoint server and the machine running your Visual Studio) with a certificate. ( https://docs.microsoft.com/en-us/sharepoint/dev/sp-add-ins/create-high-trust-sharepoint-add-ins ).
Let me know it that helps !
LikeLike
Can you also make sure you properly updated the web.config with the information of the certificate. You might take a look at this documentation if you installed the certificate in the certificates store https://docs.microsoft.com/en-us/sharepoint/dev/sp-add-ins/package-and-publish-high-trust-sharepoint-add-ins instead of using the ClientSigningCertificatePath, ClientSigningCertificatePassword, and ClientCertificate.
LikeLiked by 1 person
Hello,
thank you for your answers and all the information.
This is the Message and stackTrace related to the InnerException:
Message = “The parameter ‘token’ cannot be a null or empty string”
StackTrace = “at SharePointPnP.IdentityModel.Extensions.S2S.Tokens.Utility.VerifyNonNullOrEmptyStringArgument(String name, String value)
at SharePointPnP.IdentityModel.Extensions.S2S.Tokens.JsonWebSecurityTokenHandler.ReadTokenCore(String token, Boolean isActor…
It seems that the authentication tokens are not passed.
I will check the machine configuration and also the SharePoint one, because I received everything already configured.
I tried to develop a classical MVC provided hosted add-in always in high-trust with the same certificate, and everything works properly in this case.
In the web.config I am using the following “keys” ClientSigningCertificatePath, ClientSigningCertificatePassword, and ClientCertificate. I will try to register the certificate as well.
I also tried to register the Add in using the AppRegNew.aspx and set the ClientID and secret in the Appmanifest and web.config but I get always the same error.
Thank you again for your help.
Francesco
LikeLiked by 1 person
Hi Fran, I was reviewing the blog post and your issues, did you manage to solve it? something came to my mind, can you make sure the register action is called through the AppRedirect SP page? That is the most important step that passes the context to your external app. In the body or the query string of the http request calling the Register action there should be one of these parameters “AppContext”, “AppContextToken”, “AccessToken”, “SPAppToken. You can also make sure the register method is annotated with SharePointContextFilter, that ensures the action is called from AppRedirect or returns an error
LikeLike
hello,
i have not solved yet, i found an alternative solution.
the Register action is called, and the attribute SharePointContextFilter is set it up.
the problem is that the parameters “AppContext”, “AppContextToken”, “AccessToken”, “SPAppToken” are not defined, they are all null.
i will give another try in the next weeks.
thank you again.
LikeLike
Ok so I guess you have to make sure it is opened thtough the AppRedirect page of SharePoint. In that occurs in debug mode with visual studio. Make sure you start through the addin project and have no action on the web project (see multiple startup projects). Hope that’ll help
LikeLike
Hello,
Nice article, but I can not get it work because request to add-in without StandartTokens (like ClientTag, SPAppWebUrl …) returns 405 in my case. How can I get them and pass to the query or there is another way to resolve issue? I am using high trust model.
Is there any ideas about it?
LikeLike
Hi Sasha, when do you get the 405 response? Did you implement the hidden iframe that loads the app redirect page? Setup the startup page to your register action? Can you make sure in your API the register action is called when running the app?
LikeLike
Hi ypcode,
thank you first of all for the excellent tutorial and samples. this helped me a lot.
I tried to deploy the solution to my office tenant, and webapi to an azure app service. this works all so far. but when adding the webpart and open it an error occurs. for example it calls the api …api/MyBusinessDocuments and it fails with Message: “Service requestor is not registered: access denied”
Did you come across this error during your implementation?
Thank you in advance
Stefan
LikeLike
Hi Stefan, can you ensure you trusted the sp addin ? If yes, do you have the proper app instance Id in the SPFx webpart properties?
LikeLike
You might also double check the cors access are allowed on your azure app service. You can maybe get more info inspecting the network traffic to see what gets loaded in the hidden iframe 🙂
LikeLike