Article 2 — Implementing SharePoint “Upsert” with Microsoft Graph (WPF Study & Rating App)
This article continues the series by showing a practical, code-forward implementation of saving your YouTube study cards (rating/status/notes) into a SharePoint Online list using Microsoft Graph.
Everything is written for a public post:
- No real tenant URLs
- No real client IDs or secrets
- No real list names required (we’ll use placeholders)
- You can paste this into your blog safely and replace placeholders later when you actually code.
What You’ll Build
From your existing WPF app (search + cards), you’ll add:
Rating(0–10)Status(ToWatch/Watching/Watched/Favorite)NotesSavebutton per card- “Upsert” to SharePoint:
- If the item exists (same
VideoId) → update - Else → create a new item
- If the item exists (same
Prerequisites (Conceptual)
1) A SharePoint list
Example placeholders:
- Site:
https://contoso.sharepoint.com/sites/LearningHub - List:
Learning Videos
Columns:
Title(default)VideoId(Single line text) indexedVideoUrl(Hyperlink)Channel(Single line text)ThumbnailUrl(Single line text)SearchQuery(Single line text)Rating(Number)Status(Choice)Notes(Multiple lines)
Internal column names matter. For a public guide, assume your internal names match the display names above.
2) Entra ID (Azure AD) app registration
You will need an app registration that can acquire Graph tokens (delegated permissions for the signed-in user).
Public-safe guidance: in real life you’ll configure scopes and admin consent as required by your org. Don’t publish real IDs.
Graph Strategy: Use SiteId + ListId
Graph list operations are much easier and more stable when you resolve IDs once and cache them.
You’ll do this:
- Resolve
siteId - Resolve
listId - Upsert items at:
GET /sites/{siteId}/lists/{listId}/items?...POST /sites/{siteId}/lists/{listId}/itemsPATCH /sites/{siteId}/lists/{listId}/items/{itemId}/fields
Step 1 — Authentication (MSAL) and Token Provider
In a WPF desktop app, a clean pattern is:
- One class responsible for tokens:
GraphAuthService - Everything else just asks: “give me an access token”
NuGet packages (when coding later):
Microsoft.Identity.Client
GraphAuthService (template)
using Microsoft.Identity.Client;using System;using System.Threading.Tasks;public sealed class GraphAuthService{ private readonly IPublicClientApplication _app; private readonly string[] _scopes; public GraphAuthService() { // PUBLIC PLACEHOLDERS var clientId = "YOUR_CLIENT_ID"; var tenantId = "YOUR_TENANT_ID"; _scopes = new[] { "https://graph.microsoft.com/.default" }; _app = PublicClientApplicationBuilder .Create(clientId) .WithAuthority(AzureCloudInstance.AzurePublic, tenantId) .WithRedirectUri("http://localhost") .Build(); } public async Task<string> AcquireTokenAsync() { // Basic interactive for simplicity (public article) var result = await _app.AcquireTokenInteractive(_scopes).ExecuteAsync(); return result.AccessToken; }}
When you actually code, you can add “silent token” to avoid prompting every run.
Step 2 — Minimal Graph HTTP Client
Keep it simple: raw HttpClient + Bearer token.
using System.Net.Http;using System.Net.Http.Headers;using System.Threading.Tasks;public sealed class GraphHttpClient{ private readonly GraphAuthService _auth; public GraphHttpClient(GraphAuthService auth) { _auth = auth; } public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req) { var token = await _auth.AcquireTokenAsync(); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using (var http = new HttpClient()) { return await http.SendAsync(req); } }}
Step 3 — Resolve SiteId and ListId
3.1 Resolve SiteId by path
Graph can resolve a site by hostname + site path:
GET https://graph.microsoft.com/v1.0/sites/{hostname}:/sites/{sitePath}
Example placeholders:
- hostname:
contoso.sharepoint.com - sitePath:
LearningHub
So the request becomes:
/sites/contoso.sharepoint.com:/sites/LearningHub
Code (SiteResolver)
using System.Net.Http;using System.Text.Json;using System.Threading.Tasks;public sealed class SiteResolver{ private readonly GraphHttpClient _graph; public SiteResolver(GraphHttpClient graph) { _graph = graph; } public async Task<string> GetSiteIdAsync(string hostname, string sitePath) { var url = "https://graph.microsoft.com/v1.0/sites/" + hostname + ":/sites/" + sitePath; var req = new HttpRequestMessage(HttpMethod.Get, url); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = JsonDocument.Parse(json)) { return doc.RootElement.GetProperty("id").GetString(); } }}
3.2 Resolve ListId by list display name
GET https://graph.microsoft.com/v1.0/sites/{siteId}/lists?$filter=displayName eq 'Learning Videos'
Code:
using System;using System.Net.Http;using System.Text.Json;using System.Threading.Tasks;public sealed class ListResolver{ private readonly GraphHttpClient _graph; public ListResolver(GraphHttpClient graph) { _graph = graph; } public async Task<string> GetListIdByNameAsync(string siteId, string listDisplayName) { var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists?$filter=displayName eq '" + listDisplayName.Replace("'", "''") + "'"; var req = new HttpRequestMessage(HttpMethod.Get, url); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = JsonDocument.Parse(json)) { var value = doc.RootElement.GetProperty("value"); if (value.GetArrayLength() == 0) throw new Exception("List not found: " + listDisplayName); return value[0].GetProperty("id").GetString(); } }}
In production, you’d cache siteId/listId locally to avoid resolving each time.
Step 4 — Upsert by VideoId
4.1 Query existing item by VideoId
Graph filter for fields is used with expand:
GET /sites/{siteId}/lists/{listId}/items?expand=fields&$filter=fields/VideoId eq 'abc123'
Code:
using System;using System.Net.Http;using System.Text.Json;using System.Threading.Tasks;public sealed class VideoItemLookup{ private readonly GraphHttpClient _graph; public VideoItemLookup(GraphHttpClient graph) { _graph = graph; } public async Task<(string itemId, bool found)> FindByVideoIdAsync(string siteId, string listId, string videoId) { var safe = videoId.Replace("'", "''"); var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists/" + listId + "/items?expand=fields&$filter=fields/VideoId eq '" + safe + "'"; var req = new HttpRequestMessage(HttpMethod.Get, url); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = JsonDocument.Parse(json)) { var value = doc.RootElement.GetProperty("value"); if (value.GetArrayLength() == 0) return ("", false); var itemId = value[0].GetProperty("id").GetString(); return (itemId, true); } }}
4.2 Create new item
POST /sites/{siteId}/lists/{listId}/items{ "fields": { ... }}
Code:
using System.Net.Http;using System.Text;using System.Text.Json;using System.Threading.Tasks;public sealed class VideoItemCreator{ private readonly GraphHttpClient _graph; public VideoItemCreator(GraphHttpClient graph) { _graph = graph; } public async Task CreateAsync(string siteId, string listId, object fields) { var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists/" + listId + "/items"; var payload = JsonSerializer.Serialize(new { fields = fields }); var req = new HttpRequestMessage(HttpMethod.Post, url); req.Content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); }}
4.3 Update existing item fields
PATCH /sites/{siteId}/lists/{listId}/items/{itemId}/fields{ ... }
Code:
using System.Net.Http;using System.Text;using System.Text.Json;using System.Threading.Tasks;public sealed class VideoItemUpdater{ private readonly GraphHttpClient _graph; public VideoItemUpdater(GraphHttpClient graph) { _graph = graph; } public async Task UpdateFieldsAsync(string siteId, string listId, string itemId, object fields) { var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists/" + listId + "/items/" + itemId + "/fields"; var payload = JsonSerializer.Serialize(fields); var req = new HttpRequestMessage(new HttpMethod("PATCH"), url); req.Content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); }}
4.4 The Upsert Service
using System.Threading.Tasks;public sealed class SharePointVideoUpsertService{ private readonly VideoItemLookup _lookup; private readonly VideoItemCreator _creator; private readonly VideoItemUpdater _updater; public SharePointVideoUpsertService(VideoItemLookup lookup, VideoItemCreator creator, VideoItemUpdater updater) { _lookup = lookup; _creator = creator; _updater = updater; } public async Task UpsertAsync(string siteId, string listId, VideoRecord record) { var found = await _lookup.FindByVideoIdAsync(siteId, listId, record.VideoId); var fields = new { Title = record.Title, VideoId = record.VideoId, VideoUrl = record.VideoUrl, Channel = record.Channel, ThumbnailUrl = record.ThumbnailUrl, SearchQuery = record.SearchQuery, Rating = record.Rating, Status = record.Status, Notes = record.Notes }; if (!found.found) { await _creator.CreateAsync(siteId, listId, fields); } else { // For updates you can send only changed fields if you want await _updater.UpdateFieldsAsync(siteId, listId, found.itemId, fields); } }}
And the DTO:
public sealed class VideoRecord{ public string VideoId { get; set; } public string Title { get; set; } public string VideoUrl { get; set; } public string Channel { get; set; } public string ThumbnailUrl { get; set; } public string SearchQuery { get; set; } public int Rating { get; set; } public string Status { get; set; } public string Notes { get; set; }}
Step 5 — Connect Upsert to Your WPF Cards (MVVM pattern)
You have two clean options:
Option 1: Save command lives in MainViewModel
- Add
SaveVideoCommand - Bind the button on the card with
CommandParameter="{Binding}"
Inside VM:
- convert
VideoCard→VideoRecord - call
_upsertService.UpsertAsync(...)
Option 2: Save command lives inside each card
This can work but mixes responsibilities. For public guidance, keep “save” logic in VM or a service.
Operational Notes: Columns and Internal Names
SharePoint sometimes uses internal names that differ from display names. In production:
- confirm internal names for
VideoId,Rating,Status,Notes - adjust field JSON accordingly
This is the #1 source of “it returns 400” pain in real projects.
Practical Enhancements
- Cache
siteIdandlistIdlocally - Add retry/backoff for transient failures
- Add “Saved ✅” status in the UI
- Add “My Library” tab that reads from SharePoint
Wrap-Up
With Microsoft Graph you can implement a robust, modern persistence layer for your YouTube study app:
- a card UI becomes a personal knowledge database
- SharePoint lists become your “learning ledger”
- upsert by VideoId prevents duplicates
- MVVM keeps UI clean and testable
If you say “y” again, I’ll publish Article 3 next:
“Same Upsert using SharePoint REST API (direct _api calls, ETag, headers, payload shape)”Article 2 — Implementing SharePoint “Upsert” with Microsoft Graph (WPF Study & Rating App)
This article continues the series by showing a practical, code-forward implementation of saving your YouTube study cards (rating/status/notes) into a SharePoint Online list using Microsoft Graph.
Everything is written for a public post:
- No real tenant URLs
- No real client IDs or secrets
- No real list names required (we’ll use placeholders)
- You can paste this into your blog safely and replace placeholders later when you actually code.
What You’ll Build
From your existing WPF app (search + cards), you’ll add:
Rating(0–10)Status(ToWatch/Watching/Watched/Favorite)NotesSavebutton per card- “Upsert” to SharePoint:
- If the item exists (same
VideoId) → update - Else → create a new item
- If the item exists (same
Prerequisites (Conceptual)
1) A SharePoint list
Example placeholders:
- Site:
https://contoso.sharepoint.com/sites/LearningHub - List:
Learning Videos
Columns:
Title(default)VideoId(Single line text) indexedVideoUrl(Hyperlink)Channel(Single line text)ThumbnailUrl(Single line text)SearchQuery(Single line text)Rating(Number)Status(Choice)Notes(Multiple lines)
Internal column names matter. For a public guide, assume your internal names match the display names above.
2) Entra ID (Azure AD) app registration
You will need an app registration that can acquire Graph tokens (delegated permissions for the signed-in user).
Public-safe guidance: in real life you’ll configure scopes and admin consent as required by your org. Don’t publish real IDs.
Graph Strategy: Use SiteId + ListId
Graph list operations are much easier and more stable when you resolve IDs once and cache them.
You’ll do this:
- Resolve
siteId - Resolve
listId - Upsert items at:
GET /sites/{siteId}/lists/{listId}/items?...POST /sites/{siteId}/lists/{listId}/itemsPATCH /sites/{siteId}/lists/{listId}/items/{itemId}/fields
Step 1 — Authentication (MSAL) and Token Provider
In a WPF desktop app, a clean pattern is:
- One class responsible for tokens:
GraphAuthService - Everything else just asks: “give me an access token”
NuGet packages (when coding later):
Microsoft.Identity.Client
GraphAuthService (template)
using Microsoft.Identity.Client;using System;using System.Threading.Tasks;public sealed class GraphAuthService{ private readonly IPublicClientApplication _app; private readonly string[] _scopes; public GraphAuthService() { // PUBLIC PLACEHOLDERS var clientId = "YOUR_CLIENT_ID"; var tenantId = "YOUR_TENANT_ID"; _scopes = new[] { "https://graph.microsoft.com/.default" }; _app = PublicClientApplicationBuilder .Create(clientId) .WithAuthority(AzureCloudInstance.AzurePublic, tenantId) .WithRedirectUri("http://localhost") .Build(); } public async Task<string> AcquireTokenAsync() { // Basic interactive for simplicity (public article) var result = await _app.AcquireTokenInteractive(_scopes).ExecuteAsync(); return result.AccessToken; }}
When you actually code, you can add “silent token” to avoid prompting every run.
Step 2 — Minimal Graph HTTP Client
Keep it simple: raw HttpClient + Bearer token.
using System.Net.Http;using System.Net.Http.Headers;using System.Threading.Tasks;public sealed class GraphHttpClient{ private readonly GraphAuthService _auth; public GraphHttpClient(GraphAuthService auth) { _auth = auth; } public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req) { var token = await _auth.AcquireTokenAsync(); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using (var http = new HttpClient()) { return await http.SendAsync(req); } }}
Step 3 — Resolve SiteId and ListId
3.1 Resolve SiteId by path
Graph can resolve a site by hostname + site path:
GET https://graph.microsoft.com/v1.0/sites/{hostname}:/sites/{sitePath}
Example placeholders:
- hostname:
contoso.sharepoint.com - sitePath:
LearningHub
So the request becomes:
/sites/contoso.sharepoint.com:/sites/LearningHub
Code (SiteResolver)
using System.Net.Http;using System.Text.Json;using System.Threading.Tasks;public sealed class SiteResolver{ private readonly GraphHttpClient _graph; public SiteResolver(GraphHttpClient graph) { _graph = graph; } public async Task<string> GetSiteIdAsync(string hostname, string sitePath) { var url = "https://graph.microsoft.com/v1.0/sites/" + hostname + ":/sites/" + sitePath; var req = new HttpRequestMessage(HttpMethod.Get, url); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = JsonDocument.Parse(json)) { return doc.RootElement.GetProperty("id").GetString(); } }}
3.2 Resolve ListId by list display name
GET https://graph.microsoft.com/v1.0/sites/{siteId}/lists?$filter=displayName eq 'Learning Videos'
Code:
using System;using System.Net.Http;using System.Text.Json;using System.Threading.Tasks;public sealed class ListResolver{ private readonly GraphHttpClient _graph; public ListResolver(GraphHttpClient graph) { _graph = graph; } public async Task<string> GetListIdByNameAsync(string siteId, string listDisplayName) { var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists?$filter=displayName eq '" + listDisplayName.Replace("'", "''") + "'"; var req = new HttpRequestMessage(HttpMethod.Get, url); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = JsonDocument.Parse(json)) { var value = doc.RootElement.GetProperty("value"); if (value.GetArrayLength() == 0) throw new Exception("List not found: " + listDisplayName); return value[0].GetProperty("id").GetString(); } }}
In production, you’d cache siteId/listId locally to avoid resolving each time.
Step 4 — Upsert by VideoId
4.1 Query existing item by VideoId
Graph filter for fields is used with expand:
GET /sites/{siteId}/lists/{listId}/items?expand=fields&$filter=fields/VideoId eq 'abc123'
Code:
using System;using System.Net.Http;using System.Text.Json;using System.Threading.Tasks;public sealed class VideoItemLookup{ private readonly GraphHttpClient _graph; public VideoItemLookup(GraphHttpClient graph) { _graph = graph; } public async Task<(string itemId, bool found)> FindByVideoIdAsync(string siteId, string listId, string videoId) { var safe = videoId.Replace("'", "''"); var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists/" + listId + "/items?expand=fields&$filter=fields/VideoId eq '" + safe + "'"; var req = new HttpRequestMessage(HttpMethod.Get, url); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = JsonDocument.Parse(json)) { var value = doc.RootElement.GetProperty("value"); if (value.GetArrayLength() == 0) return ("", false); var itemId = value[0].GetProperty("id").GetString(); return (itemId, true); } }}
4.2 Create new item
POST /sites/{siteId}/lists/{listId}/items{ "fields": { ... }}
Code:
using System.Net.Http;using System.Text;using System.Text.Json;using System.Threading.Tasks;public sealed class VideoItemCreator{ private readonly GraphHttpClient _graph; public VideoItemCreator(GraphHttpClient graph) { _graph = graph; } public async Task CreateAsync(string siteId, string listId, object fields) { var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists/" + listId + "/items"; var payload = JsonSerializer.Serialize(new { fields = fields }); var req = new HttpRequestMessage(HttpMethod.Post, url); req.Content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); }}
4.3 Update existing item fields
PATCH /sites/{siteId}/lists/{listId}/items/{itemId}/fields{ ... }
Code:
using System.Net.Http;using System.Text;using System.Text.Json;using System.Threading.Tasks;public sealed class VideoItemUpdater{ private readonly GraphHttpClient _graph; public VideoItemUpdater(GraphHttpClient graph) { _graph = graph; } public async Task UpdateFieldsAsync(string siteId, string listId, string itemId, object fields) { var url = "https://graph.microsoft.com/v1.0/sites/" + siteId + "/lists/" + listId + "/items/" + itemId + "/fields"; var payload = JsonSerializer.Serialize(fields); var req = new HttpRequestMessage(new HttpMethod("PATCH"), url); req.Content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await _graph.SendAsync(req); resp.EnsureSuccessStatusCode(); }}
4.4 The Upsert Service
using System.Threading.Tasks;public sealed class SharePointVideoUpsertService{ private readonly VideoItemLookup _lookup; private readonly VideoItemCreator _creator; private readonly VideoItemUpdater _updater; public SharePointVideoUpsertService(VideoItemLookup lookup, VideoItemCreator creator, VideoItemUpdater updater) { _lookup = lookup; _creator = creator; _updater = updater; } public async Task UpsertAsync(string siteId, string listId, VideoRecord record) { var found = await _lookup.FindByVideoIdAsync(siteId, listId, record.VideoId); var fields = new { Title = record.Title, VideoId = record.VideoId, VideoUrl = record.VideoUrl, Channel = record.Channel, ThumbnailUrl = record.ThumbnailUrl, SearchQuery = record.SearchQuery, Rating = record.Rating, Status = record.Status, Notes = record.Notes }; if (!found.found) { await _creator.CreateAsync(siteId, listId, fields); } else { // For updates you can send only changed fields if you want await _updater.UpdateFieldsAsync(siteId, listId, found.itemId, fields); } }}
And the DTO:
public sealed class VideoRecord{ public string VideoId { get; set; } public string Title { get; set; } public string VideoUrl { get; set; } public string Channel { get; set; } public string ThumbnailUrl { get; set; } public string SearchQuery { get; set; } public int Rating { get; set; } public string Status { get; set; } public string Notes { get; set; }}
Step 5 — Connect Upsert to Your WPF Cards (MVVM pattern)
You have two clean options:
Option 1: Save command lives in MainViewModel
- Add
SaveVideoCommand - Bind the button on the card with
CommandParameter="{Binding}"
Inside VM:
- convert
VideoCard→VideoRecord - call
_upsertService.UpsertAsync(...)
Option 2: Save command lives inside each card
This can work but mixes responsibilities. For public guidance, keep “save” logic in VM or a service.
Operational Notes: Columns and Internal Names
SharePoint sometimes uses internal names that differ from display names. In production:
- confirm internal names for
VideoId,Rating,Status,Notes - adjust field JSON accordingly
This is the #1 source of “it returns 400” pain in real projects.
Practical Enhancements
- Cache
siteIdandlistIdlocally - Add retry/backoff for transient failures
- Add “Saved ✅” status in the UI
- Add “My Library” tab that reads from SharePoint
Wrap-Up
With Microsoft Graph you can implement a robust, modern persistence layer for your YouTube study app:
- a card UI becomes a personal knowledge database
- SharePoint lists become your “learning ledger”
- upsert by VideoId prevents duplicates
- MVVM keeps UI clean and testable
