Die Kladde hat Termine, Kontakte und eine Mail-Anbindung. Das Büro der Nordlicht Reisen GmbH arbeitet aber in Microsoft 365: Dort liegen die Kalender, die Postfächer und die Dateien. In diesem Kurs lernt die Kladde, mit diesen Daten zu arbeiten. Der Weg dorthin heißt Microsoft Graph. Diese Lektion zeigt, was Graph ist, wie eine Anfrage aufgebaut ist, wie man sie ausprobiert, einschränkt und seitenweise liest und wie das .NET-SDK das alles in Code gießt. Am Ende steht ein kleines Konsolenwerkzeug, Kladde.GraphLab, das Ihr Profil und die Termine des Tages abruft, samt Tests, die ohne Microsoft-365-Konto laufen.

Stand der Prüfung, bitte vorab lesen. Jede fachliche Aussage dieser Lektion stammt von Seiten auf Microsoft Learn, die beim Schreiben der Lektion gelesen wurden. Kein Befehl und kein Code dieser Lektion wurde gegen einen Microsoft-365-Mandanten ausgeführt, und der Code ist in der hier stehenden Fassung nicht gebaut worden. Wo eine Ausgabe stünde, beschreibt der Text, was Learn erwarten lässt, statt eine Ausgabe vorzutäuschen. Wo wir etwas nicht gelesen haben, steht es als „offen“ da. Wenn Ihr Compiler oder Ihr Mandant etwas anderes sagt als diese Lektion, gilt Ihr Befund, und die Lektion verdient eine Korrektur.

Was Sie aus den Kursen davor mitbringen, wiederholt diese Lektion nicht: Die Anmeldung mit Microsoft Entra ID kennen Sie aus Lektion 2.6.2 „OpenID Connect mit Microsoft Entra ID“, den HttpClient und den nachgemachten HttpMessageHandler aus Lektion 2.8.1 „HttpClientFactory und eine fremde Schnittstelle“, die Mail über Graph aus Lektion 2.8.5 „Mails verschicken, bestätigt“. Hier geht es darum, was dahinter liegt.

Was Microsoft Graph ist

Learn beschreibt Microsoft Graph als das Tor zu Daten und Auswertungen in den Cloud-Diensten von Microsoft, darunter Microsoft Entra und Microsoft 365. Der Kern ist die Microsoft Graph API: ein einziger Endpunkt, https://graph.microsoft.com, über den Sie per REST oder mit einem SDK auf die Daten sehr verschiedener Dienste zugreifen. Zu den Diensten gehören nach Learn unter anderem Kalender, Excel, OneDrive, Outlook und Exchange, Personen, Planner, SharePoint, Teams und To Do, außerdem Microsoft Entra, Intune und weitere. Die vollständige Liste führt Learn auf der Seite „Major services and features in Microsoft Graph“ (Weiterlesen).

Für uns heißt das: eine Adresse, eine Art der Anmeldung, ein Token für viele Dienste. Wer die Kladde einmal mit Graph verbunden hat, erreicht mit denselben Mitteln den Kalender, die Mail und die Dateien. Wie dieses Token entsteht und was es erlaubt, ist Thema der Lektion W4.1.2.

Learn nennt neben der Graph API zwei weitere Bausteine, die Sie vom Namen her kennen sollten, weil sie sich ähnlich anhören:

  • Microsoft 365 Copilot-Konnektoren (früher Microsoft Graph-Konnektoren) arbeiten in der Gegenrichtung: Sie bringen Daten von außerhalb der Microsoft-Cloud nach Graph, etwa aus einer Personal- oder Produktdatenbank, damit sie zum Beispiel in der Microsoft-Suche auftauchen.
  • Microsoft Graph Data Connect liefert Graph-Daten in großen Mengen und nach Zeitplan in Azure-Datenspeicher.

Learn stellt die Graph API und Data Connect so gegenüber:

Eigenschaft Microsoft Graph API Microsoft Graph Data Connect
Reichweite einzelne Person oder ganzer Mandant viele Personen oder Gruppen
Zugriffsmuster in Echtzeit nach wiederkehrendem Zeitplan
Datenbasis arbeitet auf den Quelldaten arbeitet auf einem Abbild (Cache) der Daten
Zugriffswerkzeug RESTful-Webabfragen Azure Data Factory

Die Kladde braucht Echtzeit für einzelne Postfächer und Kalender. Dieser Kurs behandelt deshalb nur die Graph API. Copilot-Konnektoren und Data Connect kommen nicht vor.

Eine Anfrage lesen

Learn gibt für jede Anfrage dieses Muster an:

http
{HTTP method} https://graph.microsoft.com/{version}/{resource}?{query-parameters}

Die Bestandteile:

  • Methode: sagt, was geschehen soll.
  • Version: v1.0 oder beta (nächster Abschnitt).
  • Ressource: wovon die Rede ist, zum Beispiel me, users, groups, drive oder site. Ressourcen haben oft Beziehungen, über die man weitergeht: me/messages sind die Nachrichten der angemeldeten Person, me/drive ihr OneDrive. Daneben gibt es Methoden auf Ressourcen: Mail verschickt man mit me/sendMail.
  • Abfrageoptionen: schränken die Antwort ein oder füllen Parameter einer Methode aus.
  • Kopfzeilen: Standardkopfzeilen und eigene; manche Aufrufe verlangen bestimmte.

Auf den Referenzseiten stehen viele Aufrufe in zwei Schreibweisen, etwa /me/calendarView und /users/{id | userPrincipalName}/calendarView. Die erste meint die angemeldete Person, die zweite eine Person, die Sie benennen. Warum das wichtig wird, sehen Sie in Lektion W4.1.2: Mit Berechtigungen ohne angemeldete Person gibt es keine „angemeldete Person“, und Sie müssen die Person benennen.

Die Methode bestimmt die Wirkung. Learn führt diese auf:

Methode Wirkung
GET Daten einer Ressource lesen
POST eine neue Ressource anlegen oder eine Aktion ausführen
PATCH eine Ressource mit neuen Werten aktualisieren (bei manchen Ressourcen auch anlegen, wenn es sie nicht gibt)
PUT eine Ressource durch eine neue ersetzen
DELETE eine Ressource entfernen

GET und DELETE brauchen keinen Anfragekörper, POST, PATCH und PUT einen, meist JSON. Eine Grenze, die Sie kennen sollten: Schreibende Anfragen sind auf 4 MB beschränkt, in manchen Fällen auf weniger. Learn nennt als Beispiel einen Anhang an einen Termin (POST /me/events/{id}/attachments), bei dem 3 MB gelten, weil eine Datei von etwa 3,5 MB in Base64 größer als 4 MB werden kann. Zu große Anfragen scheitern mit dem Status 413.

Die Antwort besteht aus einem Statuscode, den Daten oder dem Ergebnis der Aktion (bei manchen Aufrufen ist der Körper leer), einer Verweis-URL @odata.nextLink, wenn es mehr Daten gibt (siehe „Seiten“), und Kopfzeilen. Zwei Kopfzeilen merken Sie sich jetzt, weil Sie sie im Betrieb brauchen: request-id gehört zu jeder Antwort und ordnet sie der Anfrage zu, und Retry-After steht in der Antwort, wenn Ihre App gedrosselt wird. Kopfzeilen sind nach RFC 9110, den Learn nennt, nicht von Groß- und Kleinschreibung abhängig. Wie Sie mit Drosselung umgehen, behandelt die Lektion W4.3.2.

Zwei Versionen: v1.0 und beta

Graph kennt zwei Versionen:

  • v1.0 enthält die allgemein verfügbaren Schnittstellen. Learn: „Use the v1.0 version for all production apps.“ Änderungen an diesen Schnittstellen sind additiv und brechen laut Learn bestehende Szenarien nicht.
  • beta enthält Schnittstellen in der Vorschau. Learn warnt ausdrücklich: Sie können sich ohne Ankündigung ändern und bestehende Szenarien brechen, und es gibt keine Zusage, dass ein Beta-Merkmal nach v1.0 übernommen wird. Nehmen Sie keine Produktionsabhängigkeit auf beta. Beta-Aufrufe gehören höchstens in Versuche.

Zwei Zusätze aus Learns Seite zu Versionen und Unterstützung, die Ihre Programmierung betreffen:

  1. Zu den abwärtskompatiblen Änderungen zählt Learn unter anderem neue Eigenschaften, die leer sein dürfen oder einen Standardwert haben, neue Werte in einer Aufzählung, die Einführung von Seiten bei bestehenden Sammlungen und Änderungen an Fehlercodes. Ihr Code darf also nicht stürzen, wenn in der Antwort Eigenschaften stehen, die er nicht kennt, und er darf sich auf keine Reihenfolge von Eigenschaften verlassen. Das SDK nimmt Ihnen das ab; wer Antworten selbst liest, muss es bedenken.
  2. Learn erklärt eine Version oder eine allgemein verfügbare Schnittstelle mindestens 24 Monate vor der Abschaltung für veraltet. Beim SDK gilt etwas Kürzeres: Nur die jeweils neueste Hauptversion bekommt Neuerungen und Fehlerkorrekturen, die vorherige noch 12 Monate lang Sicherheitskorrekturen. Planen Sie Aktualisierungen des SDK deshalb ein.

Die Version steckt in der Adresse (https://graph.microsoft.com/v1.0/... oder .../beta/...). Für das SDK gibt es zwei Pakete, dazu gleich mehr.

Der Graph Explorer

Bevor Sie Code schreiben, probieren Sie eine Anfrage im Graph Explorer aus, einem Webwerkzeug von Microsoft. Nach Learn dient er dazu, APIs in einem Beispielmandanten auszuprobieren, sich bei Bedarf im eigenen Mandanten anzumelden, die nötigen Berechtigungen kennenzulernen und Codeauszüge zu holen.

  • Ohne Anmeldung laufen die vorbereiteten Beispielabfragen (zum Beispiel „Get my profile“) gegen den Beispielmandanten von Microsoft. Sie brauchen kein Konto. Schreibende Anfragen (POST, PUT, PATCH, DELETE) probieren Sie nach Learn nur mit Anmeldung.
  • Mit Anmeldung arbeiten Sie in Ihrem eigenen Mandanten und dürfen auch POST, PUT, PATCH und DELETE ausprobieren. Learn empfiehlt dafür ausdrücklich den Microsoft 365 Developer-Sandbox-Mandanten statt des Produktivmandanten. Der Grund steht ebenfalls dort: Schreibende Anfragen können die Daten des Mandanten verändern, in den Sie sich angemeldet haben.
  • Schlägt eine Anfrage an fehlenden Berechtigungen fehl, zeigt der Explorer die Meldung und bietet Modify permissions an. Dort stimmen Sie den nötigen Berechtigungen zu und führen die Anfrage noch einmal aus.
  • Der Reiter Code snippets zeigt dieselbe Anfrage als Code in C#, JavaScript, Java, Go und PowerShell. Der C#-Auszug ist ein guter Startpunkt für Ihren eigenen Code.
  • Zu jeder Anfrage verweist Documentation auf die Referenzseite mit den Berechtigungen und Parametern. Der Verlauf hält die Anfragen der letzten 30 Tage.

Zwei Stellen sind für diesen Kurs offen: Ob und unter welchen Bedingungen das Entwicklerprogramm von Microsoft 365 für Sie als Lernende offen ist, haben wir nicht geprüft; das entscheidet Microsoft und steht auf deren Seiten. Und wir haben den Graph Explorer für diese Lektion nicht selbst bedient: Die Beschreibung ist Learns.

Abfrageoptionen: antworten, was Sie brauchen

Graph unterstützt viele der OData-Systemabfrageoptionen, allerdings nur bei GET. Welche ein Aufruf kann, steht in der Referenz des Aufrufs im Abschnitt „Optional query parameters“, und es unterscheidet sich zwischen Aufrufen und zwischen v1.0 und beta. Learn rät, das Zeichen $ immer mitzuschreiben, auch wenn es bei beta und bei einem Teil der Aufrufe optional ist.

Option Wirkung Beispiel von Learn
$select nur bestimmte Eigenschaften (Spalten) /users?$select=givenName,surname
$filter nur bestimmte Elemente (Zeilen) /users?$filter=startswith(givenName,'J')
$orderby Reihenfolge /users?$orderby=displayName desc
$top Seitengröße, mindestens 1, das Höchste hängt vom Aufruf ab /me/messages?$top=5
$skip Elemente überspringen; manche Aufrufe nutzen es für Seiten /me/events?$orderby=createdDateTime&$skip=20
$count Anzahl der Treffer /me/messages?$top=2&$count=true
$expand verwandte Ressourcen gleich mitliefern /me/drive/root?$expand=children
$search Suchbegriff /me/messages?$search=pizza

Dazu $skiptoken für die nächste Seite (kommt gleich). Nicht jeder Aufruf kann jede Option, und die Regeln sind fein. Wir gehen die wichtigen durch.

$select: nur holen, was Sie brauchen

$select begrenzt die Eigenschaften. Das spart Übertragung und ist nach Learn eine Leistungsmaßnahme. Bei einem GET ohne $select fügt Graph eine Eigenschaft @microsoft.graph.tips hinzu, die genau diese Empfehlung ausspricht. Die Eigenschaft ist also ein Hinweis, kein Fehler. Bei manchen Ressourcen liefert Graph Eigenschaften nur, wenn man sie mit $select anfordert.

http
GET https://graph.microsoft.com/v1.0/me/messages?$select=from,subject

$filter: nur die passenden Zeilen

Die Syntax der Filterausdrücke ist eine eigene Wissenswelt, die Learn auf einer eigenen Seite beschreibt (Weiterlesen); diese Seite haben wir nicht gelesen. Aus der Seite zu Abfrageoptionen stammt ein Fall, der oft schiefgeht: Ein einfaches Anführungszeichen im Wert verdoppeln Sie.

http
GET https://graph.microsoft.com/v1.0/me/messages?$filter=subject eq 'let''s meet for lunch?'

Die Werte der Abfrageoptionen müssen nach RFC 3986 prozentkodiert sein. Fast alle HTTP-Clients, Browser und der Graph Explorer erledigen das selbst. Scheitert eine Abfrage seltsam, ist falsche Kodierung eine mögliche Ursache. Das SDK kodiert für Sie.

$orderby, $top und $skip

$orderby nimmt eine oder mehrere durch Komma getrennte Eigenschaften, jede mit desc für absteigend. Learn zeigt ein Beispiel mit zwei Schlüsseln:

http
GET https://graph.microsoft.com/v1.0/me/mailFolders/Inbox/messages?$orderby=from/emailAddress/name desc,subject

$top setzt die Seitengröße. Wenn mehr Elemente übrig sind, steht in der Antwort @odata.nextLink. $skip ist bei manchen Aufrufen der zweite Weg zu Seiten. Nicht jeder Aufruf kann beides: Learn sagt zum Beispiel, dass /users zwar $top, aber nicht $skip unterstützt.

$count

$count gibt es in zwei Formen: als Abfrageoption $count=true, die neben der Seite mit Daten eine Anzahl in @odata.count liefert, und als Adressabschnitt /$count, der nur die Zahl liefert (users/$count). Bei Ressourcen, die von directoryObject abgeleitet sind (Benutzer, Gruppen und andere Verzeichnisobjekte), geht $count nur in einer erweiterten Abfrage mit der Kopfzeile ConsistencyLevel, die Learn auf der Seite „Advanced query capabilities on directory objects“ beschreibt. Diese Seite haben wir nicht gelesen. Für Postfach und Kalender, um die es in diesem Kurs geht, brauchen Sie das nicht.

$expand

$expand holt eine verwandte Ressource oder Sammlung gleich mit, spart also eine zweite Anfrage. Es gelten Grenzen, die Learn aufzählt:

  • Nicht jede Beziehung lässt sich erweitern. Auf einem Benutzer geht es zum Beispiel für manager und memberOf, nicht für events, messages oder photo.
  • Bei Verzeichnisobjekten (Benutzer, Gruppen) liefert $expand meist höchstens 20 Elemente der Beziehung und keinen @odata.nextLink.
  • $expand geht nicht zusammen mit erweiterten Abfragen.

Ein nicht unterstütztes $expand meldet Graph manchmal als Fehler (ExpandNotSupported bei me?$expand=photo), manchmal aber stillschweigend nicht. Das gilt für alle Abfrageoptionen: Learn sagt, dass nicht unterstützte Optionen und Kombinationen ohne Fehler wirkungslos bleiben können. Prüfen Sie deshalb an der Antwort, ob Ihre Option gewirkt hat. Ein Test mit nachgemachtem Handler kann das nicht beweisen; ein Lauf gegen den echten Dienst schon.

Seiten: die Antwort kommt in Teilen

Sammlungen kommen in Seiten. Learn unterscheidet zwei Arten:

  • Serverseitig: Graph liefert von sich aus eine Standardzahl je Seite, bei GET /users zum Beispiel 100.
  • Clientseitig: Sie legen die Seitengröße mit $top fest (oder $skip, $skipToken), soweit der Aufruf es erlaubt.

Gibt es mindestens eine weitere Seite, steht in der Antwort @odata.nextLink mit einer Adresse, etwa:

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

Die Regeln dafür, jede mit Folgen für Ihren Code:

  1. Alle Ergebnisse lesen heißt weiterfragen, bis kein @odata.nextLink mehr kommt.
  2. Nehmen Sie die ganze Adresse aus @odata.nextLink. Ziehen Sie $skiptoken oder $skip nicht heraus und bauen Sie damit keine eigene Anfrage; die übrigen Optionen der ersten Anfrage stecken schon in der Adresse.
  3. Eine Seite kann null Ergebnisse haben, und es gibt trotzdem eine nächste. Ein Test mit Count == 0 als Abbruch ist falsch.
  4. Standard- und Höchstgrößen unterscheiden sich je Aufruf. Wer mit $top mehr fordert, als der Aufruf erlaubt, bekommt je nachdem die Höchstgröße, die Forderung wird ignoriert, oder es kommt ein Fehler. Nicht jede Ressource unterstützt Seiten.
  5. Benutzerdefinierte Kopfzeilen der ersten Anfrage (alles außer Authorization und Content-Type) sind bei Verzeichnisobjekten nicht automatisch in den Folgeanfragen. Das SDK sagt das ebenfalls (siehe unten). Wer eine Kopfzeile braucht, setzt sie bei jeder Seite neu.
  6. Bei $count=true auf Verzeichnisobjekten steht @odata.count nur in der ersten Seite.
  7. Wiederholungen: Hat ein Aufruf nach einem Netzfehler neu versucht und dabei eine neue Adresse bekommen, ist nicht diese für die nächste Seite gültig, sondern die aus der letzten erfolgreichen Antwort. Sonst droht der Fehler DirectoryPageTokenNotFoundException. Merken Sie sich also die Adresse der letzten erfolgreichen Seite.

Das SDK

Wer Graph aus .NET aufruft, schreibt keine URLs von Hand. Microsoft pflegt dafür Bibliotheken. Für .NET sind es:

  • das NuGet-Paket Microsoft.Graph für v1.0 (abhängig von Microsoft.Graph.Core), und
  • Microsoft.Graph.Beta für den beta-Endpunkt (siehe oben: nicht für den Betrieb).

Die Codebeispiele in Learns Referenz tragen den Hinweis „Current version is 5.x“: Dieser Kurs schreibt im Stil dieser Hauptversion. Ältere Beispiele im Netz mit .Request() vor .GetAsync() gehören zur Version 4 und früher; Learns Beispiele für Version 5 haben diesen Zwischenschritt nicht.

Der Einstieg in alles ist der GraphServiceClient. Learn sagt ausdrücklich: Eine Instanz darf für die Lebensdauer der Anwendung bestehen. Für die Kladde heißt das: ein Client, einmal gebaut, in der Abhängigkeitsverwaltung als Singleton registriert (Lektion 2.3.3 „Dependency Injection“), nicht einer je Anfrage.

Anfragen mit Request-Buildern

Das SDK nutzt einen fließenden Stil: Der Pfad der URL wird zu Eigenschaften und Indexern, am Ende steht die Methode.

C#
// GET https://graph.microsoft.com/v1.0/me
var user = await graphClient.Me
    .GetAsync();

Abfrageoptionen und Kopfzeilen setzen Sie in einer Lambda-Funktion, die die Anfragekonfiguration bekommt. Das folgende Beispiel stammt von 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"];
    });

Die Eigenschaften der Abfrageoptionen heißen wie die Optionen, ohne $. Aus den Beispielen von Learn:

Option SDK-Eigenschaft Typ im Beispiel
$select QueryParameters.Select Liste von Texten
$filter QueryParameters.Filter Text
$orderby QueryParameters.Orderby Liste von Texten, je Eintrag Eigenschaft und eventuell desc
$top QueryParameters.Top ganze Zahl
$skip QueryParameters.Skip ganze Zahl
$count QueryParameters.Count true oder false
$expand QueryParameters.Expand Liste von Texten

Ein Element einer Sammlung greifen Sie mit einem Indexer, graphClient.Me.Messages[messageId].GetAsync(); Löschen heißt DeleteAsync(), Anlegen PostAsync(objekt), Aktualisieren PatchAsync(objekt). Bei PATCH übergeben Sie, so Learn, nur die Eigenschaften, die sich ändern sollen. Kopfzeilen kommen in requestConfiguration.Headers, zum Beispiel Prefer mit outlook.timezone für die Zeitzone der Zeiten im Ergebnis (die Lektion W4.2.2 kommt darauf zurück). Aufrufe mit eigenen Parametern wie calendarView haben eigene Eigenschaften in QueryParameters: StartDateTime und EndDateTime.

Was dieser Kurs nicht gelesen hat: ob und wie ein einzelner Aufruf mit einem CancellationToken abgebrochen wird. Die Beispiele von Learn zeigen es nicht. In den Codebeispielen dieser Lektion steht deshalb keiner; wenn Ihr Compiler beim GetAsync einen Parameter dafür anbietet, nutzen Sie ihn.

Seiten mit dem SDK

Die Klasse PageIterator liest eine paginierte Sammlung: Sie holt die Folgeseiten selbst und ruft für jedes Element eine Funktion auf. Das Beispiel von Learn (gekürzt auf das Wesentliche):

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();

Drei Dinge daran sind wichtig:

  1. Die Funktion für jedes Element gibt true zurück, um weiterzumachen. Mit false pausiert der Iterator; ResumeAsync() macht weiter (Learn zeigt ein Beispiel mit pageIterator.State != PagingState.Complete).
  2. Der vierte Parameter konfiguriert die Folgeanfragen, hier um die Kopfzeile Prefer wieder zu setzen: Kopfzeilen der ersten Anfrage gelten für die Folgeseiten nicht von selbst (Regel 5 oben). Ohne diesen Parameter kämen die Texte der Folgeseiten im anderen Format.
  3. Die Seitengröße kann bis 999 gehen (Learn nennt das für Nachrichten als Beispiel in einem Hinweis); die Beispiele nehmen 10, damit Seiten sichtbar werden.

Der Weg von Hand ist ebenso zulässig: Sie lesen OdataNextLink der Antwort und fragen mit WithUrl weiter:

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;
    }
}

Für die Kladde nehmen wir den PageIterator, weil er die Regeln von oben (ganze Adresse, null Ergebnisse auf einer Seite) schon beherzigt.

Der erste Aufruf: Anmeldung mit einem TokenCredential

Ein GraphServiceClient braucht einen Weg zu einem Token. Das SDK bekommt ihn als TokenCredential aus dem Paket Azure.Identity und eine Liste von Bereichen (Scopes): new GraphServiceClient(credential, scopes). Welche TokenCredential-Klasse passt, hängt von der Art der Anwendung ab; Learn hat dazu eine Tabelle („Choose an authentication provider“): eine Person im Browser, ein Konsolenwerkzeug mit Gerätecode, ein Dienst ohne Person mit Schlüssel. Für unser Werkzeug nehmen wir das Beispiel von Learn: den Gerätecode. Das Programm gibt eine Meldung aus, die Sie in einem Browser mit einem Code bestätigen.

Zwei Bedingungen nennt Learn für diesen Ablauf: Er geht nur für öffentliche Clientanwendungen, die App-Registrierung braucht also die Einstellung Allow public client flows; und die Registrierung muss wissen, welche Bereiche die App anfordern darf (Lektion W4.1.2). Die Berechtigungen des Werkzeugs sind User.Read (Profil der angemeldeten Person) und Calendars.Read (Kalender lesen). Die kleinere Berechtigung Calendars.ReadBasic wäre für calendarView laut Learn die kleinste; ob sie für unser Werkzeug reicht, weil sie nach unserem Kenntnisstand nicht alle Eigenschaften des Termins liefert, ist offen und wird in Lektion W4.1.2 Teil der Rechteliste.

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 und clientId kommen aus der App-Registrierung (Platzhalter hier: <tenant-id>, <client-id>). Beide sind keine Geheimnisse; ein Client-Geheimnis gibt es bei dieser Art Anmeldung nicht, und das Werkzeug braucht keines. Das Werkzeug liest sie aus Umgebungsvariablen und trägt sie nicht in den Code ein.

Das Profil und die Termine des Tages als zwei kleine Klassen, die den Client übergeben bekommen (damit die Tests ihn austauschen können):

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);
    }
}

Wichtig an calendarView, aus Learns Referenz: startDateTime und endDateTime sind Pflicht und werden mit dem Versatz im Wert gelesen; ohne Versatz gilt UTC, und die Kopfzeile Prefer: outlook.timezone ändert daran nichts. Diese Kopfzeile bestimmt nur, in welcher Zeitzone Beginn und Ende im Ergebnis stehen; ohne sie sind es UTC-Zeiten. Deshalb gibt Midnight den Versatz mit, und DayEntry.Start ist hier UTC. Die schöne Darstellung in Ortszeit, den Aufbau von dateTimeTimeZone und Sonderfälle wie Zeitumstellung behandelt die Lektion W4.2.2; offen ist hier, welche Eigenschaften dateTimeTimeZone im SDK genau hat: Die Referenzseite dazu haben wir nicht gelesen, wir greifen nur DateTime ab, wie es Learns Beispiele für event nahelegen.

Das Programm, das beides verbindet:

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;
}

Beim ersten Aufruf gibt das Werkzeug die Meldung des Gerätecodes aus (Text und Adresse bestimmt Microsoft); Sie öffnen die genannte Seite, geben den Code ein und melden sich als Testperson an. Danach schreibt me eine Zeile mit Name und Funktion der Testperson, day je Termin eine Zeile mit Beginn in UTC und Betreff. Das haben wir nicht ausgeführt; die Beschreibung folgt aus dem Code und aus Learn. Die Pakete holen Sie mit dotnet add package Microsoft.Graph und dotnet add package Azure.Identity in das Projekt; eine feste Version nennen wir nicht, weil Learn nur „5.x“ für die Hauptversion von Microsoft.Graph sagt.

Namensräume, die Learn auf den gelesenen Seiten nicht ausdrücklich nennt und die wir nach der Struktur des SDK eingetragen haben: Microsoft.Graph.Models (für User, Event, EventCollectionResponse) und die Zuordnung von PageIterator zu Microsoft.Graph. Sagt Ihr Compiler etwas anderes, folgen Sie ihm.

Tests ohne Mandanten

Ein Test, der erst mit einem Microsoft-365-Konto läuft, läuft in der Pipeline nie. Der Weg aus Lektion 2.8.1 gilt hier genauso: das Netz ersetzen, alles andere echt lassen. Learn nennt für das SDK einen Platz dafür: Man kann die Handler-Kette des Clients selbst bauen, etwa um „a test handler to simulate specific scenarios“ einzusetzen. Der Aufbau:

C#
var handlers = GraphClientFactory.CreateDefaultHandlers();
handlers.Add(new ChaosHandler());
var httpClient = GraphClientFactory.Create(handlers);
var customGraphClient = new GraphServiceClient(httpClient, authProvider);

Wir nutzen denselben Weg, setzen aber nur einen Handler in die Kette: einen, der die Antwort selbst gibt und die Anfrage nicht weiterreicht. Dass ein DelegatingHandler die Antwort selbst erzeugen und das Weiterreichen sparen darf, steht in der Dokumentation zu HTTP-Nachrichtenhandlern (Weiterlesen). Den authProvider baut der Test aus einem Stellvertreter für das TokenCredential: Er gibt ein Token zurück, das keinen Wert hat, und kein Netz sieht je eine Anmeldung.

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));
    }
}

Auch hier gilt: Der Test baut den echten Client und tauscht nur das Netz aus, damit er Adresse, Abfrageoptionen und das Lesen der Antwort mitprüft. TokenCredential und AccessToken sind Typen aus Azure.Core, die auch Lektion 2.8.5 benutzt. Offen: Dass GraphClientFactory.Create eine Liste mit nur diesem einen Handler annimmt und dass AzureIdentityAuthenticationProvider mit diesen Argumenten gebaut wird, folgt aus den Beispielen von Learn (Liste aus CreateDefaultHandlers(), Konstruktor mit scopes:); wir haben es nicht übersetzt. Bricht der Compiler an einer Stelle, ist das der Ort, an dem Sie nachsehen.

Der erste Test prüft, wonach die Klasse fragt:

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": "Teamrunde", "start": { "dateTime": "2025-11-05T09:00:00.0000000", "timeZone": "UTC" } },
                { "subject": "Abstimmung Reiseplan", "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(["Abstimmung Reiseplan", "Teamrunde"]);
    }
}

Die Beispiele nehmen den 5. November 2025 als festen Tag. Der Test nimmt nie den heutigen Tag, sonst wäre er morgen ein anderer. Die Prüfung des Adressteils ist grob (ShouldContain), weil wir nicht gelesen haben, wie das SDK die Abfrage genau kodiert; Uri.UnescapeDataString macht sie vergleichbar.

Übung 1: Anfragen im Graph Explorer (ohne Mandanten)

Eine Anfrage bauen, einschränken und lesen. Sie brauchen einen Browser und keinen Mandanten: Die Aufgabe läuft mit den Beispieldaten des Graph Explorers ohne Anmeldung. Melden Sie sich nicht an.

  1. Öffnen Sie den Graph Explorer (Adresse in „Weiterlesen“) und wählen Sie die Beispielabfrage „Get my profile“. Lesen Sie die Anfrage: Methode, Version, Ressource. Führen Sie sie mit Run query aus.
  2. Suchen Sie in der Antwort die Eigenschaft @microsoft.graph.tips. Wenn sie da ist, steht dort der Hinweis auf $select.
  3. Setzen Sie die Version auf v1.0 (falls sie es nicht schon ist) und ändern Sie die Anfrage in me?$select=displayName,jobTitle. Führen Sie sie aus.
  4. Schreiben Sie die Anfrage users?$top=2 und führen Sie sie aus. Suchen Sie @odata.nextLink.
  5. Öffnen Sie den Reiter Code snippets, wählen Sie C# und lesen Sie, wie der Explorer die Anfrage von Schritt 3 oder 4 als SDK-Code schreibt.
  6. Notieren Sie für jede Anfrage in einer Zeile: Methode, Adresse, Statuscode.

So prüfen Sie: In Schritt 3 hat die Antwort weniger Eigenschaften als in Schritt 1, und @microsoft.graph.tips fehlt. In Schritt 4 steht, wenn der Beispielmandant mehr als zwei Benutzer hat (das wissen wir nicht), ein @odata.nextLink. Der C#-Auszug enthält graphClient.Me.GetAsync(...) mit Select und bei users QueryParameters.Top. Der Explorer zeigt, ob die Anfrage Erfolg hatte, mit dem Statuscode.

Aufräumen: nichts, denn Sie haben nur gelesen. Hätten Sie sich angemeldet und einen Schreibaufruf probiert, gehörte das Zurücknehmen dazu, und es gehörte nicht in einen Produktivmandanten.

Lösung 1

Was Sie sehen müssen, steht in den Prüfhinweisen. Häufige Stolpersteine:

  • Die Anfrage wird mit einem Fehler zu Berechtigungen abgewiesen: Sie sind angemeldet oder haben eine Beispielabfrage gewählt, die eine Anmeldung verlangt. Wählen Sie eine andere Beispielabfrage oder bleiben Sie bei „Get my profile“.
  • Der Explorer meldet eine Anfrage ohne $ als Fehler oder ignoriert die Option: Schreiben Sie $select, nicht select (Learn: immer mit $).
  • Der C#-Auszug sieht anders aus als in der Lektion: Die Auszüge sind für die Hauptversion 5 des SDK geschrieben, und der Explorer wählt andere Variablennamen. Gleich sind der Weg graphClient.Me und die Eigenschaften von QueryParameters.

Übung 2: Die Termine des Tages ohne Mandanten testen

Den Test aus der Lektion bauen. Diese Übung braucht keinen Mandanten und kein Konto: Der Test ersetzt das Netz.

  1. Legen Sie die Projekte an: dotnet new console -o src/Kladde.GraphLab und dotnet new xunit -o tests/Kladde.GraphLab.Tests, dazu in beiden die Pakete: Microsoft.Graph und Azure.Identity für die Anwendung, im Test Shouldly und einen Verweis auf das Anwendungsprojekt (dotnet add tests/Kladde.GraphLab.Tests reference src/Kladde.GraphLab). Passen Sie die Namen an Ihre Lösung an.
  2. Schreiben Sie GraphLabClient, CalendarDay und Program wie in der Lektion.
  3. Schreiben Sie FakeGraphHandler, GraphTestClient und die zwei Tests.
  4. Eine dritte Prüfung kommt von Ihnen: Ein Test TheProfileLineHasNameAndTitle, der WhoAmI gegen die Antwort { "displayName": "Mira Hansen", "jobTitle": "Büroleitung" } prüft und erwartet, dass die Zeile Mira Hansen (Büroleitung) herauskommt und dass die Adresse /me mit $select=displayName,jobTitle ist.
  5. Lassen Sie die Tests laufen und lesen Sie den Exit-Code: dotnet test und $LASTEXITCODE in der PowerShell.

So prüfen Sie: Alle drei Tests sind grün, der Exit-Code ist 0, und das gelingt mit gezogenem Netzwerkkabel. Dann die Gegenprobe: Ändern Sie in CalendarDay den Aufruf day.AddDays(1) in day.AddDays(2); der erste Test muss rot werden und den falschen Endzeitpunkt nennen. Machen Sie die Änderung rückgängig.

Aufräumen: nichts außerhalb Ihres Rechners; Sie können die Projekte behalten, Lektion W4.2.1 baut darauf auf.

Lösung 2

Der Test für das Profil:

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": "Büroleitung" }""");
        WhoAmI who = new(GraphTestClient.With(fake));

        string line = await who.DescribeAsync();

        line.ShouldBe("Mira Hansen (Büroleitung)");
        Uri address = fake.Requests.ShouldHaveSingleItem().Address!;
        address.AbsolutePath.ShouldEndWith("/me");
        Uri.UnescapeDataString(address.Query).ShouldContain("$select=displayName,jobTitle");
    }
}

Häufige Fehler:

  • Der Test hängt ohne Ergebnis: Der Handler wurde nicht in die Kette gesetzt (GraphClientFactory.Create ohne die Liste) oder der Client nutzt eine eigene Kette mit echtem Netz. Prüfen Sie GraphTestClient.With.
  • Die Anfrage geht doch ins Netz und scheitert an der Anmeldung: Der authProvider des Tests hat das Stellvertreter-TokenCredential nicht bekommen.
  • ShouldContain("$select=subject,start") schlägt fehl, weil das SDK die Abfrage anders kodiert: Sehen Sie sich address.Query im Test an (address.ToString() in der Fehlermeldung) und passen Sie die Prüfung an; die Aussage bleibt: Die Option wurde gesendet.
  • Der Compiler kennt Event oder User nicht: Der Namensraum Microsoft.Graph.Models fehlt (siehe die Anmerkung zu Namensräumen).
  • Die Zeitzone im Test lässt sich nicht finden: Sie haben FindSystemTimeZoneById genommen; die Lektion baut sie mit CreateCustomTimeZone, damit der Test nicht von der Zeitzonen-Datenbank des Rechners abhängt.

Übung 3: Seiten lesen, ohne den Dienst zu fragen

Prüfen, dass der Iterator alle Seiten liest. Diese Übung braucht keinen Mandanten.

  1. Schreiben Sie einen Test AllPagesAreReadAndTheNextLinkIsFollowed in CalendarDayTests. Er baut einen FakeGraphHandler mit einer Funktion, die je nach Anfrage antwortet: Seite 1 mit zwei Terminen und einem @odata.nextLink (https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=zweite-seite), Seite 2 mit einem Termin und ohne @odata.nextLink.
  2. Lesen Sie den Tag und erwarten Sie drei Einträge.
  3. Prüfen Sie, dass es genau zwei Anfragen waren und dass die zweite das skiptoken trägt.
  4. Fügen Sie einen zweiten Test hinzu, in dem Seite 1 keinen Termin hat, aber einen @odata.nextLink, und Seite 2 zwei: Das Ergebnis muss zwei Einträge sein (Regel 3 oben).

So prüfen Sie: Beide Tests sind grün, $LASTEXITCODE ist 0. Gegenprobe: Schreiben Sie CalendarDay vorübergehend so um, dass es nur first.Value liest (ohne PageIterator); der erste Test muss rot werden und eine Anfrage statt zwei zeigen.

Aufräumen: Machen Sie die Gegenprobe rückgängig.

Lösung 3

Beide Tests gehören in die Klasse CalendarDayTests, neben das Feld PlusOne.

C#
    [Fact]
    public async Task AllPagesAreReadAndTheNextLinkIsFollowed()
    {
        FakeGraphHandler fake = new(request => request.RequestUri!.Query.Contains("skiptoken")
            ? FakeGraphHandler.Reply("""
                { "value": [ { "subject": "Dritter", "start": { "dateTime": "2025-11-05T10:00:00.0000000", "timeZone": "UTC" } } ] }
                """)
            : FakeGraphHandler.Reply("""
                {
                  "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=zweite-seite",
                  "value": [
                    { "subject": "Erster", "start": { "dateTime": "2025-11-05T08:00:00.0000000", "timeZone": "UTC" } },
                    { "subject": "Zweiter", "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=zweite-seite");
    }

    [Fact]
    public async Task AnEmptyPageWithANextLinkIsNotTheEnd()
    {
        FakeGraphHandler fake = new(request => request.RequestUri!.Query.Contains("skiptoken")
            ? FakeGraphHandler.Reply("""
                { "value": [
                  { "subject": "Erster", "start": { "dateTime": "2025-11-05T08:00:00.0000000", "timeZone": "UTC" } },
                  { "subject": "Zweiter", "start": { "dateTime": "2025-11-05T09:00:00.0000000", "timeZone": "UTC" } } ] }
                """)
            : FakeGraphHandler.Reply("""
                { "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=zweite-seite", "value": [] }
                """));
        CalendarDay day = new(GraphTestClient.With(fake));

        IReadOnlyList<DayEntry> entries = await day.ReadAsync(new DateOnly(2025, 11, 5), PlusOne);

        entries.Count.ShouldBe(2);
    }

Fehler: Der Test endet nach einer Anfrage, obwohl @odata.nextLink da ist: Der Iterator bekommt nur die erste Antwort, wenn Sie first.Value statt PageIterator lesen. Der Test läuft endlos: Die zweite Antwort trägt auch einen @odata.nextLink; prüfen Sie die Funktion, die die Antwort wählt. Wenn der Iterator die zweite Anfrage an eine andere Adresse schickt als erwartet, lesen Sie fake.Requests[1].Address im Debugger: Die Adresse ist die aus dem JSON, ergänzt um das, was das SDK selbst anhängt.

Übung 4: Ein Lauf im Testmandanten

Das Werkzeug gegen echte Daten laufen lassen. Diese Übung braucht einen Testmandanten, nie einen Produktivmandanten: Das Werkzeug liest nur, aber Sie legen eine App-Registrierung an und stimmen Berechtigungen zu. Learn empfiehlt für Versuche den Microsoft 365 Developer-Sandbox-Mandanten (ob das Entwicklerprogramm für Sie offen ist, prüfen Sie bei Microsoft). Wer keinen Testmandanten hat, macht die Übungen 2 und 3: Sie prüfen denselben Code ohne Mandanten und lassen diese Übung aus.

  1. Registrieren Sie im Microsoft Entra Admin Center eine App (Weg nach Learn: Identity, Applications, App registrations, New registration). Name kladde-graphlab-test; Supported account types: „Accounts in this organizational directory only“. Die kleinste Rolle dafür ist nach Learn „Cloud Application Administrator“. Kein Redirect-URI.
  2. Unter Authentication schalten Sie Allow public client flows auf Yes (Learn: der Gerätecode-Ablauf gilt nur für öffentliche Clients).
  3. Unter API permissions fügen Sie für Microsoft Graph die delegierten Berechtigungen User.Read und Calendars.Read hinzu.
  4. Notieren Sie die Application (client) ID und die Mandanten-ID aus der Übersicht, nur im Fenster Ihrer Shell: $env:GRAPHLAB_CLIENT_ID = "<client-id>" und $env:GRAPHLAB_TENANT_ID = "<tenant-id>".
  5. Legen Sie im Outlook der Testperson einen Termin für heute an, Betreff Probe Graph.
  6. Starten Sie dotnet run --project src/Kladde.GraphLab -- me, melden Sie sich mit dem Code an und starten Sie danach ... -- day.
  7. Wenn Ihr Mandant die Einwilligung für Benutzer sperrt, meldet die Anmeldung einen Fehler zur Einwilligung. Das ist der Stoff der Lektion W4.1.2; legen Sie die Übung beiseite oder bitten Sie die Administration Ihres Testmandanten um die Zustimmung.

So prüfen Sie: me schreibt eine Zeile mit Name und Funktion der Testperson, day eine Zeile mit Beginn (UTC) und Probe Graph. Beides haben wir nicht gesehen, nur aus Learn abgeleitet. Und dotnet run endet mit Exit-Code 0.

Aufräumen: Remove-Item Env:GRAPHLAB_CLIENT_ID, Env:GRAPHLAB_TENANT_ID; den Termin im Outlook löschen; die App-Registrierung kladde-graphlab-test in der Liste der App-Registrierungen löschen (Menüweg dafür nicht auf den gelesenen Seiten); das Fenster schließen. Eine App-Registrierung ist eine Tür zu Ihrem Mandanten; lassen Sie sie nicht stehen.

Lösung 4

Typische Fehler:

  • AADSTS700016: Learn nennt als Ursache, dass die App unter dieser ID im Mandanten nicht gefunden wurde. Prüfen Sie GRAPHLAB_CLIENT_ID und GRAPHLAB_TENANT_ID (richtiger Mandant, keine Verwechslung mit der Objekt-ID).
  • Die Anmeldung scheitert beim Gerätecode, weil die Registrierung kein öffentlicher Client ist: Schalten Sie Allow public client flows ein (Schritt 2).
  • AADSTS65001: Die Einwilligung fehlt. Lektion W4.1.2 erklärt, wer zustimmen darf und wie.
  • 403: Die Berechtigung fehlt, oder die Person darf selbst nicht, was sie über die App tun soll; das Wirksame bei delegiert ist der Schnitt aus beidem (Lektion W4.1.2).
  • day ist leer, obwohl Sie einen Termin haben: Der Termin liegt außerhalb des Fensters von Mitternacht bis Mitternacht Ihrer Ortszeit, oder er liegt in einem anderen Kalender als dem Standardkalender; me/calendarView liest den Standardkalender.

Was Sie jetzt können

  • Sie sagen, was Microsoft Graph ist, und grenzen die Graph API von Data Connect und Copilot-Konnektoren ab.
  • Sie lesen und schreiben eine Anfrage aus Methode, Version, Ressource und Abfrageoptionen, kennen die 4-MB-Grenze für schreibende Anfragen und wissen, warum beta nicht in den Betrieb gehört.
  • Sie probieren Anfragen im Graph Explorer ohne Mandanten aus und wissen, dass schreibende Versuche in einen Testmandanten gehören.
  • Sie nutzen $select, $filter, $orderby, $top, $count und $expand, schreiben $ immer mit und prüfen an der Antwort, ob eine Option gewirkt hat.
  • Sie lesen alle Seiten einer Sammlung über @odata.nextLink, mit dem PageIterator oder von Hand, und kennen die Fallen: ganze Adresse nehmen, leere Seiten, Kopfzeilen wieder setzen.
  • Sie bauen einen GraphServiceClient mit einem TokenCredential, rufen me und me/calendarView auf und testen den Code ohne Mandanten mit einem nachgemachten Handler.

Weiterlesen

Die Kladde: Sie hat jetzt Kladde.GraphLab, ein Werkzeug, das mit einem GraphServiceClient das Profil einer angemeldeten Person und die Termine eines Tages liest, und drei Tests, die dafür kein Konto brauchen. Was dem Werkzeug noch fehlt, ist das Verständnis, warum es genau zwei Berechtigungen anfordert und was eine Administration dazu sagen muss. Das ist die nächste Lektion.