The Kladde has appointments, contacts and a mail connection. But the office of Nordlicht Reisen GmbH works in Microsoft 365: that is where the
calendars, the mailboxes and the files live. In this course the Kladde learns to work with that data. The way there is called Microsoft Graph.
This lesson shows what Graph is, how a request is built, how to try it out, narrow it down and read it page by page, and how the
.NET SDK pours all of that into code. At the end there is a small console tool, Kladde.GraphLab, that fetches your profile and the day's
appointments, together with tests that run without a Microsoft 365 account.
State of verification, please read first. Every factual statement in this lesson comes from pages on Microsoft Learn that were read while the lesson was written. No command and no code of this lesson was run against a Microsoft 365 tenant, and the code in the form given here has not been built. Where an output would appear, the text describes what Learn leads you to expect instead of faking an output. Where we have not read something, it is marked “open”. If your compiler or your tenant says something different from this lesson, your finding counts, and the lesson deserves a correction.
What you bring from the earlier courses is not repeated in this lesson: you know signing in with Microsoft Entra ID from lesson 2.6.2
“OpenID Connect with Microsoft Entra ID”, the HttpClient and the faked HttpMessageHandler from lesson 2.8.1 “HttpClientFactory and an
external API”, and mail through Graph from lesson 2.8.5 “Sending mail, confirmed”. Here the subject is what lies behind all that.
What Microsoft Graph is
Learn describes Microsoft Graph as the gateway to data and insights in Microsoft's cloud services, including Microsoft Entra and
Microsoft 365. The core is the Microsoft Graph API: a single endpoint, https://graph.microsoft.com, through which you reach the data of very different
services by REST or with an SDK. According to Learn, the services include calendar, Excel, OneDrive,
Outlook and Exchange, people, Planner, SharePoint, Teams and To Do, as well as Microsoft Entra, Intune and more. Learn gives the complete list
on the page “Major services and features in Microsoft Graph” (Further reading).
For us that means: one address, one kind of sign-in, one token for many services. Once you have connected the Kladde to Graph, you reach the calendar, the mail and the files with the same means. How this token comes about and what it allows is the subject of lesson W4.1.2.
Besides the Graph API, Learn names two more building blocks that you should know by name because they sound alike:
- Microsoft 365 Copilot connectors (formerly Microsoft Graph connectors) work in the opposite direction: they bring data from outside the Microsoft cloud into Graph, for example from a personnel or product database, so that it shows up in Microsoft Search, for instance.
- Microsoft Graph Data Connect delivers Graph data in large amounts and on a schedule to Azure data stores.
Learn contrasts the Graph API and Data Connect like this:
| Property | Microsoft Graph API | Microsoft Graph Data Connect |
|---|---|---|
| Scope | a single person or the whole tenant | many people or groups |
| Access pattern | in real time | on a recurring schedule |
| Data basis | works on the source data | works on a copy (cache) of the data |
| Access tool | RESTful web queries | Azure Data Factory |
The Kladde needs real time for individual mailboxes and calendars. This course therefore covers only the Graph API. Copilot connectors and Data Connect do not appear.
Reading a request
For every request, Learn gives this pattern:
{HTTP method} https://graph.microsoft.com/{version}/{resource}?{query-parameters}
The parts:
- Method: says what is to happen.
- Version:
v1.0orbeta(next section). - Resource: what is being talked about, for example
me,users,groups,driveorsite. Resources often have relationships that you follow on:me/messagesare the messages of the signed-in person,me/driveis their OneDrive. There are also methods on resources: you send mail withme/sendMail. - Query options: narrow down the response or fill in parameters of a method.
- Headers: standard headers and custom ones; some calls require particular ones.
On the reference pages, many calls are written in two ways, for example /me/calendarView and /users/{id | userPrincipalName}/calendarView. The
first means the signed-in person, the second a person you name. Why this matters you will see in lesson W4.1.2: with
permissions that have no signed-in person, there is no “signed-in person”, and you have to name the person.
The method determines the effect. Learn lists these:
| Method | Effect |
|---|---|
GET |
read the data of a resource |
POST |
create a new resource or perform an action |
PATCH |
update a resource with new values (for some resources also create it if it does not exist) |
PUT |
replace a resource with a new one |
DELETE |
remove a resource |
GET and DELETE need no request body, POST, PATCH and PUT do, usually JSON. A limit you should know about: requests that write are limited to 4 MB, in some
cases to less. Learn gives as an example an attachment to an appointment (POST /me/events/{id}/attachments), where 3 MB applies, because a file of about 3.5 MB can grow larger than 4 MB in Base64. Requests that are too large fail
with the status 413.
The response consists of a status code, the data or the result of the action (for some calls the body is empty), a reference URL
@odata.nextLink if there is more data (see “Pages”), and headers. Remember two headers now, because you will need them in operation:
request-id belongs to every response and ties it to the request, and Retry-After is in the response when your app is being throttled. According to RFC 9110, which Learn cites, headers
are not case-sensitive. How to deal with throttling is covered in lesson W4.3.2.
Two versions: v1.0 and beta
Graph has two versions:
v1.0contains the generally available interfaces. Learn: “Use the v1.0 version for all production apps.” According to Learn, changes to these interfaces are additive and do not break existing scenarios.betacontains interfaces in preview. Learn warns explicitly: they can change without notice and break existing scenarios, and there is no promise that a beta feature will be carried over intov1.0. Take no production dependency onbeta. Beta calls belong at most in experiments.
Two additions from Learn's page on versioning and support that affect your programming:
- Among the backward-compatible changes, Learn counts, among others, new properties that may be empty or have a default value, new values in an enumeration, the introduction of paging on existing collections, and changes to error codes. So your code must not crash when the response contains properties it does not know, and it must not rely on any order of properties. The SDK takes this off your hands; anyone who reads responses themselves has to keep it in mind.
- Learn declares a version or a generally available interface deprecated at least 24 months before it is shut down. For the SDK something shorter applies: only the latest major version gets new features and bug fixes, and the previous one still gets security fixes for 12 months. So plan for SDK updates.
The version sits in the address (https://graph.microsoft.com/v1.0/... or .../beta/...). There are two packages for the SDK, more on that shortly.
Graph Explorer
Before you write code, try a request in Graph Explorer, a web tool from Microsoft. According to Learn, it is for trying out APIs in a sample tenant, signing in to your own tenant when needed, getting to know the permissions required, and fetching code snippets.
- Without signing in, the prepared sample queries (for example “Get my profile”) run against Microsoft's sample tenant. You need no
account. According to Learn, you try requests that write (
POST,PUT,PATCH,DELETE) only after signing in. - After signing in you work in your own tenant and may also try
POST,PUT,PATCHandDELETE. For that, Learn explicitly recommends the Microsoft 365 developer sandbox tenant rather than a production tenant. The reason is stated there as well: requests that write can change the data of the tenant you signed in to. - If a request fails because of missing permissions, the Explorer shows the message and offers Modify permissions. There you consent to the required permissions and run the request again.
- The Code snippets tab shows the same request as code in C#, JavaScript, Java, Go and PowerShell. The C# snippet is a good starting point for your own code.
- For every request, Documentation points to the reference page with the permissions and parameters. The history keeps the requests of the last 30 days.
Two points are open for this course: whether, and under which conditions, the Microsoft 365 developer program is open to you as a learner, we have not checked; that is Microsoft's decision and is stated on their pages. And we did not operate Graph Explorer ourselves for this lesson: the description is Learn's.
Query options: ask for what you need
Graph supports many of the OData system query options, but only with GET. Which ones a call supports is stated in the call's reference in the section
“Optional query parameters”, and it differs between calls and between v1.0 and beta. Learn advises always writing the $ character,
even though it is optional with beta and with some calls.
| Option | Effect | Learn's example |
|---|---|---|
$select |
only certain properties (columns) | /users?$select=givenName,surname |
$filter |
only certain items (rows) | /users?$filter=startswith(givenName,'J') |
$orderby |
order | /users?$orderby=displayName desc |
$top |
page size, at least 1, the maximum depends on the call | /me/messages?$top=5 |
$skip |
skip items; some calls use it for paging | /me/events?$orderby=createdDateTime&$skip=20 |
$count |
number of matches | /me/messages?$top=2&$count=true |
$expand |
deliver related resources right away | /me/drive/root?$expand=children |
$search |
search term | /me/messages?$search=pizza |
Add $skiptoken for the next page (coming up shortly). Not every call supports every option, and the rules are subtle. We go through the important ones.
$select: fetch only what you need
$select limits the properties. That saves transfer and, according to Learn, is a performance measure. On a GET without $select, Graph adds a
property @microsoft.graph.tips that makes exactly this recommendation. So the property is a hint, not an error. For some resources,
Graph returns properties only if you request them with $select.
GET https://graph.microsoft.com/v1.0/me/messages?$select=from,subject
$filter: only the matching rows
The syntax of filter expressions is a world of its own, which Learn describes on a page of its own (Further reading); we have not read that page. One case from the page on query options often goes wrong: you double a single quotation mark in the value.
GET https://graph.microsoft.com/v1.0/me/messages?$filter=subject eq 'let''s meet for lunch?'
The values of query options must be percent-encoded according to RFC 3986. Almost all HTTP clients, browsers and Graph Explorer do this themselves. If a query fails in a strange way, wrong encoding is one possible cause. The SDK encodes for you.
$orderby, $top and $skip
$orderby takes one or more properties separated by commas, each with desc for descending. Learn shows an example with two keys:
GET https://graph.microsoft.com/v1.0/me/mailFolders/Inbox/messages?$orderby=from/emailAddress/name desc,subject
$top sets the page size. If more items are left, the response contains @odata.nextLink. With some calls, $skip is the second way to get pages.
Not every call supports both: Learn says, for example, that /users supports $top but not $skip.
$count
$count comes in two forms: as the query option $count=true, which, alongside the page of data, delivers a number in @odata.count, and as
the address segment /$count, which delivers only the number (users/$count). For resources derived from directoryObject (users, groups and other
directory objects), $count works only in an advanced query with the header ConsistencyLevel, which Learn describes on the page “Advanced query
capabilities on directory objects”. We have not read that page. For mailbox and calendar, which this course is about, you do not need
it.
$expand
$expand fetches a related resource or collection right away, so it saves a second request. There are limits, which Learn lists:
- Not every relationship can be expanded. On a user it works, for example, for
managerandmemberOf, but not forevents,messagesorphoto. - For directory objects (users, groups),
$expandusually returns at most 20 items of the relationship and no@odata.nextLink. $expanddoes not work together with advanced queries.
Graph sometimes reports an unsupported $expand as an error (ExpandNotSupported for me?$expand=photo), but sometimes it silently does not. This applies
to all query options: Learn says that unsupported options and combinations can have no effect without an error. So check in the
response whether your option took effect. A test with a faked handler cannot prove that; a run against the real service can.
Pages: the response comes in parts
Collections come in pages. Learn distinguishes two kinds:
- Server-side: Graph delivers a default number per page on its own, for
GET /users, for example, 100. - Client-side: you set the page size with
$top(or$skip,$skipToken), as far as the call allows.
If there is at least one more page, the response contains @odata.nextLink with an address, for example:
"@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$top=5&$skiptoken=RFNwdAIAAQAAAD8...AAAAAAAA"
The rules for this, each with consequences for your code:
- Reading all results means asking again until no
@odata.nextLinkcomes back. - Take the whole address from
@odata.nextLink. Do not pull out$skiptokenor$skipand build your own request with it; the other options of the first request are already in the address. - A page can have zero results and there is still a next one. A test that uses
Count == 0as the stop condition is wrong. - Default and maximum sizes differ per call. If you ask for more with
$topthan the call allows, you get, depending on the call, the maximum size, the request is ignored, or an error comes back. Not every resource supports paging. - Custom headers of the first request (everything except
AuthorizationandContent-Type) are not automatically carried into the follow-up requests for directory objects. The SDK says this too (see below). If you need a header, set it again on every page. - With
$count=trueon directory objects,@odata.countis only in the first page. - Retries: if a call retried after a network error and received a new address in the process, that one is not valid for the next page;
the one from the last successful response is. Otherwise you risk the error
DirectoryPageTokenNotFoundException. So remember the address of the last successful page.
The SDK
Anyone who calls Graph from .NET does not write URLs by hand. Microsoft maintains libraries for that. For .NET they are:
- the NuGet package
Microsoft.Graphforv1.0(depends onMicrosoft.Graph.Core), and Microsoft.Graph.Betafor thebetaendpoint (see above: not for operation).
The code samples in Learn's reference carry the note “Current version is 5.x”: this course writes in the style of that major version. Older examples on the web
with .Request() before .GetAsync() belong to version 4 and earlier; Learn's examples for version 5 do not have that intermediate step.
The entry point to everything is the GraphServiceClient. Learn says explicitly: One instance may live for the lifetime of the application. For the Kladde
that means: one client, built once, registered as a singleton in dependency injection (lesson 2.3.3 “Dependency injection”), not one per request.
Requests with request builders
The SDK uses a fluent style: the path of the URL becomes properties and indexers, and the method comes at the end.
// GET https://graph.microsoft.com/v1.0/me
var user = await graphClient.Me
.GetAsync();
You set query options and headers in a lambda that receives the request configuration. The following example is from Learn:
// GET https://graph.microsoft.com/v1.0/me?$select=displayName,jobTitle
var user = await graphClient.Me
.GetAsync(requestConfiguration =>
{
requestConfiguration.QueryParameters.Select =
["displayName", "jobTitle"];
});
The properties for the query options are named like the options, without the $. From Learn's examples:
| Option | SDK property | Type in the example |
|---|---|---|
$select |
QueryParameters.Select |
list of strings |
$filter |
QueryParameters.Filter |
string |
$orderby |
QueryParameters.Orderby |
list of strings, each entry a property and possibly desc |
$top |
QueryParameters.Top |
integer |
$skip |
QueryParameters.Skip |
integer |
$count |
QueryParameters.Count |
true or false |
$expand |
QueryParameters.Expand |
list of strings |
You reach one item of a collection with an indexer, graphClient.Me.Messages[messageId].GetAsync(); deleting is DeleteAsync(), creating is
PostAsync(object), updating is PatchAsync(object). With PATCH you pass, according to Learn, only the properties that are to change.
Headers go into requestConfiguration.Headers, for example Prefer with outlook.timezone for the time zone of the times in the result (lesson W4.2.2
comes back to this). Calls with parameters of their own, such as calendarView, have properties of their own in QueryParameters: StartDateTime and EndDateTime.
What this course has not read: whether and how a single call is canceled with a CancellationToken. Learn's examples do not show it. So there is none in
this lesson's code samples; if your compiler offers a parameter for it on GetAsync, use it.
Paging with the SDK
The class PageIterator reads a paged collection: it fetches the follow-up pages itself and calls a function for every item. Learn's example
(shortened to the essentials):
var messages = await graphClient.Me.Messages
.GetAsync(requestConfiguration =>
{
requestConfiguration.QueryParameters.Top = 10;
requestConfiguration.QueryParameters.Select =
["sender", "subject", "body"];
requestConfiguration.Headers.Add(
"Prefer", "outlook.body-content-type=\"text\"");
});
var pageIterator = PageIterator<Message, MessageCollectionResponse>
.CreatePageIterator(
graphClient,
messages,
(msg) =>
{
Console.WriteLine(msg.Subject);
return true;
},
(req) =>
{
// Re-add the header to subsequent requests
req.Headers.Add("Prefer", "outlook.body-content-type=\"text\"");
return req;
});
await pageIterator.IterateAsync();
Three things about it matter:
- The function for each item returns
trueto continue. Withfalse, the iterator pauses;ResumeAsync()continues (Learn shows an example withpageIterator.State != PagingState.Complete). - The fourth parameter configures the follow-up requests, here to set the
Preferheader again: headers of the first request do not apply to the follow-up pages on their own (rule 5 above). Without this parameter, the texts of the follow-up pages would arrive in a different format. - The page size can go up to 999 (Learn mentions this for messages as an example in a note); the examples use 10 so that pages become visible.
The manual way is just as valid: you read OdataNextLink of the response and keep asking with WithUrl:
var messages = await graphClient.Me.Messages
.GetAsync(requestConfiguration =>
{
requestConfiguration.QueryParameters.Top = 10;
});
while (messages?.Value != null)
{
foreach (var message in messages.Value)
{
Console.WriteLine(message.Subject);
}
if (!string.IsNullOrEmpty(messages.OdataNextLink))
{
messages = await graphClient.Me.Messages
.WithUrl(messages.OdataNextLink)
.GetAsync();
}
else
{
break;
}
}
For the Kladde we use the PageIterator, because it already observes the rules from above (whole address, zero results on a page).
The first call: signing in with a TokenCredential
A GraphServiceClient needs a way to a token. The SDK gets it as a TokenCredential from the package Azure.Identity and a list of
scopes: new GraphServiceClient(credential, scopes). Which TokenCredential class fits depends on the kind of application; Learn has
a table for this (“Choose an authentication provider”): a person in a browser, a console tool with a device code, a service without a person with a key. For our
tool we take Learn's example: the device code. The program prints a message that you confirm in a browser with a code.
Learn names two conditions for this flow: it works only for public client applications, so the app registration needs the setting Allow public
client flows; and the registration must know which scopes the app may request (lesson W4.1.2). The tool's permissions are User.Read (profile of the
signed-in person) and Calendars.Read (read calendar). The smaller permission Calendars.ReadBasic would, according to Learn, be the least privileged one for calendarView; whether it is enough for our
tool, since to our knowledge it does not deliver all properties of the appointment, is open and becomes part of the permissions list in lesson W4.1.2.
// src/Kladde.GraphLab/GraphLabClient.cs
using Azure.Identity;
using Microsoft.Graph;
namespace Kladde.GraphLab;
/// <summary>Builds the tool's one GraphServiceClient: a signed-in person, the device code flow, the smallest scopes the tool needs.</summary>
public static class GraphLabClient
{
public static readonly string[] Scopes = ["User.Read", "Calendars.Read"];
public static GraphServiceClient Create(
string tenantId,
string clientId,
Func<DeviceCodeInfo, CancellationToken, Task> prompt)
{
DeviceCodeCredentialOptions options = new()
{
TenantId = tenantId,
ClientId = clientId,
DeviceCodeCallback = prompt,
};
return new GraphServiceClient(new DeviceCodeCredential(options), Scopes);
}
}
tenantId and clientId come from the app registration (placeholders here: <tenant-id>, <client-id>). Neither is a secret; there is no client secret with
this kind of sign-in, and the tool does not need one. The tool reads them from environment variables and does not write them into the code.
The profile and the day's appointments as two small classes that are handed the client (so that the tests can swap it out):
// src/Kladde.GraphLab/WhoAmI.cs
using Microsoft.Graph;
using Microsoft.Graph.Models;
namespace Kladde.GraphLab;
/// <summary>GET me, with only the two properties the tool shows.</summary>
public sealed class WhoAmI(GraphServiceClient graph)
{
public async Task<string> DescribeAsync()
{
User? me = await graph.Me.GetAsync(request =>
{
request.QueryParameters.Select = ["displayName", "jobTitle"];
});
return me is null ? "(no answer)" : $"{me.DisplayName} ({me.JobTitle})";
}
}
// src/Kladde.GraphLab/CalendarDay.cs
using System.Globalization;
using Microsoft.Graph;
using Microsoft.Graph.Models;
namespace Kladde.GraphLab;
/// <summary>One entry of the day. Start is the text Graph sends, in UTC unless a time zone is asked for.</summary>
public sealed record DayEntry(string Subject, string Start);
/// <summary>GET me/calendarView for one calendar day in the caller's time zone, every page.</summary>
public sealed class CalendarDay(GraphServiceClient graph)
{
public async Task<IReadOnlyList<DayEntry>> ReadAsync(DateOnly day, TimeZoneInfo zone)
{
string from = Midnight(day, zone);
string until = Midnight(day.AddDays(1), zone);
EventCollectionResponse? first = await graph.Me.CalendarView.GetAsync(request =>
{
request.QueryParameters.StartDateTime = from;
request.QueryParameters.EndDateTime = until;
request.QueryParameters.Select = ["subject", "start"];
});
if (first is null)
{
return [];
}
List<DayEntry> entries = [];
PageIterator<Event, EventCollectionResponse> pages = PageIterator<Event, EventCollectionResponse>
.CreatePageIterator(
graph,
first,
item =>
{
entries.Add(new DayEntry(item.Subject ?? "", item.Start?.DateTime ?? ""));
return true;
});
await pages.IterateAsync();
// Same format, all UTC: ordinal order of the text is the order in time.
return entries.OrderBy(entry => entry.Start, StringComparer.Ordinal).ToList();
}
// The window is given with an offset, so Graph does not read it as UTC (calendarView: no offset means UTC).
private static string Midnight(DateOnly day, TimeZoneInfo zone)
{
DateTime local = day.ToDateTime(TimeOnly.MinValue);
return new DateTimeOffset(local, zone.GetUtcOffset(local))
.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
}
}
What matters about calendarView, from Learn's reference: startDateTime and endDateTime are required and are read with the offset in the value; without an offset, UTC applies, and the
header Prefer: outlook.timezone changes nothing about that. That header only determines the time zone in which start and end appear in the result; without it they are UTC times. That is why
Midnight supplies the offset, and DayEntry.Start is UTC here. The nice display in local time, the structure of dateTimeTimeZone and special cases such as daylight saving time are covered in
lesson W4.2.2; open here is which properties dateTimeTimeZone has in the SDK exactly: we did not read the reference page for it, and we only pick up DateTime, as Learn's
examples for event suggest.
The program that ties both together:
// src/Kladde.GraphLab/Program.cs
using Kladde.GraphLab;
using Microsoft.Graph;
string? tenantId = Environment.GetEnvironmentVariable("GRAPHLAB_TENANT_ID");
string? clientId = Environment.GetEnvironmentVariable("GRAPHLAB_CLIENT_ID");
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || args.Length == 0)
{
Console.Error.WriteLine("Set GRAPHLAB_TENANT_ID and GRAPHLAB_CLIENT_ID, then run: Kladde.GraphLab me | day");
return 2;
}
GraphServiceClient graph = GraphLabClient.Create(tenantId, clientId, (code, _) =>
{
Console.WriteLine(code.Message);
return Task.CompletedTask;
});
switch (args[0])
{
case "me":
Console.WriteLine(await new WhoAmI(graph).DescribeAsync());
return 0;
case "day":
DateOnly today = DateOnly.FromDateTime(TimeProvider.System.GetLocalNow().DateTime);
foreach (DayEntry entry in await new CalendarDay(graph).ReadAsync(today, TimeZoneInfo.Local))
{
Console.WriteLine($"{entry.Start} {entry.Subject}");
}
return 0;
default:
Console.Error.WriteLine($"Unknown command: {args[0]}");
return 2;
}
On the first call, the tool prints the device code message (text and address are determined by Microsoft); you open the page it names, enter the code and sign in as a test person. After that, me writes one
line with the test person's name and job title, and day one line per appointment with the start in UTC and the subject. We did not run this; the description follows from the code and from Learn. You get the packages with
dotnet add package Microsoft.Graph and dotnet add package Azure.Identity in the project; we do not name a fixed version, because Learn says only “5.x” for the major version of Microsoft.Graph.
Namespaces that Learn does not explicitly name on the pages we read and that we entered according to the structure of the SDK: Microsoft.Graph.Models (for User, Event, EventCollectionResponse) and the
assignment of PageIterator to Microsoft.Graph. If your compiler says something different, follow it.
Tests without a tenant
A test that runs only with a Microsoft 365 account never runs in the pipeline. The way from lesson 2.8.1 applies here just the same: replace the network, leave everything else real. For the SDK, Learn names a place for this: you can build the client's handler chain yourself, for example to insert “a test handler to simulate specific scenarios”. The structure:
var handlers = GraphClientFactory.CreateDefaultHandlers();
handlers.Add(new ChaosHandler());
var httpClient = GraphClientFactory.Create(handlers);
var customGraphClient = new GraphServiceClient(httpClient, authProvider);
We use the same way, but put only one handler in the chain: one that gives the response itself and does not pass the request on. That a DelegatingHandler may produce the response itself
and skip passing it on is stated in the documentation on HTTP message handlers (Further reading). The test builds the authProvider from a stand-in for the
TokenCredential: it returns a token that has no value, and no network ever sees a sign-in.
// tests/Kladde.GraphLab.Tests/FakeGraphHandler.cs
using System.Net;
using System.Text;
namespace Kladde.GraphLab.Tests;
/// <summary>Stands in for Graph: answers every request with what the test says, never passes it on, and remembers it.</summary>
internal sealed class FakeGraphHandler(Func<HttpRequestMessage, HttpResponseMessage> answer) : DelegatingHandler
{
public List<(HttpMethod Method, Uri? Address)> Requests { get; } = [];
public static FakeGraphHandler Json(string json, HttpStatusCode status = HttpStatusCode.OK) =>
new(_ => Reply(json, status));
public static HttpResponseMessage Reply(string json, HttpStatusCode status = HttpStatusCode.OK) =>
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add((request.Method, request.RequestUri));
return Task.FromResult(answer(request));
}
}
// tests/Kladde.GraphLab.Tests/GraphTestClient.cs
using Azure.Core;
using Microsoft.Graph;
using Microsoft.Kiota.Authentication.Azure;
namespace Kladde.GraphLab.Tests;
/// <summary>A real GraphServiceClient (real request builders, real parsing) whose network is a fake and whose token is worthless.</summary>
internal static class GraphTestClient
{
public static GraphServiceClient With(FakeGraphHandler fake)
{
AzureIdentityAuthenticationProvider authProvider = new(new WorthlessCredential(), scopes: GraphLabClient.Scopes);
HttpClient http = GraphClientFactory.Create(new List<DelegatingHandler> { fake });
return new GraphServiceClient(http, authProvider);
}
private sealed class WorthlessCredential : TokenCredential
{
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) =>
new("not-a-real-token", DateTimeOffset.UtcNow.AddHours(1));
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) =>
ValueTask.FromResult(GetToken(requestContext, cancellationToken));
}
}
Here too: the test builds the real client and swaps out only the network, so that it also checks the address, the query options and the reading of the response. TokenCredential and AccessToken are
types from Azure.Core, which lesson 2.8.5 also uses. Open: that GraphClientFactory.Create accepts a list with only this one handler and that AzureIdentityAuthenticationProvider is built with these
arguments follows from Learn's examples (list from CreateDefaultHandlers(), constructor with scopes:); we did not compile it. If the compiler breaks at one spot, that
is the place to look.
The first test checks what the class asks for:
// tests/Kladde.GraphLab.Tests/CalendarDayTests.cs
using Shouldly;
using Xunit;
namespace Kladde.GraphLab.Tests;
public sealed class CalendarDayTests
{
// A zone one hour ahead of UTC, built by hand so the test needs no time zone database.
private static readonly TimeZoneInfo PlusOne =
TimeZoneInfo.CreateCustomTimeZone("plus-one", TimeSpan.FromHours(1), "plus one", "plus one");
[Fact]
public async Task TheDayIsAskedForAsOneWindowWithTheCallersOffset()
{
FakeGraphHandler fake = FakeGraphHandler.Json("""{ "value": [] }""");
CalendarDay day = new(GraphTestClient.With(fake));
await day.ReadAsync(new DateOnly(2025, 11, 5), PlusOne);
Uri address = fake.Requests.ShouldHaveSingleItem().Address!;
address.AbsolutePath.ShouldEndWith("/me/calendarView");
string query = Uri.UnescapeDataString(address.Query);
query.ShouldContain("startDateTime=2025-11-05T00:00:00+01:00");
query.ShouldContain("endDateTime=2025-11-06T00:00:00+01:00");
query.ShouldContain("$select=subject,start");
}
[Fact]
public async Task EntriesComeBackInTheOrderOfTheirStart()
{
FakeGraphHandler fake = FakeGraphHandler.Json("""
{
"value": [
{ "subject": "Team meeting", "start": { "dateTime": "2025-11-05T09:00:00.0000000", "timeZone": "UTC" } },
{ "subject": "Travel plan sync", "start": { "dateTime": "2025-11-05T08:00:00.0000000", "timeZone": "UTC" } }
]
}
""");
CalendarDay day = new(GraphTestClient.With(fake));
IReadOnlyList<DayEntry> entries = await day.ReadAsync(new DateOnly(2025, 11, 5), PlusOne);
entries.Select(entry => entry.Subject).ShouldBe(["Travel plan sync", "Team meeting"]);
}
}
The examples take November 5, 2025 as a fixed day. The test never takes today's date, otherwise it would be a different test tomorrow. The check of the address part is coarse (ShouldContain), because we have not read
how the SDK encodes the query exactly; Uri.UnescapeDataString makes it comparable.
Exercise 1: Requests in Graph Explorer (without a tenant)
Build, narrow down and read a request. You need a browser and no tenant: the task runs with Graph Explorer's sample data without signing in. Do not sign in.
- Open Graph Explorer (address under “Further reading”) and choose the sample query “Get my profile”. Read the request: method, version, resource. Run it with Run query.
- Look in the response for the property
@microsoft.graph.tips. If it is there, it holds the hint about$select. - Set the version to
v1.0(if it is not already) and change the request tome?$select=displayName,jobTitle. Run it. - Write the request
users?$top=2and run it. Look for@odata.nextLink. - Open the Code snippets tab, choose C# and read how the Explorer writes the request from step 3 or 4 as SDK code.
- For each request, note in one line: method, address, status code.
How to check: in step 3, the response has fewer properties than in step 1, and @microsoft.graph.tips is missing. In step 4, if the sample tenant has more than two users (we do not know), there is an
@odata.nextLink. The C# snippet contains graphClient.Me.GetAsync(...) with Select and, for users, QueryParameters.Top. The Explorer shows whether the request succeeded, with the status code.
Cleanup: nothing, because you only read. Had you signed in and tried a write call, undoing it would be part of the task, and it would not belong in a production tenant.
Solution 1
What you should see is in the checking notes. Common stumbling blocks:
- The request is rejected with a permissions error: you are signed in or you chose a sample query that requires a sign-in. Choose a different sample query or stay with “Get my profile”.
- The Explorer reports a request without
$as an error or ignores the option: write$select, notselect(Learn: always with$). - The C# snippet looks different from the lesson: the snippets are written for major version 5 of the SDK, and the Explorer picks other variable names. The same are the path
graphClient.Meand the properties ofQueryParameters.
Exercise 2: Test the day's appointments without a tenant
Build the test from the lesson. This exercise needs no tenant and no account: the test replaces the network.
- Create the projects:
dotnet new console -o src/Kladde.GraphLabanddotnet new xunit -o tests/Kladde.GraphLab.Tests, plus the packages in both:Microsoft.GraphandAzure.Identityfor the application, in the testShouldlyand a reference to the application project (dotnet add tests/Kladde.GraphLab.Tests reference src/Kladde.GraphLab). Adjust the names to your solution. - Write
GraphLabClient,CalendarDayandProgramas in the lesson. - Write
FakeGraphHandler,GraphTestClientand the two tests. - A third check comes from you: a test
TheProfileLineHasNameAndTitlethat checksWhoAmIagainst the response{ "displayName": "Mira Hansen", "jobTitle": "Office manager" }and expects the lineMira Hansen (Office manager)to come out and the address to be/mewith$select=displayName,jobTitle. - Run the tests and read the exit code:
dotnet testand$LASTEXITCODEin PowerShell.
How to check: all three tests are green, the exit code is 0, and that works with the network cable pulled. Then the counter-check: in CalendarDay, change the call day.AddDays(1) to day.AddDays(2); the first test must turn red and
name the wrong end time. Undo the change.
Cleanup: nothing outside your machine; you can keep the projects, lesson W4.2.1 builds on them.
Solution 2
The test for the profile:
// tests/Kladde.GraphLab.Tests/WhoAmITests.cs
using Shouldly;
using Xunit;
namespace Kladde.GraphLab.Tests;
public sealed class WhoAmITests
{
[Fact]
public async Task TheProfileLineHasNameAndTitle()
{
FakeGraphHandler fake = FakeGraphHandler.Json("""{ "displayName": "Mira Hansen", "jobTitle": "Office manager" }""");
WhoAmI who = new(GraphTestClient.With(fake));
string line = await who.DescribeAsync();
line.ShouldBe("Mira Hansen (Office manager)");
Uri address = fake.Requests.ShouldHaveSingleItem().Address!;
address.AbsolutePath.ShouldEndWith("/me");
Uri.UnescapeDataString(address.Query).ShouldContain("$select=displayName,jobTitle");
}
}
Common mistakes:
- The test hangs without a result: the handler was not put in the chain (
GraphClientFactory.Createwithout the list) or the client uses a chain of its own with the real network. CheckGraphTestClient.With. - The request does go to the network after all and fails at the sign-in: the test's
authProviderdid not get the stand-inTokenCredential. ShouldContain("$select=subject,start")fails because the SDK encodes the query differently: look ataddress.Queryin the test (address.ToString()in the failure message) and adjust the check; the point stays: the option was sent.- The compiler does not know
EventorUser: the namespaceMicrosoft.Graph.Modelsis missing (see the note on namespaces). - The time zone cannot be found in the test: you used
FindSystemTimeZoneById; the lesson builds it withCreateCustomTimeZone, so that the test does not depend on the machine's time zone database.
Exercise 3: Reading pages without asking the service
Check that the iterator reads all pages. This exercise needs no tenant.
- Write a test
AllPagesAreReadAndTheNextLinkIsFollowedinCalendarDayTests. It builds aFakeGraphHandlerwith a function that answers depending on the request: page 1 with two appointments and an@odata.nextLink(https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=second-page), page 2 with one appointment and no@odata.nextLink. - Read the day and expect three entries.
- Check that there were exactly two requests and that the second carries the
skiptoken. - Add a second test in which page 1 has no appointment but an
@odata.nextLink, and page 2 has two: the result must be two entries (rule 3 above).
How to check: both tests are green, $LASTEXITCODE is 0. Counter-check: temporarily rewrite CalendarDay so that it reads only first.Value (without PageIterator); the first test must turn red and show one request instead of two.
Cleanup: undo the counter-check.
Solution 3
Both tests belong in the class CalendarDayTests, next to the field PlusOne.
[Fact]
public async Task AllPagesAreReadAndTheNextLinkIsFollowed()
{
FakeGraphHandler fake = new(request => request.RequestUri!.Query.Contains("skiptoken")
? FakeGraphHandler.Reply("""
{ "value": [ { "subject": "Third", "start": { "dateTime": "2025-11-05T10:00:00.0000000", "timeZone": "UTC" } } ] }
""")
: FakeGraphHandler.Reply("""
{
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=second-page",
"value": [
{ "subject": "First", "start": { "dateTime": "2025-11-05T08:00:00.0000000", "timeZone": "UTC" } },
{ "subject": "Second", "start": { "dateTime": "2025-11-05T09:00:00.0000000", "timeZone": "UTC" } }
]
}
"""));
CalendarDay day = new(GraphTestClient.With(fake));
IReadOnlyList<DayEntry> entries = await day.ReadAsync(new DateOnly(2025, 11, 5), PlusOne);
entries.Count.ShouldBe(3);
fake.Requests.Count.ShouldBe(2);
fake.Requests[1].Address!.Query.ShouldContain("skiptoken=second-page");
}
[Fact]
public async Task AnEmptyPageWithANextLinkIsNotTheEnd()
{
FakeGraphHandler fake = new(request => request.RequestUri!.Query.Contains("skiptoken")
? FakeGraphHandler.Reply("""
{ "value": [
{ "subject": "First", "start": { "dateTime": "2025-11-05T08:00:00.0000000", "timeZone": "UTC" } },
{ "subject": "Second", "start": { "dateTime": "2025-11-05T09:00:00.0000000", "timeZone": "UTC" } } ] }
""")
: FakeGraphHandler.Reply("""
{ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=second-page", "value": [] }
"""));
CalendarDay day = new(GraphTestClient.With(fake));
IReadOnlyList<DayEntry> entries = await day.ReadAsync(new DateOnly(2025, 11, 5), PlusOne);
entries.Count.ShouldBe(2);
}
Mistakes: the test ends after one request even though @odata.nextLink is there: the iterator gets only the first response if you read first.Value instead of PageIterator. The test runs forever: the second
response also carries an @odata.nextLink; check the function that chooses the response. If the iterator sends the second request to a different address than expected, read fake.Requests[1].Address in the
debugger: the address is the one from the JSON, plus whatever the SDK appends itself.
Exercise 4: A run in the test tenant
Run the tool against real data. This exercise needs a test tenant, never a production tenant: the tool only reads, but you create an app registration and consent to permissions. For experiments, Learn recommends the Microsoft 365 developer sandbox tenant (whether the developer program is open to you, check with Microsoft). If you do not have a test tenant, do exercises 2 and 3: you check the same code without a tenant and skip this exercise.
- In the Microsoft Entra admin center, register an app (path according to Learn: Identity, Applications, App registrations, New registration). Name
kladde-graphlab-test; Supported account types: “Accounts in this organizational directory only”. The least privileged role for this is, according to Learn, “Cloud Application Administrator”. No redirect URI. - Under Authentication, set Allow public client flows to Yes (Learn: the device code flow applies only to public clients).
- Under API permissions, add the delegated permissions
User.ReadandCalendars.Readfor Microsoft Graph. - Note the Application (client) ID and the tenant ID from the overview, only in the window of your shell:
$env:GRAPHLAB_CLIENT_ID = "<client-id>"and$env:GRAPHLAB_TENANT_ID = "<tenant-id>". - In the test person's Outlook, create an appointment for today with the subject
Graph trial. - Start
dotnet run --project src/Kladde.GraphLab -- me, sign in with the code, and then start... -- day. - If your tenant blocks consent for users, the sign-in reports a consent error. That is the subject of lesson W4.1.2; put the exercise aside or ask the administration of your test tenant for the consent.
How to check: me writes one line with the test person's name and job title, day one line with the start (UTC) and Graph trial. We have not seen either of these, only derived them from Learn. And dotnet run ends with exit code 0.
Cleanup: Remove-Item Env:GRAPHLAB_CLIENT_ID, Env:GRAPHLAB_TENANT_ID; delete the appointment in Outlook; delete the app registration kladde-graphlab-test in the list of app registrations (the menu path for that is not on the pages we read);
close the window. An app registration is a door to your tenant; do not leave it standing.
Solution 4
Typical mistakes:
AADSTS700016: Learn names as the cause that the app with this ID was not found in the tenant. CheckGRAPHLAB_CLIENT_IDandGRAPHLAB_TENANT_ID(right tenant, no mix-up with the object ID).- The sign-in fails at the device code because the registration is not a public client: turn on Allow public client flows (step 2).
AADSTS65001: the consent is missing. Lesson W4.1.2 explains who may consent and how.403: the permission is missing, or the person is not allowed to do themselves what they are supposed to do through the app; what is effective with delegated is the intersection of both (lesson W4.1.2).dayis empty although you have an appointment: the appointment lies outside the window from midnight to midnight of your local time, or it is in a calendar other than the default calendar;me/calendarViewreads the default calendar.
What you can do now
- You say what Microsoft Graph is, and you set the Graph API apart from Data Connect and Copilot connectors.
- You read and write a request made of method, version, resource and query options, know the 4 MB limit for requests that write, and know why
betadoes not belong in operation. - You try requests in Graph Explorer without a tenant and know that attempts that write belong in a test tenant.
- You use
$select,$filter,$orderby,$top,$countand$expand, always write$, and check in the response whether an option took effect. - You read all pages of a collection through
@odata.nextLink, with thePageIteratoror by hand, and know the traps: take the whole address, empty pages, set headers again. - You build a
GraphServiceClientwith aTokenCredential, callmeandme/calendarView, and test the code without a tenant with a faked handler.
Further reading
- Overview of Microsoft Graph: what Graph, Copilot connectors and Data Connect are.
- Use the Microsoft Graph API: structure of a request, methods, headers, limits.
- Versioning, support, and breaking change policies for Microsoft Graph: v1.0, beta and deprecation.
- Use Graph Explorer to try Microsoft Graph APIs: the Explorer, the sample tenant, permissions and code snippets.
- Customize responses with query parameters: all query options and their limits.
- Paging Microsoft Graph data in your app: server-side and client-side pages,
@odata.nextLink. - Page through a collection using the Microsoft Graph SDKs:
PageIterator, pausing, paging on by hand. - Make API calls using the Microsoft Graph SDKs: request builders, headers, query parameters.
- Create a Microsoft Graph client: the
GraphServiceClientwith aTokenCredential. - Choose a Microsoft Graph authentication provider: which sign-in for which kind of application.
- Customize the Microsoft Graph SDK service client: your own handlers in the client's chain.
- List calendarView: permissions, time window and headers of
calendarView. - Microsoft Graph error responses and resource types: status codes and the error object.
- HTTP Message Handlers in ASP.NET Web API: a handler that produces the response itself.
The Kladde: it now has Kladde.GraphLab, a tool that uses a GraphServiceClient to read the profile of a signed-in person and the appointments of a day, and three tests that need no account for that. What the tool still lacks is
an understanding of why it requests exactly two permissions and what an administration has to say about it. That is the next lesson.