diff --git a/DirectConnector/ConnectorFunctions.cs b/DirectConnector/ConnectorFunctions.cs deleted file mode 100644 index f3dbf0c..0000000 --- a/DirectConnector/ConnectorFunctions.cs +++ /dev/null @@ -1,1362 +0,0 @@ -//------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -//------------------------------------------------------------ - -using System.Net; -using System.Text; -using System.Text.Json; -using Azure.Connectors.Sdk; -using Azure.Connectors.Sdk.Office365; -using Azure.Connectors.Sdk.Office365.Models; -using Azure.Connectors.Sdk.SharePointOnline; -using Azure.Connectors.Sdk.SharePointOnline.Models; -using Azure.Connectors.Sdk.Teams; -using Azure.Connectors.Sdk.Teams.Models; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using SharePointBlobMetadata = Azure.Connectors.Sdk.SharePointOnline.Models.BlobMetadata; - -namespace DirectConnector; - -/// -/// Azure Functions that use the generated , , -/// and from the Azure Connectors SDK. -/// -/// -/// Demonstrates DI-based lifetime management, JSON deserialization for structured responses, -/// binary content (byte[]) download and upload, connector-specific exception handling, -/// and CancellationToken propagation from the Functions host. -/// -public class ConnectorFunctions -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - /// - /// Maximum accepted request body size for trigger callbacks (1 MB). - /// Requests exceeding this size are rejected with 200 OK to avoid Connector Gateway retries. - /// - private const int MaxTriggerCallbackBodySize = 1 * 1024 * 1024; - - /// - /// Teams connector API path template for posting messages. - /// Parameters: {0} = poster (e.g., "Flow bot"), {1} = location (e.g., "Channel"). - /// - private const string TeamsPostMessagePathTemplate = "/beta/teams/conversation/message/poster/{0}/location/{1}"; - - /// - /// Default poster identity for Teams messages posted via the connector. - /// - private const string TeamsDefaultPoster = "Flow bot"; - - /// - /// Default message location for Teams channel posts. - /// - private const string TeamsDefaultLocation = "Channel"; - - private readonly ILogger _logger; - private readonly Office365Client _office365Client; - private readonly SharePointOnlineClient _sharePointClient; - private readonly TeamsClient _teamsClient; - - /// - /// Initializes a new instance of the class. - /// - /// The logger instance. - /// The DI-injected Office365 client (disposed by the host). - /// The DI-injected SharePoint client (disposed by the host). - /// The DI-injected Teams client (disposed by the host). - public ConnectorFunctions( - ILogger logger, - Office365Client office365Client, - SharePointOnlineClient sharePointClient, - TeamsClient teamsClient) - { - this._logger = logger; - this._office365Client = office365Client; - this._sharePointClient = sharePointClient; - this._teamsClient = teamsClient; - } - - /// - /// Sends an email using the generated . - /// - /// The HTTP request containing email details. - /// The cancellation token. - [Function("SendEmail")] - public async Task SendEmailAsync( - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "email")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("SendEmail: Using generated Office365Client from SDK."); - - try - { - using var reader = new StreamReader(request.Body); - var body = await reader - .ReadToEndAsync(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - SendEmailRequest? input; - try - { - input = JsonSerializer.Deserialize(body, ConnectorFunctions.JsonOptions); - } - catch (JsonException ex) - { - this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); - - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) - .ConfigureAwait(continueOnCapturedContext: false); - - return badRequest; - } - - if (input == null || string.IsNullOrEmpty(input.To)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Invalid request body - 'to' is required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - var emailMessage = new SendEmailInput - { - To = input.To, - Subject = input.Subject ?? "No Subject", - Body = input.Body ?? string.Empty - }; - - await this._office365Client - .SendEmailAsync(emailMessage, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - message = "Email sent via generated Office365Client from SDK.", - to = input.To, - subject = input.Subject, - timestamp = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in SendEmail."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Gets Outlook categories using the generated . - /// - /// The HTTP request. - /// The cancellation token. - [Function("GetCategories")] - public async Task GetCategoriesAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "categories")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("GetCategories: Using generated Office365Client from SDK."); - - try - { - var categories = await this._office365Client - .GetOutlookCategoryNamesAsync(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - count = categories?.Count ?? 0, - categories = categories - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in GetCategories."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Gets all SharePoint lists and libraries for a site using the generated . - /// - /// The HTTP request containing the site address. - /// The cancellation token. - [Function("GetSharePointLists")] - public async Task GetSharePointListsAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sharepoint/lists")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("GetSharePointLists: Using generated SharePointOnlineClient from SDK."); - - var siteAddress = request.Query["site"]; - if (string.IsNullOrEmpty(siteAddress)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Query parameter 'site' is required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - try - { - var tables = await this._sharePointClient - .GetAllTablesAsync(siteAddress, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - site = siteAddress, - count = tables?.Value?.Count ?? 0, - lists = tables?.Value?.Select(table => new - { - name = table.Name, - displayName = table.DisplayName - }) - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in GetSharePointLists."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Lists files in a SharePoint folder using the generated . - /// - /// - /// Exercises the model for folder browsing. - /// - /// The HTTP request containing site and optional folder identifier. - /// The cancellation token. - [Function("ListFolder")] - public async Task ListFolderAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sharepoint/files")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("ListFolder: Using generated SharePointOnlineClient from SDK."); - - var siteAddress = request.Query["site"]; - if (string.IsNullOrEmpty(siteAddress)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Query parameter 'site' is required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - try - { - var folderId = request.Query["folder"]; - - // NOTE: ListRootFolderAsync vs ListFolderAsync demonstrates - // two overloads with the same return type but different parameter sets. - var files = string.IsNullOrEmpty(folderId) - ? await this._sharePointClient - .ListRootFolderAsync(siteAddress, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false) - : await this._sharePointClient - .ListFolderAsync(siteAddress, folderId, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - site = siteAddress, - folder = string.IsNullOrEmpty(folderId) ? "(root)" : folderId, - count = files?.Count ?? 0, - files = (files ?? Enumerable.Empty()).Select(file => new - { - id = file.Id, - name = file.Name, - displayName = file.DisplayName, - path = file.Path, - size = file.Size, - mediaType = file.MediaType, - isFolder = file.IsFolder - }) - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in ListFolder."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Downloads file content from SharePoint as binary bytes. - /// - /// - /// Exercises the byte[] response path in . - /// The generated CallConnectorAsync detects byte[] as the response type and uses - /// ReadAsByteArrayAsync instead of JSON deserialization. - /// - /// The HTTP request containing site address and file path. - /// The cancellation token. - [Function("DownloadFile")] - public async Task DownloadFileAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sharepoint/download")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("DownloadFile: Using generated SharePointOnlineClient byte[] response path."); - - var siteAddress = request.Query["site"]; - var filePath = request.Query["path"]; - if (string.IsNullOrEmpty(siteAddress) || string.IsNullOrEmpty(filePath)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Query parameters 'site' and 'path' are required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - try - { - // NOTE: This exercises the byte[] return path in the generated client. - // CallConnectorAsync uses ReadAsByteArrayAsync instead of JSON deserialization. - var fileBytes = await this._sharePointClient - .GetFileContentByPathAsync(siteAddress, filePath, cancellationToken: cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - // NOTE: Sanitize the filename to prevent response header injection. - // Path.GetFileName strips directory traversal, and we remove CR/LF and quotes - // that could corrupt the Content-Disposition header value. - var fileName = System.IO.Path.GetFileName(filePath) - .Replace("\r", string.Empty) - .Replace("\n", string.Empty) - .Replace("\"", string.Empty); - - var response = request.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/octet-stream"); - response.Headers.Add("Content-Disposition", $"attachment; filename=\"{fileName}\""); - await response.Body - .WriteAsync(fileBytes, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - this._logger.LogInformation("Downloaded '{FileName}': '{ByteCount}' bytes.", fileName, fileBytes.Length); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in DownloadFile."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Uploads a file to a SharePoint document library. - /// - /// - /// Exercises the byte[] input path in . - /// Accepts a JSON body with base64-encoded content or plain text, and uploads it to - /// the specified SharePoint folder. - /// - /// The HTTP request containing upload details. - /// The cancellation token. - [Function("UploadFile")] - public async Task UploadFileAsync( - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "sharepoint/upload")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("UploadFile: Using generated SharePointOnlineClient byte[] input path."); - - try - { - using var reader = new StreamReader(request.Body); - var body = await reader - .ReadToEndAsync(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - UploadFileRequest? input; - try - { - input = JsonSerializer.Deserialize(body, ConnectorFunctions.JsonOptions); - } - catch (JsonException ex) - { - this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); - - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) - .ConfigureAwait(continueOnCapturedContext: false); - - return badRequest; - } - - if (input == null || - string.IsNullOrEmpty(input.Site) || - string.IsNullOrEmpty(input.FolderPath) || - string.IsNullOrEmpty(input.FileName)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Fields 'site', 'folderPath', and 'fileName' are required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - // NOTE: Support both base64-encoded binary and plain text content. - byte[] fileBytes; - try - { - fileBytes = !string.IsNullOrEmpty(input.ContentBase64) - ? Convert.FromBase64String(input.ContentBase64) - : Encoding.UTF8.GetBytes(input.Content ?? string.Empty); - } - catch (FormatException ex) - { - this._logger.LogError(ex, "Invalid base64 content in 'contentBase64'."); - - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "The 'contentBase64' field must contain valid base64-encoded data." }) - .ConfigureAwait(continueOnCapturedContext: false); - - return badRequest; - } - - var metadata = await this._sharePointClient - .CreateFileAsync(input.Site, fileBytes, input.FolderPath, input.FileName, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - message = $"File '{input.FileName}' uploaded to '{input.FolderPath}'.", - fileId = metadata?.Id, - name = metadata?.Name, - path = metadata?.Path, - size = fileBytes.Length - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in UploadFile."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Exports an email message as raw RFC822 (.eml) bytes. - /// - /// - /// Exercises the byte[] response path in . - /// This is the Office365 counterpart to for SharePoint — - /// both prove that CallConnectorAsync<byte[]> uses ReadAsByteArrayAsync - /// instead of JSON deserialization. - /// - /// The HTTP request containing the message ID. - /// The cancellation token. - [Function("ExportEmail")] - public async Task ExportEmailAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "email/export")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("ExportEmail: Using generated Office365Client byte[] response path."); - - var messageId = request.Query["messageId"]; - if (string.IsNullOrEmpty(messageId)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Query parameter 'messageId' is required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - try - { - // NOTE: This exercises the same byte[] return path as SharePoint's - // GetFileContentByPathAsync, proving the pattern works across connectors. - var emailBytes = await this._office365Client - .ExportEmailAsync(messageId, cancellationToken: cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "message/rfc822"); - response.Headers.Add("Content-Disposition", "attachment; filename=\"exported-email.eml\""); - await response.Body - .WriteAsync(emailBytes, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - this._logger.LogInformation("Exported email '{MessageId}': '{ByteCount}' bytes.", messageId, emailBytes.Length); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in ExportEmail."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Receives Connector Gateway trigger callback with raw triggerBody() JSON. - /// - /// - /// The Connector Gateway provisions a hidden Consumption Logic App that polls for trigger events - /// (e.g., OnNewEmail). When fired, it POSTs @triggerBody() to this callback URL - /// with a function key via ?code= query parameter. - /// - /// Unauthenticated requests (missing or invalid function key) are rejected with HTTP 401 - /// by the Functions runtime before this handler runs. - /// - /// For authenticated invocations, all exceptions return 200 to prevent Connector Gateway retries. - /// - /// The HTTP request containing the trigger payload. - /// The cancellation token. - [Function("TriggerCallback")] - [ConnectorTriggerMetadata( - ConnectorName = ConnectorNames.Office365, - OperationName = Office365TriggerOperations.OnNewEmail, - Connection = "Connectors:Office365")] - public async Task TriggerCallbackAsync( - // NOTE: Function-level key auth. Connector Gateway includes the key via ?code= query parameter - // in the callbackUrl configured in the TriggerConfig. Preview uses function key; MI before GA. - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "triggerCallback")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("TriggerCallback: Received Connector Gateway trigger callback."); - - try - { - // NOTE: Check Content-Length header first (works for all streams), - // then fall back to Body.Length for seekable streams. - long contentLength = -1; - if (request.Headers.TryGetValues("Content-Length", out var contentLengthHeaderValues) && - long.TryParse(contentLengthHeaderValues.FirstOrDefault(), out var parsedLength)) - { - contentLength = parsedLength; - } - - if (contentLength > ConnectorFunctions.MaxTriggerCallbackBodySize || - (contentLength < 0 && request.Body.CanSeek && request.Body.Length > ConnectorFunctions.MaxTriggerCallbackBodySize)) - { - this._logger.LogWarning("TriggerCallback: Payload too large. Rejecting."); - - var rejectResponse = request.CreateResponse(HttpStatusCode.OK); - await rejectResponse - .WriteAsJsonAsync(new - { - success = true, - message = "Trigger callback received (payload too large, discarded).", - receivedAt = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return rejectResponse; - } - - // NOTE: Read at most (limit + 1) chars so oversized non-seekable - // payloads without Content-Length can still be detected reliably. - using var reader = new StreamReader(request.Body); - var buffer = new char[ConnectorFunctions.MaxTriggerCallbackBodySize + 1]; - var charsRead = await reader - .ReadBlockAsync(buffer.AsMemory(0, buffer.Length), cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - if (charsRead > ConnectorFunctions.MaxTriggerCallbackBodySize) - { - this._logger.LogWarning("TriggerCallback: Payload too large. Rejecting."); - - var rejectResponse = request.CreateResponse(HttpStatusCode.OK); - await rejectResponse - .WriteAsJsonAsync(new - { - success = true, - message = "Trigger callback received (payload too large, discarded).", - receivedAt = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return rejectResponse; - } - - var body = new string(buffer, 0, charsRead); - - // NOTE: Use SDK's per-trigger convenience type for typed deserialization. - // Office365OnNewEmailTriggerPayload is a subclass of TriggerCallbackPayload - // that provides discoverability — the developer no longer needs to know the inner type. - var payload = JsonSerializer.Deserialize( - body, - ConnectorFunctions.JsonOptions); - - var emails = payload?.Body?.Value; - var emailCount = emails?.Count ?? 0; - - this._logger.LogInformation( - "TriggerCallback: Deserialized '{EmailCount}' email(s) using Office365OnNewEmailTriggerPayload.", - emailCount); - - // NOTE: Cap per-email logging to avoid unbounded log volume on batch triggers. - // Log only message IDs (not PII like Subject/From) to reduce accidental exposure. - if (emails != null) - { - foreach (var email in emails.Take(5)) - { - this._logger.LogDebug( - "TriggerCallback email: Id='{Id}', ReceivedTime='{ReceivedTime}', HasAttachments='{HasAttachments}', Importance='{Importance}'.", - email.MessageId, - email.ReceivedTime, - email.HasAttachment, - email.Importance); - } - - if (emailCount > 5) - { - this._logger.LogDebug("TriggerCallback: '{RemainingCount}' additional email(s) not logged.", emailCount - 5); - } - } - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - message = "Trigger callback received (typed deserialization via SDK).", - receivedAt = DateTime.UtcNow, - emailCount - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (JsonException ex) - { - this._logger.LogError(ex, "TriggerCallback: Invalid JSON payload: '{Message}'.", ex.Message); - - var errorResponse = request.CreateResponse(HttpStatusCode.OK); - await errorResponse - .WriteAsJsonAsync(new - { - success = true, - message = "Trigger callback received (non-JSON payload).", - receivedAt = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in TriggerCallback."); - - // NOTE: Return 200 even on unexpected errors — Connector Gateway treats any 2xx - // as "delivered" and we don't want transient failures to cause retries. - var errorResponse = request.CreateResponse(HttpStatusCode.OK); - await errorResponse - .WriteAsJsonAsync(new - { - success = true, - message = "Trigger callback received (processing error).", - receivedAt = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Request model for sending email. - /// - private record SendEmailRequest(string? To, string? Subject, string? Body); - - /// - /// Request model for uploading a file to SharePoint. - /// - private record UploadFileRequest( - string? Site, - string? FolderPath, - string? FileName, - string? Content, - string? ContentBase64); - - /// - /// Request model for posting a Teams message. - /// - private record PostTeamsMessageRequest(string? TeamId, string? ChannelId, string? Message); - - /// - /// Request model for creating a calendar event. - /// - private record CreateCalendarEventRequest( - string? CalendarId, - string? Subject, - string? Body, - string? StartTime, - string? EndTime, - string? TimeZone, - string? RequiredAttendees); - - /// - /// Lists all Teams the signed-in user is a member of using the generated . - /// - /// The HTTP request. - /// The cancellation token. - [Function("GetAllTeams")] - public async Task GetAllTeamsAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "teams/teams")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("GetAllTeams: Using generated TeamsClient from SDK."); - - try - { - var result = await this._teamsClient - .GetAllTeamsAsync(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - count = result?.TeamsList?.Count ?? 0, - teams = result?.TeamsList - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in GetAllTeams."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Lists all channels for a specific team using the generated . - /// - /// The HTTP request containing the team ID. - /// The cancellation token. - [Function("GetTeamChannels")] - public async Task GetTeamChannelsAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "teams/channels")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("GetTeamChannels: Using generated TeamsClient from SDK."); - - var teamId = request.Query["teamId"]; - if (string.IsNullOrEmpty(teamId)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Query parameter 'teamId' is required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - try - { - var result = await this._teamsClient - .GetChannelsForGroupAsync(teamId, cancellationToken: cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - teamId, - count = result?.ChannelList?.Count ?? 0, - channels = result?.ChannelList?.Select(channel => new - { - id = channel.ChannelID, - displayName = channel.DisplayName, - description = channel.DescriptionOfChannel, - membershipType = channel.TheTypeOfTheChannel - }) - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in GetTeamChannels."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Gets all messages from a Teams channel, automatically paginating across all pages. - /// - /// - /// Demonstrates pagination: GetMessagesFromChannelAsync - /// returns a ConnectorPageable that follows @odata.nextLink automatically. - /// The caller uses await foreach and never sees pagination details. - /// - /// The HTTP request with teamId and channelId query parameters. - /// The cancellation token. - [Function("GetChannelMessages")] - public async Task GetChannelMessagesAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "teams/messages")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("GetChannelMessages: Demonstrating IAsyncEnumerable pagination."); - - var teamId = request.Query["teamId"]; - var channelId = request.Query["channelId"]; - - if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(channelId)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Query parameters 'teamId' and 'channelId' are required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - try - { - // GetMessagesFromChannelAsync returns IAsyncEnumerable that automatically - // follows @odata.nextLink pagination across all pages. - var messages = new List(); - await foreach (var message in this._teamsClient - .GetMessagesFromChannelAsync(teamId, channelId) - .WithCancellation(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false)) - { - messages.Add(new - { - id = message.Id, - subject = message.Subject, - messageType = message.MessageType, - createdDateTime = message.CreationTimestamp, - from = message.From - }); - } - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - teamId, - channelId, - totalMessages = messages.Count, - messages - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in GetChannelMessages."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new { success = false, error = ex.Message }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Posts a message to a Teams channel using the generated . - /// - /// The HTTP request containing team, channel, and message details. - /// The cancellation token. - [Function("PostTeamsMessage")] - public async Task PostTeamsMessageAsync( - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "teams/message")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("PostTeamsMessage: Using generated TeamsClient from SDK."); - - try - { - using var reader = new StreamReader(request.Body); - var body = await reader - .ReadToEndAsync(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - PostTeamsMessageRequest? input; - try - { - input = JsonSerializer.Deserialize(body, ConnectorFunctions.JsonOptions); - } - catch (JsonException ex) - { - this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); - - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) - .ConfigureAwait(continueOnCapturedContext: false); - - return badRequest; - } - - if (input == null || string.IsNullOrEmpty(input.TeamId) || - string.IsNullOrEmpty(input.ChannelId) || string.IsNullOrEmpty(input.Message)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Fields 'teamId', 'channelId', and 'message' are required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - // NOTE: PostMessageToConversationAsync uses DynamicPostMessageRequest (dynamic schema). - // The actual message body properties are determined at runtime by the connector's schema - // discovery endpoint. With [JsonExtensionData] on AdditionalProperties, arbitrary properties - // are now serialized correctly. Populate the dictionary with the expected message fields. - var messageRequest = new Azure.Connectors.Sdk.Teams.Models.DynamicPostMessageRequest(); - messageRequest.AdditionalProperties["recipient"] = JsonSerializer.SerializeToElement( - new - { - groupId = input.TeamId, - channelId = input.ChannelId, - }); - messageRequest.AdditionalProperties["messageBody"] = JsonSerializer.SerializeToElement( - $"

{WebUtility.HtmlEncode(input.Message)}

"); - - var result = await this._teamsClient - .PostMessageToConversationAsync( - postAs: ConnectorFunctions.TeamsDefaultPoster, - postIn: ConnectorFunctions.TeamsDefaultLocation, - input: messageRequest, - cancellationToken: cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - message = "Message posted to Teams channel via generated TeamsClient from SDK.", - messageId = result?.MessageID, - messageLink = result?.MessageLink, - timestamp = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in PostTeamsMessage."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } - - /// - /// Creates a calendar event using the generated . - /// - /// The HTTP request containing calendar event details. - /// The cancellation token. - [Function("CreateCalendarEvent")] - public async Task CreateCalendarEventAsync( - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "calendar/event")] HttpRequestData request, - CancellationToken cancellationToken) - { - this._logger.LogInformation("CreateCalendarEvent: Using generated Office365Client from SDK."); - - try - { - using var reader = new StreamReader(request.Body); - var body = await reader - .ReadToEndAsync(cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - CreateCalendarEventRequest? input; - try - { - input = JsonSerializer.Deserialize(body, ConnectorFunctions.JsonOptions); - } - catch (JsonException ex) - { - this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); - - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) - .ConfigureAwait(continueOnCapturedContext: false); - - return badRequest; - } - - if (input == null || string.IsNullOrEmpty(input.Subject) || - string.IsNullOrEmpty(input.StartTime) || string.IsNullOrEmpty(input.EndTime)) - { - var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); - await badRequest - .WriteAsJsonAsync(new { error = "Fields 'subject', 'startTime', and 'endTime' are required." }) - .ConfigureAwait(continueOnCapturedContext: false); - return badRequest; - } - - var calendarEvent = new GraphCalendarEventClient - { - Subject = input.Subject, - Body = input.Body ?? string.Empty, - StartTime = input.StartTime, - EndTime = input.EndTime, - TimeZone = input.TimeZone ?? "UTC", - RequiredAttendees = input.RequiredAttendees - }; - - // NOTE: "Calendar" is the default calendar ID for the signed-in user. - var calendarId = input.CalendarId ?? "Calendar"; - - var result = await this._office365Client - .CalendarPostItemAsync(calendarId, calendarEvent, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); - - var response = request.CreateResponse(HttpStatusCode.OK); - await response - .WriteAsJsonAsync(new - { - success = true, - message = "Calendar event created via generated Office365Client from SDK.", - eventId = result?.ICalUId, - subject = result?.Subject, - start = result?.StartTime, - end = result?.EndTime, - timestamp = DateTime.UtcNow - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return response; - } - catch (ConnectorException ex) - { - this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); - - var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message, - statusCode = ex.Status, - details = ex.ResponseBody - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - catch (Exception ex) when (!ex.IsFatal()) - { - this._logger.LogError(ex, "Error in CreateCalendarEvent."); - - var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); - await errorResponse - .WriteAsJsonAsync(new - { - success = false, - error = ex.Message - }) - .ConfigureAwait(continueOnCapturedContext: false); - - return errorResponse; - } - } -} diff --git a/DirectConnector/Office365Functions.cs b/DirectConnector/Office365Functions.cs new file mode 100644 index 0000000..b7c9c69 --- /dev/null +++ b/DirectConnector/Office365Functions.cs @@ -0,0 +1,591 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System.Net; +using System.Text.Json; +using Azure.Connectors.Sdk; +using Azure.Connectors.Sdk.Office365; +using Azure.Connectors.Sdk.Office365.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace DirectConnector; + +/// +/// Azure Functions that use the generated from the Azure Connectors SDK. +/// +/// +/// Demonstrates email operations, calendar events, trigger callbacks, +/// and CancellationToken propagation from the Functions host. +/// +public class Office365Functions +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + /// + /// Maximum accepted request body size for trigger callbacks (1 MB). + /// Requests exceeding this size are rejected with 200 OK to avoid Connector Gateway retries. + /// + private const int MaxTriggerCallbackBodySize = 1 * 1024 * 1024; + + private readonly ILogger _logger; + private readonly Office365Client _office365Client; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The DI-injected Office365 client (disposed by the host). + public Office365Functions( + ILogger logger, + Office365Client office365Client) + { + this._logger = logger; + this._office365Client = office365Client; + } + + /// + /// Sends an email using the generated . + /// + /// The HTTP request containing email details. + /// The cancellation token. + [Function("SendEmail")] + public async Task SendEmailAsync( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "email")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("SendEmail: Using generated Office365Client from SDK."); + + try + { + using var reader = new StreamReader(request.Body); + var body = await reader + .ReadToEndAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + SendEmailRequest? input; + try + { + input = JsonSerializer.Deserialize(body, Office365Functions.JsonOptions); + } + catch (JsonException ex) + { + this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); + + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) + .ConfigureAwait(continueOnCapturedContext: false); + + return badRequest; + } + + if (input == null || string.IsNullOrEmpty(input.To)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Invalid request body - 'to' is required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + var emailMessage = new SendEmailInput + { + To = input.To, + Subject = input.Subject ?? "No Subject", + Body = input.Body ?? string.Empty + }; + + await this._office365Client + .SendEmailAsync(emailMessage, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + message = "Email sent via generated Office365Client from SDK.", + to = input.To, + subject = emailMessage.Subject, + timestamp = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in SendEmail."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Gets Outlook categories using the generated . + /// + /// The HTTP request. + /// The cancellation token. + [Function("GetCategories")] + public async Task GetCategoriesAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "categories")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("GetCategories: Using generated Office365Client from SDK."); + + try + { + var categories = await this._office365Client + .GetOutlookCategoryNamesAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + count = categories?.Count ?? 0, + categories = categories + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in GetCategories."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Exports an email message as raw RFC822 (.eml) bytes. + /// + /// + /// Exercises the byte[] response path in . + /// This is the Office365 counterpart to for SharePoint — + /// both prove that CallConnectorAsync<byte[]> uses ReadAsByteArrayAsync + /// instead of JSON deserialization. + /// + /// The HTTP request containing the message ID. + /// The cancellation token. + [Function("ExportEmail")] + public async Task ExportEmailAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "email/export")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("ExportEmail: Using generated Office365Client byte[] response path."); + + var messageId = request.Query["messageId"]; + if (string.IsNullOrEmpty(messageId)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Query parameter 'messageId' is required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + try + { + // NOTE: This exercises the same byte[] return path as SharePoint's + // GetFileContentByPathAsync, proving the pattern works across connectors. + var emailBytes = await this._office365Client + .ExportEmailAsync(messageId, cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "message/rfc822"); + response.Headers.Add("Content-Disposition", "attachment; filename=\"exported-email.eml\""); + await response.Body + .WriteAsync(emailBytes, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + this._logger.LogInformation("Exported email '{MessageId}': '{ByteCount}' bytes.", messageId, emailBytes.Length); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in ExportEmail."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Receives Connector Gateway trigger callback with raw triggerBody() JSON. + /// + /// + /// The Connector Gateway provisions a hidden Consumption Logic App that polls for trigger events + /// (e.g., OnNewEmail). When fired, it POSTs @triggerBody() to this callback URL + /// with a function key via ?code= query parameter. + /// + /// Unauthenticated requests (missing or invalid function key) are rejected with HTTP 401 + /// by the Functions runtime before this handler runs. + /// + /// For authenticated invocations, all exceptions return 200 to prevent Connector Gateway retries. + /// + /// The HTTP request containing the trigger payload. + /// The cancellation token. + [Function("TriggerCallback")] + [ConnectorTriggerMetadata( + ConnectorName = ConnectorNames.Office365, + OperationName = Office365TriggerOperations.OnNewEmail, + Connection = "Connectors:Office365")] + public async Task TriggerCallbackAsync( + // NOTE: Function-level key auth. Connector Gateway includes the key via ?code= query parameter + // in the callbackUrl configured in the TriggerConfig. Preview uses function key; MI before GA. + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "triggerCallback")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("TriggerCallback: Received Connector Gateway trigger callback."); + + try + { + // NOTE: Check Content-Length header first (works for all streams), + // then fall back to Body.Length for seekable streams. + long contentLength = -1; + if (request.Headers.TryGetValues("Content-Length", out var contentLengthHeaderValues) && + long.TryParse(contentLengthHeaderValues.FirstOrDefault(), out var parsedLength)) + { + contentLength = parsedLength; + } + + if (contentLength > Office365Functions.MaxTriggerCallbackBodySize || + (contentLength < 0 && request.Body.CanSeek && request.Body.Length > Office365Functions.MaxTriggerCallbackBodySize)) + { + this._logger.LogWarning("TriggerCallback: Payload too large. Rejecting."); + + var rejectResponse = request.CreateResponse(HttpStatusCode.OK); + await rejectResponse + .WriteAsJsonAsync(new + { + success = true, + message = "Trigger callback received (payload too large, discarded).", + receivedAt = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return rejectResponse; + } + + // NOTE: Read at most (limit + 1) chars so oversized non-seekable + // payloads without Content-Length can still be detected reliably. + using var reader = new StreamReader(request.Body); + var buffer = new char[Office365Functions.MaxTriggerCallbackBodySize + 1]; + var charsRead = await reader + .ReadBlockAsync(buffer.AsMemory(0, buffer.Length), cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + if (charsRead > Office365Functions.MaxTriggerCallbackBodySize) + { + this._logger.LogWarning("TriggerCallback: Payload too large. Rejecting."); + + var rejectResponse = request.CreateResponse(HttpStatusCode.OK); + await rejectResponse + .WriteAsJsonAsync(new + { + success = true, + message = "Trigger callback received (payload too large, discarded).", + receivedAt = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return rejectResponse; + } + + var body = new string(buffer, 0, charsRead); + + // NOTE: Use SDK's per-trigger convenience type for typed deserialization. + // Office365OnNewEmailTriggerPayload is a subclass of TriggerCallbackPayload + // that provides discoverability — the developer no longer needs to know the inner type. + var payload = JsonSerializer.Deserialize( + body, + Office365Functions.JsonOptions); + + var emails = payload?.Body?.Value; + var emailCount = emails?.Count ?? 0; + + this._logger.LogInformation( + "TriggerCallback: Deserialized '{EmailCount}' email(s) using Office365OnNewEmailTriggerPayload.", + emailCount); + + // NOTE: Cap per-email logging to avoid unbounded log volume on batch triggers. + // Log only message IDs (not PII like Subject/From) to reduce accidental exposure. + if (emails != null) + { + foreach (var email in emails.Take(5)) + { + this._logger.LogDebug( + "TriggerCallback email: Id='{Id}', ReceivedTime='{ReceivedTime}', HasAttachments='{HasAttachments}', Importance='{Importance}'.", + email.MessageId, + email.ReceivedTime, + email.HasAttachment, + email.Importance); + } + + if (emailCount > 5) + { + this._logger.LogDebug("TriggerCallback: '{RemainingCount}' additional email(s) not logged.", emailCount - 5); + } + } + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + message = "Trigger callback received (typed deserialization via SDK).", + receivedAt = DateTime.UtcNow, + emailCount + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (JsonException ex) + { + this._logger.LogError(ex, "TriggerCallback: Invalid JSON payload: '{Message}'.", ex.Message); + + var errorResponse = request.CreateResponse(HttpStatusCode.OK); + await errorResponse + .WriteAsJsonAsync(new + { + success = true, + message = "Trigger callback received (non-JSON payload).", + receivedAt = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in TriggerCallback."); + + // NOTE: Return 200 even on unexpected errors — Connector Gateway treats any 2xx + // as "delivered" and we don't want transient failures to cause retries. + var errorResponse = request.CreateResponse(HttpStatusCode.OK); + await errorResponse + .WriteAsJsonAsync(new + { + success = true, + message = "Trigger callback received (processing error).", + receivedAt = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Creates a calendar event using the generated . + /// + /// The HTTP request containing calendar event details. + /// The cancellation token. + [Function("CreateCalendarEvent")] + public async Task CreateCalendarEventAsync( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "calendar/event")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("CreateCalendarEvent: Using generated Office365Client from SDK."); + + try + { + using var reader = new StreamReader(request.Body); + var body = await reader + .ReadToEndAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + CreateCalendarEventRequest? input; + try + { + input = JsonSerializer.Deserialize(body, Office365Functions.JsonOptions); + } + catch (JsonException ex) + { + this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); + + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) + .ConfigureAwait(continueOnCapturedContext: false); + + return badRequest; + } + + if (input == null || string.IsNullOrEmpty(input.Subject) || + string.IsNullOrEmpty(input.StartTime) || string.IsNullOrEmpty(input.EndTime)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Fields 'subject', 'startTime', and 'endTime' are required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + var calendarEvent = new GraphCalendarEventClient + { + Subject = input.Subject, + Body = input.Body ?? string.Empty, + StartTime = input.StartTime, + EndTime = input.EndTime, + TimeZone = input.TimeZone ?? "UTC", + RequiredAttendees = input.RequiredAttendees + }; + + // NOTE: "Calendar" is the default calendar ID for the signed-in user. + var calendarId = input.CalendarId ?? "Calendar"; + + var result = await this._office365Client + .CalendarPostItemAsync(calendarId, calendarEvent, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + message = "Calendar event created via generated Office365Client from SDK.", + eventId = result?.ICalUId, + subject = result?.Subject, + start = result?.StartTime, + end = result?.EndTime, + timestamp = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in CreateCalendarEvent."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Request model for sending email. + /// + private record SendEmailRequest(string? To, string? Subject, string? Body); + + /// + /// Request model for creating a calendar event. + /// + private record CreateCalendarEventRequest( + string? CalendarId, + string? Subject, + string? Body, + string? StartTime, + string? EndTime, + string? TimeZone, + string? RequiredAttendees); +} diff --git a/DirectConnector/SharePointFunctions.cs b/DirectConnector/SharePointFunctions.cs new file mode 100644 index 0000000..fb59313 --- /dev/null +++ b/DirectConnector/SharePointFunctions.cs @@ -0,0 +1,434 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Connectors.Sdk; +using Azure.Connectors.Sdk.SharePointOnline; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using SharePointBlobMetadata = Azure.Connectors.Sdk.SharePointOnline.Models.BlobMetadata; + +namespace DirectConnector; + +/// +/// Azure Functions that use the generated from the Azure Connectors SDK. +/// +/// +/// Demonstrates list browsing, folder listing, file download/upload, +/// and CancellationToken propagation from the Functions host. +/// +public class SharePointFunctions +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly ILogger _logger; + private readonly SharePointOnlineClient _sharePointClient; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The DI-injected SharePoint client (disposed by the host). + public SharePointFunctions( + ILogger logger, + SharePointOnlineClient sharePointClient) + { + this._logger = logger; + this._sharePointClient = sharePointClient; + } + + /// + /// Gets all SharePoint lists and libraries for a site using the generated . + /// + /// The HTTP request containing the site address. + /// The cancellation token. + [Function("GetSharePointLists")] + public async Task GetSharePointListsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sharepoint/lists")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("GetSharePointLists: Using generated SharePointOnlineClient from SDK."); + + var siteAddress = request.Query["site"]; + if (string.IsNullOrEmpty(siteAddress)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Query parameter 'site' is required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + try + { + var tables = await this._sharePointClient + .GetAllTablesAsync(siteAddress, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + site = siteAddress, + count = tables?.Value?.Count ?? 0, + lists = tables?.Value?.Select(table => new + { + name = table.Name, + displayName = table.DisplayName + }) + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in GetSharePointLists."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Lists files in a SharePoint folder using the generated . + /// + /// + /// Exercises the model for folder browsing. + /// + /// The HTTP request containing site and optional folder identifier. + /// The cancellation token. + [Function("ListFolder")] + public async Task ListFolderAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sharepoint/files")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("ListFolder: Using generated SharePointOnlineClient from SDK."); + + var siteAddress = request.Query["site"]; + if (string.IsNullOrEmpty(siteAddress)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Query parameter 'site' is required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + try + { + var folderId = request.Query["folder"]; + + // NOTE: ListRootFolderAsync vs ListFolderAsync demonstrates + // two overloads with the same return type but different parameter sets. + var files = string.IsNullOrEmpty(folderId) + ? await this._sharePointClient + .ListRootFolderAsync(siteAddress, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false) + : await this._sharePointClient + .ListFolderAsync(siteAddress, folderId, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + site = siteAddress, + folder = string.IsNullOrEmpty(folderId) ? "(root)" : folderId, + count = files?.Count ?? 0, + files = (files ?? Enumerable.Empty()).Select(file => new + { + id = file.Id, + name = file.Name, + displayName = file.DisplayName, + path = file.Path, + size = file.Size, + mediaType = file.MediaType, + isFolder = file.IsFolder + }) + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in ListFolder."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Downloads file content from SharePoint as binary bytes. + /// + /// + /// Exercises the byte[] response path in . + /// The generated CallConnectorAsync detects byte[] as the response type and uses + /// ReadAsByteArrayAsync instead of JSON deserialization. + /// + /// The HTTP request containing site address and file path. + /// The cancellation token. + [Function("DownloadFile")] + public async Task DownloadFileAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sharepoint/download")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("DownloadFile: Using generated SharePointOnlineClient byte[] response path."); + + var siteAddress = request.Query["site"]; + var filePath = request.Query["path"]; + if (string.IsNullOrEmpty(siteAddress) || string.IsNullOrEmpty(filePath)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Query parameters 'site' and 'path' are required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + try + { + // NOTE: This exercises the byte[] return path in the generated client. + // CallConnectorAsync uses ReadAsByteArrayAsync instead of JSON deserialization. + var fileBytes = await this._sharePointClient + .GetFileContentByPathAsync(siteAddress, filePath, cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + // NOTE: Sanitize the filename to prevent response header injection. + // Path.GetFileName strips directory traversal, and we remove CR/LF and quotes + // that could corrupt the Content-Disposition header value. + var fileName = System.IO.Path.GetFileName(filePath) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Replace("\"", string.Empty); + + var response = request.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/octet-stream"); + response.Headers.Add("Content-Disposition", $"attachment; filename=\"{fileName}\""); + await response.Body + .WriteAsync(fileBytes, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + this._logger.LogInformation("Downloaded '{FileName}': '{ByteCount}' bytes.", fileName, fileBytes.Length); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in DownloadFile."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Uploads a file to a SharePoint document library. + /// + /// + /// Exercises the byte[] input path in . + /// Accepts a JSON body with base64-encoded content or plain text, and uploads it to + /// the specified SharePoint folder. + /// + /// The HTTP request containing upload details. + /// The cancellation token. + [Function("UploadFile")] + public async Task UploadFileAsync( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "sharepoint/upload")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("UploadFile: Using generated SharePointOnlineClient byte[] input path."); + + try + { + using var reader = new StreamReader(request.Body); + var body = await reader + .ReadToEndAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + UploadFileRequest? input; + try + { + input = JsonSerializer.Deserialize(body, SharePointFunctions.JsonOptions); + } + catch (JsonException ex) + { + this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); + + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) + .ConfigureAwait(continueOnCapturedContext: false); + + return badRequest; + } + + if (input == null || + string.IsNullOrEmpty(input.Site) || + string.IsNullOrEmpty(input.FolderPath) || + string.IsNullOrEmpty(input.FileName)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Fields 'site', 'folderPath', and 'fileName' are required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + // NOTE: Support both base64-encoded binary and plain text content. + byte[] fileBytes; + try + { + fileBytes = !string.IsNullOrEmpty(input.ContentBase64) + ? Convert.FromBase64String(input.ContentBase64) + : Encoding.UTF8.GetBytes(input.Content ?? string.Empty); + } + catch (FormatException ex) + { + this._logger.LogError(ex, "Invalid base64 content in 'contentBase64'."); + + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "The 'contentBase64' field must contain valid base64-encoded data." }) + .ConfigureAwait(continueOnCapturedContext: false); + + return badRequest; + } + + var metadata = await this._sharePointClient + .CreateFileAsync(input.Site, fileBytes, input.FolderPath, input.FileName, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + message = $"File '{input.FileName}' uploaded to '{input.FolderPath}'.", + fileId = metadata?.Id, + name = metadata?.Name, + path = metadata?.Path, + size = fileBytes.Length + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "SharePoint connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in UploadFile."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Request model for uploading a file to SharePoint. + /// + private record UploadFileRequest( + string? Site, + string? FolderPath, + string? FileName, + string? Content, + string? ContentBase64); +} diff --git a/DirectConnector/TeamsFunctions.cs b/DirectConnector/TeamsFunctions.cs new file mode 100644 index 0000000..f72a3ce --- /dev/null +++ b/DirectConnector/TeamsFunctions.cs @@ -0,0 +1,406 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System.Net; +using System.Text.Json; +using Azure.Connectors.Sdk; +using Azure.Connectors.Sdk.Teams; +using Azure.Connectors.Sdk.Teams.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace DirectConnector; + +/// +/// Azure Functions that use the generated from the Azure Connectors SDK. +/// +/// +/// Demonstrates listing teams, channels, channel messages with IAsyncEnumerable pagination, +/// posting messages, and CancellationToken propagation from the Functions host. +/// +public class TeamsFunctions +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + /// + /// Default poster identity for Teams messages posted via the connector. + /// + private const string TeamsDefaultPoster = "Flow bot"; + + /// + /// Default message location for Teams channel posts. + /// + private const string TeamsDefaultLocation = "Channel"; + + private readonly ILogger _logger; + private readonly TeamsClient _teamsClient; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The DI-injected Teams client (disposed by the host). + public TeamsFunctions( + ILogger logger, + TeamsClient teamsClient) + { + this._logger = logger; + this._teamsClient = teamsClient; + } + + /// + /// Lists all Teams the signed-in user is a member of using the generated . + /// + /// The HTTP request. + /// The cancellation token. + [Function("GetAllTeams")] + public async Task GetAllTeamsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "teams/teams")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("GetAllTeams: Using generated TeamsClient from SDK."); + + try + { + var result = await this._teamsClient + .GetAllTeamsAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + count = result?.TeamsList?.Count ?? 0, + teams = result?.TeamsList + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in GetAllTeams."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Lists all channels for a specific team using the generated . + /// + /// The HTTP request containing the team ID. + /// The cancellation token. + [Function("GetTeamChannels")] + public async Task GetTeamChannelsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "teams/channels")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("GetTeamChannels: Using generated TeamsClient from SDK."); + + var teamId = request.Query["teamId"]; + if (string.IsNullOrEmpty(teamId)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Query parameter 'teamId' is required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + try + { + var result = await this._teamsClient + .GetChannelsForGroupAsync(teamId, cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + teamId, + count = result?.ChannelList?.Count ?? 0, + channels = result?.ChannelList?.Select(channel => new + { + id = channel.ChannelID, + displayName = channel.DisplayName, + description = channel.DescriptionOfChannel, + membershipType = channel.TheTypeOfTheChannel + }) + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in GetTeamChannels."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Gets all messages from a Teams channel, automatically paginating across all pages. + /// + /// + /// Demonstrates pagination: GetMessagesFromChannelAsync + /// returns a ConnectorPageable that follows @odata.nextLink automatically. + /// The caller uses await foreach and never sees pagination details. + /// + /// The HTTP request with teamId and channelId query parameters. + /// The cancellation token. + [Function("GetChannelMessages")] + public async Task GetChannelMessagesAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "teams/messages")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("GetChannelMessages: Demonstrating IAsyncEnumerable pagination."); + + var teamId = request.Query["teamId"]; + var channelId = request.Query["channelId"]; + + if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(channelId)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Query parameters 'teamId' and 'channelId' are required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + try + { + // GetMessagesFromChannelAsync returns IAsyncEnumerable that automatically + // follows @odata.nextLink pagination across all pages. + var messages = new List(); + await foreach (var message in this._teamsClient + .GetMessagesFromChannelAsync(teamId, channelId) + .WithCancellation(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false)) + { + messages.Add(new + { + id = message.Id, + subject = message.Subject, + messageType = message.MessageType, + createdDateTime = message.CreationTimestamp, + from = message.From + }); + } + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + teamId, + channelId, + totalMessages = messages.Count, + messages + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in GetChannelMessages."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new { success = false, error = ex.Message }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Posts a message to a Teams channel using the generated . + /// + /// The HTTP request containing team, channel, and message details. + /// The cancellation token. + [Function("PostTeamsMessage")] + public async Task PostTeamsMessageAsync( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "teams/message")] HttpRequestData request, + CancellationToken cancellationToken) + { + this._logger.LogInformation("PostTeamsMessage: Using generated TeamsClient from SDK."); + + try + { + using var reader = new StreamReader(request.Body); + var body = await reader + .ReadToEndAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + PostTeamsMessageRequest? input; + try + { + input = JsonSerializer.Deserialize(body, TeamsFunctions.JsonOptions); + } + catch (JsonException ex) + { + this._logger.LogError(ex, "Invalid JSON in request body: '{Message}'.", ex.Message); + + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Request body must contain valid JSON." }) + .ConfigureAwait(continueOnCapturedContext: false); + + return badRequest; + } + + if (input == null || string.IsNullOrEmpty(input.TeamId) || + string.IsNullOrEmpty(input.ChannelId) || string.IsNullOrEmpty(input.Message)) + { + var badRequest = request.CreateResponse(HttpStatusCode.BadRequest); + await badRequest + .WriteAsJsonAsync(new { error = "Fields 'teamId', 'channelId', and 'message' are required." }) + .ConfigureAwait(continueOnCapturedContext: false); + return badRequest; + } + + // NOTE: PostMessageToConversationAsync uses DynamicPostMessageRequest (dynamic schema). + // The actual message body properties are determined at runtime by the connector's schema + // discovery endpoint. With [JsonExtensionData] on AdditionalProperties, arbitrary properties + // are now serialized correctly. Populate the dictionary with the expected message fields. + var messageRequest = new DynamicPostMessageRequest(); + messageRequest.AdditionalProperties["recipient"] = JsonSerializer.SerializeToElement( + new + { + groupId = input.TeamId, + channelId = input.ChannelId, + }); + messageRequest.AdditionalProperties["messageBody"] = JsonSerializer.SerializeToElement( + $"

{WebUtility.HtmlEncode(input.Message)}

"); + + var result = await this._teamsClient + .PostMessageToConversationAsync( + postAs: TeamsFunctions.TeamsDefaultPoster, + postIn: TeamsFunctions.TeamsDefaultLocation, + input: messageRequest, + cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var response = request.CreateResponse(HttpStatusCode.OK); + await response + .WriteAsJsonAsync(new + { + success = true, + message = "Message posted to Teams channel via generated TeamsClient from SDK.", + messageId = result?.MessageID, + messageLink = result?.MessageLink, + timestamp = DateTime.UtcNow + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return response; + } + catch (ConnectorException ex) + { + this._logger.LogError(ex, "Teams connector error: '{StatusCode}'.", ex.Status); + + var errorResponse = request.CreateResponse(HttpStatusCode.BadGateway); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message, + statusCode = ex.Status, + details = ex.ResponseBody + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + catch (Exception ex) when (!ex.IsFatal()) + { + this._logger.LogError(ex, "Error in PostTeamsMessage."); + + var errorResponse = request.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse + .WriteAsJsonAsync(new + { + success = false, + error = ex.Message + }) + .ConfigureAwait(continueOnCapturedContext: false); + + return errorResponse; + } + } + + /// + /// Request model for posting a Teams message. + /// + private record PostTeamsMessageRequest(string? TeamId, string? ChannelId, string? Message); +} diff --git a/README.md b/README.md index 1e02a39..4d2d997 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ Sample Azure Functions demonstrating the [Azure Connectors .NET SDK](https://git ## What's Inside -The `DirectConnector/` project is an Azure Functions (isolated worker) app with sample functions across 11 connectors. Newer connectors have dedicated Functions classes; the original three (Office 365, SharePoint, Teams) share `ConnectorFunctions.cs`: +The `DirectConnector/` project is an Azure Functions (isolated worker) app with sample functions across 11 connectors, each in its own Functions class: | File | Connector | Sample Operations | |------|-----------|-------------------| -| ConnectorFunctions.cs | Office 365 (Mail/Calendar) | Send email, get categories, export email, create calendar event, trigger callbacks | -| ConnectorFunctions.cs | SharePoint Online | List libraries, browse folders, download/upload files | -| ConnectorFunctions.cs | Microsoft Teams | List teams/channels, get messages, post messages | +| Office365Functions.cs | Office 365 (Mail/Calendar) | Send email, get categories, export email, create calendar event, trigger callbacks | +| SharePointFunctions.cs | SharePoint Online | List libraries, browse folders, download/upload files | +| TeamsFunctions.cs | Microsoft Teams | List teams/channels, get messages, post messages | | MsGraphFunctions.cs | MS Graph Groups & Users | List users, search groups, get group properties | | OneDriveFunctions.cs | OneDrive for Business | Browse folders, download/upload files, search, share links | | Office365UsersFunctions.cs | Office 365 Users | Get my profile, user lookup, manager/reports chain, search users |