// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Diagnostics; using System.Globalization; using System.Net; using System.Net.Mime; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Teams.Core.Hosting; namespace Microsoft.Teams.Core.Http; /// /// Provides shared HTTP request functionality for bot clients. /// /// The HTTP client instance used to send requests. /// The logger instance used for logging. Optional. public class BotHttpClient(HttpClient httpClient, ILogger? logger = null) { private const string UserAgent = "teams.net/" + ThisAssembly.NuGetPackageVersion; private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Sends an HTTP request and deserializes the response. /// /// The type to deserialize the response to. /// The HTTP method to use. /// The full URL for the request. /// The request body content. Optional. /// The request options. Optional. /// A cancellation token that can be used to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the response is empty or 404 (when ReturnNullOnNotFound is true). /// Thrown if the request fails and the failure is not handled by options. public async Task SendAsync( HttpMethod method, string url, string? body = null, BotRequestOptions? options = null, CancellationToken cancellationToken = default) { options ??= new BotRequestOptions(); using HttpRequestMessage request = CreateRequest(method, url, body, options); logger?.HttpRequestSending(method, url, body); using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); logger?.HttpResponseReceived(method, url, (int)response.StatusCode); return await HandleResponseAsync(response, method, url, options, cancellationToken).ConfigureAwait(false); } /// /// Sends an HTTP request with query parameters and deserializes the response. /// /// The type to deserialize the response to. /// The HTTP method to use. /// The base URL for the request. /// The endpoint path to append to the base URL. /// The query parameters to include in the request. Optional. /// The request body content. Optional. /// The request options. Optional. /// A cancellation token that can be used to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the response is empty or 404 (when ReturnNullOnNotFound is true). /// Thrown if the request fails and the failure is not handled by options. public async Task SendAsync( HttpMethod method, string baseUrl, string endpoint, Dictionary? queryParams = null, string? body = null, BotRequestOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(baseUrl); ArgumentNullException.ThrowIfNull(endpoint); string fullPath = $"{baseUrl.TrimEnd('/')}/{endpoint.TrimStart('/')}"; string url = queryParams?.Count > 0 ? QueryHelpers.AddQueryString(fullPath, queryParams) : fullPath; return await SendAsync(method, url, body, options, cancellationToken).ConfigureAwait(false); } /// /// Sends an HTTP request without expecting a response body. /// /// The HTTP method to use. /// The full URL for the request. /// The request body content. Optional. /// The request options. Optional. /// A cancellation token that can be used to cancel the operation. /// A task that represents the asynchronous operation. /// Thrown if the request fails. public async Task SendAsync( HttpMethod method, string url, string? body = null, BotRequestOptions? options = null, CancellationToken cancellationToken = default) { await SendAsync(method, url, body, options, cancellationToken).ConfigureAwait(false); } /// /// Sends an HTTP request with query parameters without expecting a response body. /// /// The HTTP method to use. /// The base URL for the request. /// The endpoint path to append to the base URL. /// The query parameters to include in the request. Optional. /// The request body content. Optional. /// The request options. Optional. /// A cancellation token that can be used to cancel the operation. /// A task that represents the asynchronous operation. /// Thrown if the request fails. public async Task SendAsync( HttpMethod method, string baseUrl, string endpoint, Dictionary? queryParams = null, string? body = null, BotRequestOptions? options = null, CancellationToken cancellationToken = default) { await SendAsync(method, baseUrl, endpoint, queryParams, body, options, cancellationToken).ConfigureAwait(false); } private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string? body, BotRequestOptions options) { HttpRequestMessage request = new(method, url); request.Headers.UserAgent.ParseAdd(UserAgent); if (body is not null) { request.Content = new StringContent(body, Encoding.UTF8, MediaTypeNames.Application.Json); } if (options.AgenticIdentity is not null) { request.Options.Set(BotAuthenticationHandler.AgenticIdentityKey, options.AgenticIdentity); } if (options.CustomHeaders is not null) { foreach (KeyValuePair header in options.CustomHeaders) { request.Headers.Remove(header.Key); request.Headers.TryAddWithoutValidation(header.Key, header.Value); } } return request; } private async Task HandleResponseAsync( HttpResponseMessage response, HttpMethod method, string url, BotRequestOptions options, CancellationToken cancellationToken) { if (response.IsSuccessStatusCode) { return await DeserializeResponseAsync(response, options, cancellationToken).ConfigureAwait(false); } if (response.StatusCode == HttpStatusCode.NotFound && options.ReturnNullOnNotFound) { logger?.ResourceNotFound(url); return default; } string errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); string responseHeaders = FormatResponseHeaders(response); logger?.HttpRequestError(method, url, response.StatusCode, responseHeaders, errorContent); string operationDescription = options.OperationDescription ?? "request"; throw new HttpRequestException( $"Error {operationDescription} {response.StatusCode}. {errorContent}", inner: null, statusCode: response.StatusCode); } private static async Task DeserializeResponseAsync( HttpResponseMessage response, BotRequestOptions options, CancellationToken cancellationToken) { string responseString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(responseString) || responseString.Length <= 2) { return default; } if (typeof(T) == typeof(string)) { // When T is string, try to deserialize as a JSON string first (unwraps quotes). // Fall back to the raw response if it's not valid JSON. // The (T)(object) cast is safe because we've verified T == string above. Debug.Assert(typeof(T) == typeof(string), "Cast below assumes T is string."); try { T? result = JsonSerializer.Deserialize(responseString, DefaultJsonOptions); return result ?? (T)(object)responseString; } catch (JsonException) { return (T)(object)responseString; } } T? deserializedResult = JsonSerializer.Deserialize(responseString, DefaultJsonOptions); if (deserializedResult is null) { string operationDescription = options.OperationDescription ?? "request"; throw new InvalidOperationException($"Failed to deserialize response for {operationDescription}"); } return deserializedResult; } private static string FormatResponseHeaders(HttpResponseMessage response) { StringBuilder sb = new(); foreach (KeyValuePair> header in response.Headers) { sb.AppendLine(CultureInfo.InvariantCulture, $"Response header: {header.Key} : {string.Join(",", header.Value)}"); } foreach (KeyValuePair> header in response.TrailingHeaders) { sb.AppendLine(CultureInfo.InvariantCulture, $"Response trailing header: {header.Key} : {string.Join(",", header.Value)}"); } return sb.ToString(); } }