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
{HTTP method} https://graph.microsoft.com/{version}/{resource}?{query-parameters}

The parts:

  • Method: says what is to happen.
  • Version: v1.0 or beta (next section).
  • Resource: what is being talked about, for example me, users, groups, drive or site. Resources often have relationships that you follow on: me/messages are the messages of the signed-in person, me/drive is their OneDrive. There are also methods on resources: you send mail with me/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.0 contains 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.
  • beta contains 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 into v1.0. Take no production dependency on beta. Beta calls belong at most in experiments.

Two additions from Learn's page on versioning and support that affect your programming:

  1. 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.
  2. 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, PATCH and DELETE. 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.

http
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.

http
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:

http
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 manager and memberOf, but not for events, messages or photo.
  • For directory objects (users, groups), $expand usually returns at most 20 items of the relationship and no @odata.nextLink.
  • $expand does 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:

JSON
"@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$top=5&$skiptoken=RFNwdAIAAQAAAD8...AAAAAAAA"

The rules for this, each with consequences for your code:

  1. Reading all results means asking again until no @odata.nextLink comes back.
  2. Take the whole address from @odata.nextLink. Do not pull out $skiptoken or $skip and build your own request with it; the other options of the first request are already in the address.
  3. A page can have zero results and there is still a next one. A test that uses Count == 0 as the stop condition is wrong.
  4. Default and maximum sizes differ per call. If you ask for more with $top than 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.
  5. Custom headers of the first request (everything except Authorization and Content-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.
  6. With $count=true on directory objects, @odata.count is only in the first page.
  7. 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.Graph for v1.0 (depends on Microsoft.Graph.Core), and
  • Microsoft.Graph.Beta for the beta endpoint (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.

C#
// 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:

C#
// 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):

C#
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:

  1. The function for each item returns true to continue. With false, the iterator pauses; ResumeAsync() continues (Learn shows an example with pageIterator.State != PagingState.Complete).
  2. The fourth parameter configures the follow-up requests, here to set the Prefer header 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.
  3. 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:

C#
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.

C#
// 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):

C#
// 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})";
    }
}
C#
// 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:

C#
// 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:

C#
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.

C#
// 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));
    }
}
C#
// 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:

C#
// 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.

  1. 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.
  2. Look in the response for the property @microsoft.graph.tips. If it is there, it holds the hint about $select.
  3. Set the version to v1.0 (if it is not already) and change the request to me?$select=displayName,jobTitle. Run it.
  4. Write the request users?$top=2 and run it. Look for @odata.nextLink.
  5. Open the Code snippets tab, choose C# and read how the Explorer writes the request from step 3 or 4 as SDK code.
  6. 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, not select (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.Me and the properties of QueryParameters.

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.

  1. Create the projects: dotnet new console -o src/Kladde.GraphLab and dotnet new xunit -o tests/Kladde.GraphLab.Tests, plus the packages in both: Microsoft.Graph and Azure.Identity for the application, in the test Shouldly and a reference to the application project (dotnet add tests/Kladde.GraphLab.Tests reference src/Kladde.GraphLab). Adjust the names to your solution.
  2. Write GraphLabClient, CalendarDay and Program as in the lesson.
  3. Write FakeGraphHandler, GraphTestClient and the two tests.
  4. A third check comes from you: a test TheProfileLineHasNameAndTitle that checks WhoAmI against the response { "displayName": "Mira Hansen", "jobTitle": "Office manager" } and expects the line Mira Hansen (Office manager) to come out and the address to be /me with $select=displayName,jobTitle.
  5. Run the tests and read the exit code: dotnet test and $LASTEXITCODE in 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:

C#
// 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.Create without the list) or the client uses a chain of its own with the real network. Check GraphTestClient.With.
  • The request does go to the network after all and fails at the sign-in: the test's authProvider did not get the stand-in TokenCredential.
  • ShouldContain("$select=subject,start") fails because the SDK encodes the query differently: look at address.Query in the test (address.ToString() in the failure message) and adjust the check; the point stays: the option was sent.
  • The compiler does not know Event or User: the namespace Microsoft.Graph.Models is missing (see the note on namespaces).
  • The time zone cannot be found in the test: you used FindSystemTimeZoneById; the lesson builds it with CreateCustomTimeZone, 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.

  1. Write a test AllPagesAreReadAndTheNextLinkIsFollowed in CalendarDayTests. It builds a FakeGraphHandler with 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.
  2. Read the day and expect three entries.
  3. Check that there were exactly two requests and that the second carries the skiptoken.
  4. 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.

C#
    [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.

  1. 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.
  2. Under Authentication, set Allow public client flows to Yes (Learn: the device code flow applies only to public clients).
  3. Under API permissions, add the delegated permissions User.Read and Calendars.Read for Microsoft Graph.
  4. 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>".
  5. In the test person's Outlook, create an appointment for today with the subject Graph trial.
  6. Start dotnet run --project src/Kladde.GraphLab -- me, sign in with the code, and then start ... -- day.
  7. 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. Check GRAPHLAB_CLIENT_ID and GRAPHLAB_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).
  • day is 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/calendarView reads 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 beta does 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, $count and $expand, always write $, and check in the response whether an option took effect.
  • You read all pages of a collection through @odata.nextLink, with the PageIterator or by hand, and know the traps: take the whole address, empty pages, set headers again.
  • You build a GraphServiceClient with a TokenCredential, call me and me/calendarView, and test the code without a tenant with a faked handler.

Further reading

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.