Extending the model for Microsoft Graph
The PnP Core SDK model contains model, collection, and complex type classes which are populated via either Microsoft Graph or SharePoint REST. In this chapter you'll learn more on how to decorate your classes and their properties to interact with Microsoft 365 via the Microsoft Graph API.
Configuring model classes
Public model (interface) decoration
All model classes need to link their concrete type (so the implementation) to the public interface via the ConcreteType
class attribute:
[ConcreteType(typeof(TeamChannel))]
public interface ITeamChannel : IDataModel<ITeamChannel>, IDataModelGet<ITeamChannel>, IDataModelLoad<ITeamChannel>, IDataModelUpdate, IDataModelDelete
{
// Ommitted for brevity
}
Model class decoration
Each model class that uses Microsoft Graph needs to have at least one GraphType
attribute:
[GraphType(Uri = "teams/{Site.GroupId}")]
internal partial class Team : BaseDataModel<ITeam>, ITeam
{
// Ommitted for brevity
}
When configuring the GraphType
attribute for Microsoft Graph you need to set the attribute properties:
Property | Required | Description |
---|---|---|
Uri | No | Defines the URI that uniquely identifies this object. See model tokens to learn more about the possible tokens you can use. |
Target | No | A model can be used from multiple scope and if so the Target property defines the scope of the GraphType attribute. |
Id | No | Defines the Microsoft graph object field which serves as unique id for the object. Typically this field is called id and that's also the default value, but you can provide another value if needed. |
Get | No | Overrides the Uri property for get operations. |
LinqGet | No | Some model classes do support linq queries which are translated in corresponding server calls. If a class supports linq in this way, then it also needs to have the LinqGet attribute set. |
Update | No | Overrides the Uri property for update operations. |
Delete | No | Overrides the Uri property for delete operations. |
OverflowProperty | No | Used when working with a dynamic property/value pair (e.g. fields in a SharePoint ListItem) whenever the Microsoft Graph field containing these dynamic properties is not named Values . |
Beta | No | Defines that a model can only be handled using the Microsoft Graph beta endpoint. If a user opted out of using the Microsoft Graph beta endpoint then this model will not be populated. |
Property decoration
The property level decoration is done using the GraphProperty
and KeyProperty
attributes. Each model instance requires to have an override of the Key
property and that Key
property must be decorated with the KeyProperty
attribute which specifies which of the actual fields in the model must be selected as key. The key is for example used to ensure there are no duplicate model class instances in a single collection.
Whereas the KeyProperty
attribute is always there once in each model class, the usage of the GraphProperty
attribute is only required for special cases.
// In graph the fieldname is "name" whereas in the model the name is "Title"
[GraphProperty("name")]
public string Title { get => GetValue<string>(); set => SetValue(value); }
// Mark the property that serves as Key field
// (used to ensure there are no duplicates in collections),
// use a JsonPath to get the specific value you need
[GraphProperty("sharepointIds", JsonPath = "webId")]
public Guid Id { get => GetValue<Guid>(); set => SetValue(value); }
// Define a collection as expandable
[GraphProperty("lists", Expandable = true)]
public IListCollection Lists { get => GetModelCollectionValue<IListCollection>(); }
// Configure an additional query to load this model class this is a non expandable collection
[GraphProperty("channels", ExpandByDefault = true, Get = "teams/{Site.GroupId}/channels")]
public ITeamChannelCollection Channels { get => GetModelCollectionValue<ITeamChannelCollection>(); }
// Set the keyfield for this model class
[KeyProperty(nameof(Id))]
public override object Key { get => Id; set => Id = Guid.Parse(value.ToString()); }
You can set following properties on this attribute:
Property | Required | Description |
---|---|---|
FieldName | Yes | Use this property when the Microsoft Graph fieldname differs from the model property name. Since the field name is required by the default constructor you always need to provide this value when you add this property. |
JsonPath | No | When the information returned from Microsoft Graph is a complex type and you only need a single value from it, then you can specify the JsonPath for that value. E.g. when you get sharePointIds.webId as response you tell the model that the fieldname is sharePointIds and the path to get there is webId. The path can be more complex, using a point to define property you need (e.g. property.child.childofchild). Using JsonPath can be a good alternative over using a Complex Type classes if your scenario only requires to reading some properties. |
Expandable | No | Defines that a collection is expandable, meaning it can be loaded via the $expand query parameter and used in the lambda expression in Get and GetAsync operations. |
ExpandByDefault | No | When the model contains a collection of other model objects then setting this attribute to true will automatically result in the population of that collection. This can negatively impact performance, so only set this when the collection is almost always needed. |
Get | No | Sometimes it is not possible to load the complete model via a single Microsoft Graph request, often this is the case with collections (so the collection is not expandable). In this case you need to explain how to load the collection via specifying the needed query. See model tokens to learn more about the possible tokens you can use. |
UseCustomMapping | No | Allows you to force a callout to the model's MappingHandler event handler whenever this property is populated. See the Event Handlers article to learn more. |
Beta | No | Defines that a model property can only be handled using the Microsoft Graph beta endpoint. If a user opted out of using the Microsoft Graph beta endpoint, then this model property will not be populated. |
Configuring collection classes
Public model (interface) decoration
All model collection classes need to link their concrete type (so the implementation) to the public interface via the ConcreteType
class attribute:
[ConcreteType(typeof(TeamChannelCollection))]
public interface ITeamChannelCollection : IQueryable<ITeamChannel>, IDataModelCollection<ITeamChannel>, IDataModelCollectionLoad<ITeamChannel>, IDataModelCollectionDeleteByStringId
{
// Omitted for brevity
}
Implementing "Add" functionality
In contradiction with get, update, and delete which are fully handled by decorating classes and properties using attributes, you'll need to write actual code to implement add. Adding is implemented as follows:
The public part (interface) is defined on the collection interface. Each functionality (like Add) is implemented via three methods:
- An async method
- An async batch method
- An async batch method that allows to pass in a
Batch
as first method parameter - A sync method that calls the async method with a
GetAwaiter().GetResult()
- A sync batch method that calls the async method with a
GetAwaiter().GetResult()
- A sync batch method that calls the async method with a
GetAwaiter().GetResult()
and allows to pass in aBatch
as first method parameter
Add methods defined on the interface are implemented in the collection classes as proxies that call into the respective add methods of the added model class
The implementation that performs the actual add is implemented as an
AddApiCallHandler
event handler in the model class. See the Event Handlers page for more details.
Below code snippets show the above three concepts. First one shows the collection interface (e.g. ITeamChannelCollection.cs) with the Add methods:
/// <summary>
/// Public interface to define a collection of Team Channels
/// </summary>
[ConcreteType(typeof(TeamChannelCollection))]
public interface ITeamChannelCollection : IQueryable<ITeamChannel>, IDataModelCollection<ITeamChannel>, IDataModelCollectionLoad<ITeamChannel>, IDataModelCollectionDeleteByStringId
{
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public Task<ITeamChannel> AddAsync(string name, string description = null);
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public ITeamChannel Add(string name, string description = null);
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="batch">Batch to use</param>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public Task<ITeamChannel> AddBatchAsync(Batch batch, string name, string description = null);
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="batch">Batch to use</param>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public ITeamChannel AddBatch(Batch batch, string name, string description = null);
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public Task<ITeamChannel> AddBatchAsync(string name, string description = null);
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public ITeamChannel AddBatch(string name, string description = null);
}
Implementation of the interface in the collection class (e.g. TeamChannelCollection.cs):
internal partial class TeamChannelCollection : QueryableDataModelCollection<ITeamChannel>, ITeamChannelCollection
{
public TeamChannelCollection(PnPContext context, IDataModelParent parent, string memberName = null)
: base(context, parent, memberName)
{
this.PnPContext = context;
this.Parent = parent;
}
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public async Task<ITeamChannel> AddAsync(string name, string description = null)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
// TODO: validate name restrictions
var newChannel = CreateNewAndAdd() as TeamChannel;
// Assign field values
newChannel.DisplayName = name;
newChannel.Description = description;
return await newChannel.AddAsync().ConfigureAwait(false) as TeamChannel;
}
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public ITeamChannel Add(string name, string description = null)
{
return AddAsync(name, description).GetAwaiter().GetResult();
}
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="batch">Batch to use</param>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public async Task<ITeamChannel> AddBatchAsync(Batch batch, string name, string description = null)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
var newChannel = CreateNewAndAdd() as TeamChannel;
// Assign field values
newChannel.DisplayName = name;
newChannel.Description = description;
return await newChannel.AddBatchAsync(batch).ConfigureAwait(false) as TeamChannel;
}
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="batch">Batch to use</param>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public ITeamChannel AddBatch(Batch batch, string name, string description = null)
{
return AddBatchAsync(batch, name, description).GetAwaiter().GetResult();
}
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public async Task<ITeamChannel> AddBatchAsync(string name, string description = null)
{
return await AddBatchAsync(PnPContext.CurrentBatch, name, description).ConfigureAwait(false);
}
/// <summary>
/// Adds a new channel
/// </summary>
/// <param name="name">Display name of the channel</param>
/// <param name="description">Optional description of the channel</param>
/// <returns>Newly added channel</returns>
public ITeamChannel AddBatch(string name, string description = null)
{
return AddBatchAsync(name, description).GetAwaiter().GetResult();
}
}
And finally you'll see the actual add logic being implemented in the model class (e.g. TeamChannel.cs) via implementing the AddApiCallHandler
:
internal partial class TeamChannel : BaseDataModel<ITeamChannel>, ITeamChannel
{
private const string baseUri = "teams/{Parent.GraphId}/channels";
internal TeamChannel()
{
// Handler to construct the Add request for this channel
AddApiCallHandler = async (additionalInformation) =>
{
// Define the JSON body of the update request based on the actual changes
dynamic body = new ExpandoObject();
body.displayName = DisplayName;
if (!string.IsNullOrEmpty(Description))
{
body.description = Description;
}
// Serialize object to json
var bodyContent = JsonSerializer.Serialize(body, typeof(ExpandoObject), new JsonSerializerOptions { WriteIndented = false });
var apiCall = await ApiHelper.ParseApiRequestAsync(this, baseUri).ConfigureAwait(false);
return new ApiCall(apiCall, ApiType.Graph, bodyContent);
};
}
}
Providing additional parameters for add requests
The AddApiCall
handler accepts an optional key value pair parameter: Task<ApiCall> AddApiCall(Dictionary<string, object> additionalInformation = null)
. You can use this to provide additional input when you call the Add
from your code in the collection class. Below sample shows how this feature is used to offer different SDK consumer methods for creating Team channel tabs (on the TeamChannelTabCollection
class) while there's only one generic creation method implementation in the TeamChannelTab
class. Let's start with the code in the TeamChannelTabCollection
class:
public async Task<ITeamChannelTab> AddWikiTabAsync(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
(TeamChannelTab newTab, Dictionary<string, object> additionalInformation) = CreateTeamChannelWikiTab(name);
return await newTab.AddAsync(additionalInformation).ConfigureAwait(false) as TeamChannelTab;
}
public async Task<ITeamChannelTab> AddDocumentLibraryTabAsync(string name, Uri documentLibraryUri)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
(TeamChannelTab newTab, Dictionary<string, object> additionalInformation) = CreateTeamChannelDocumentLibraryTab(name, documentLibraryUri);
return await newTab.AddAsync(additionalInformation).ConfigureAwait(false) as TeamChannelTab;
}
private Tuple<TeamChannelTab, Dictionary<string, object>> CreateTeamChannelDocumentLibraryTab(string displayName, Uri documentLibraryUri)
{
var newTab = CreateTeamChannelTab(displayName);
Dictionary<string, object> additionalInformation = new Dictionary<string, object>
{
{ "teamsAppId", "com.microsoft.teamspace.tab.files.sharepoint" },
};
newTab.Configuration = new TeamChannelTabConfiguration
{
EntityId = "",
ContentUrl = documentLibraryUri.ToString()
};
return new Tuple<TeamChannelTab, Dictionary<string, object>>(newTab, additionalInformation);
}
private Tuple<TeamChannelTab, Dictionary<string, object>> CreateTeamChannelWikiTab(string displayName)
{
var newTab = CreateTeamChannelTab(displayName);
Dictionary<string, object> additionalInformation = new Dictionary<string, object>
{
{ "teamsAppId", "com.microsoft.teamspace.tab.wiki" }
};
return new Tuple<TeamChannelTab, Dictionary<string, object>>(newTab, additionalInformation);
}
The code in the TeamChannelTab
class then uses the additional parameter values to drive the creation behavior:
AddApiCallHandler = async (additionalInformation) =>
{
// Define the JSON body of the update request based on the actual changes
dynamic tab = new ExpandoObject();
tab.displayName = DisplayName;
string teamsAppId = additionalInformation["teamsAppId"].ToString();
tab.teamsAppId = teamsAppId;
switch (teamsAppId)
{
case "com.microsoft.teamspace.tab.wiki": // Wiki, no configuration possible
break;
default:
{
tab.Configuration = new ExpandoObject();
if (Configuration.IsPropertyAvailable<ITeamChannelTabConfiguration>(p=>p.EntityId))
{
tab.Configuration.EntityId = Configuration.EntityId;
}
if (Configuration.IsPropertyAvailable<ITeamChannelTabConfiguration>(p => p.ContentUrl))
{
tab.Configuration.ContentUrl = Configuration.ContentUrl;
}
if (Configuration.IsPropertyAvailable<ITeamChannelTabConfiguration>(p => p.RemoveUrl))
{
tab.Configuration.RemoveUrl = Configuration.RemoveUrl;
}
if (Configuration.IsPropertyAvailable<ITeamChannelTabConfiguration>(p => p.WebsiteUrl))
{
tab.Configuration.WebsiteUrl = Configuration.WebsiteUrl;
}
break;
}
}
// Serialize object to json
var bodyContent = JsonSerializer.Serialize(tab, typeof(ExpandoObject), new JsonSerializerOptions { WriteIndented = false });
var parsedApiCall = await ApiHelper.ParseApiRequestAsync(this, baseUri).ConfigureAwait(false);
return new ApiCall(parsedApiCall, ApiType.GraphBeta, bodyContent);
};
Doing additional API calls
Above example showed the AddApiCallHandler
which provides a framework for doing add requests, but you often also need to do other types of requests and for that you need to be able to execute API calls. There are 2 ways to do this:
- Run an API call and automatically load the resulting API call response in the model
- Run an API call and process the resulting JSON as part of your code
Above methods are described in the next chapters.
Running an API call and loading the result in the model
When you know that the API call you're making will return JSON data that has to be loaded into the model then you should use the RequestAsync
method for immediate async processing or Request
method for batch processing. These methods accept an ApiCall
instance as input together with the HttpMethod
.
// to update
Running an API call and processing the resulting JSON as part of your code
Some API calls do return data, but the returned data cannot be loaded into the current model. In those cases you should use the RawRequestAsync
method. This method accepts an ApiCall
instance as input together with the HttpMethod
. Below sample shows how you can archive a Team. The sample shows how the ApiCall
is built and executed via the RawRequestAsync
method. This method returns an ApiCallResponse
object that contains the http response code, the JSON response and additional response headers from the server, which is processed and as a result the recycle bin item id is returned and the list is removed from the model.
public async Task<ITeamAsyncOperation> ArchiveAsync(bool setSPOSiteReadOnlyForMembers)
{
if (Requested)
{
dynamic body = new ExpandoObject();
body.shouldSetSpoSiteReadOnlyForMembers = setSPOSiteReadOnlyForMembers;
var bodyContent = JsonSerializer.Serialize(body, typeof(ExpandoObject), new JsonSerializerOptions { WriteIndented = false });
var apiCall = new ApiCall($"teams/{Id}/archive", ApiType.Graph, bodyContent);
var response = await RawRequestAsync(apiCall, HttpMethod.Post).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.Accepted && response.Headers != null && response.Headers.ContainsKey("Location"))
{
// The archiving operation is in progress, already set the Team IsArchived flag to true
(this as ITeam).SetSystemProperty(p => p.IsArchived, true);
// we get back a url to request a teamsAsyncOperation (https://docs.microsoft.com/en-us/graph/api/resources/teamsasyncoperation?view=graph-rest-beta)
return new TeamAsyncOperation(response.Headers["Location"], PnPContext);
}
}
return null;
}