microsoft/teams.net

Public

mirrored from https://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
023c8ce0f77c17ee0f129fa156982c2beafcd98e

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs

251lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Globalization;
5using System.Net;
6using System.Net.Mime;
7using System.Text;
8using System.Text.Json;
9using Microsoft.AspNetCore.WebUtilities;
10using Microsoft.Extensions.Logging;
11using Microsoft.Teams.Bot.Core;
12using Microsoft.Teams.Bot.Core.Hosting;
13
14namespace Microsoft.Teams.Bot.Core.Http;
15/// <summary>
16/// Provides shared HTTP request functionality for bot clients.
17/// </summary>
18/// <param name="httpClient">The HTTP client instance used to send requests.</param>
19/// <param name="logger">The logger instance used for logging. Optional.</param>
20public class BotHttpClient(HttpClient httpClient, ILogger? logger = null)
21{
22 private static readonly JsonSerializerOptions DefaultJsonOptions = new()
23 {
24 PropertyNamingPolicy = JsonNamingPolicy.CamelCase
25 };
26
27 /// <summary>
28 /// Sends an HTTP request and deserializes the response.
29 /// </summary>
30 /// <typeparam name="T">The type to deserialize the response to.</typeparam>
31 /// <param name="method">The HTTP method to use.</param>
32 /// <param name="url">The full URL for the request.</param>
33 /// <param name="body">The request body content. Optional.</param>
34 /// <param name="options">The request options. Optional.</param>
35 /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
36 /// <returns>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).</returns>
37 /// <exception cref="HttpRequestException">Thrown if the request fails and the failure is not handled by options.</exception>
38 public async Task<T?> SendAsync<T>(
39 HttpMethod method,
40 string url,
41 string? body = null,
42 BotRequestOptions? options = null,
43 CancellationToken cancellationToken = default)
44 {
45 options ??= new BotRequestOptions();
46
47 using HttpRequestMessage request = CreateRequest(method, url, body, options);
48
49 logger.LogTraceGuarded("Sending HTTP {Method} request to {Url} with body: {Body}", method, url, body);
50
51 using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
52
53 return await HandleResponseAsync<T>(response, method, url, options, cancellationToken).ConfigureAwait(false);
54 }
55
56 /// <summary>
57 /// Sends an HTTP request with query parameters and deserializes the response.
58 /// </summary>
59 /// <typeparam name="T">The type to deserialize the response to.</typeparam>
60 /// <param name="method">The HTTP method to use.</param>
61 /// <param name="baseUrl">The base URL for the request.</param>
62 /// <param name="endpoint">The endpoint path to append to the base URL.</param>
63 /// <param name="queryParams">The query parameters to include in the request. Optional.</param>
64 /// <param name="body">The request body content. Optional.</param>
65 /// <param name="options">The request options. Optional.</param>
66 /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
67 /// <returns>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).</returns>
68 /// <exception cref="HttpRequestException">Thrown if the request fails and the failure is not handled by options.</exception>
69 public async Task<T?> SendAsync<T>(
70 HttpMethod method,
71 string baseUrl,
72 string endpoint,
73 Dictionary<string, string?>? queryParams = null,
74 string? body = null,
75 BotRequestOptions? options = null,
76 CancellationToken cancellationToken = default)
77 {
78 ArgumentNullException.ThrowIfNull(baseUrl);
79 ArgumentNullException.ThrowIfNull(endpoint);
80
81 string fullPath = $"{baseUrl.TrimEnd('/')}/{endpoint.TrimStart('/')}";
82 string url = queryParams?.Count > 0
83 ? QueryHelpers.AddQueryString(fullPath, queryParams)
84 : fullPath;
85
86 return await SendAsync<T>(method, url, body, options, cancellationToken).ConfigureAwait(false);
87 }
88
89 /// <summary>
90 /// Sends an HTTP request without expecting a response body.
91 /// </summary>
92 /// <param name="method">The HTTP method to use.</param>
93 /// <param name="url">The full URL for the request.</param>
94 /// <param name="body">The request body content. Optional.</param>
95 /// <param name="options">The request options. Optional.</param>
96 /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
97 /// <returns>A task that represents the asynchronous operation.</returns>
98 /// <exception cref="HttpRequestException">Thrown if the request fails.</exception>
99 public async Task SendAsync(
100 HttpMethod method,
101 string url,
102 string? body = null,
103 BotRequestOptions? options = null,
104 CancellationToken cancellationToken = default)
105 {
106 await SendAsync<object>(method, url, body, options, cancellationToken).ConfigureAwait(false);
107 }
108
109 /// <summary>
110 /// Sends an HTTP request with query parameters without expecting a response body.
111 /// </summary>
112 /// <param name="method">The HTTP method to use.</param>
113 /// <param name="baseUrl">The base URL for the request.</param>
114 /// <param name="endpoint">The endpoint path to append to the base URL.</param>
115 /// <param name="queryParams">The query parameters to include in the request. Optional.</param>
116 /// <param name="body">The request body content. Optional.</param>
117 /// <param name="options">The request options. Optional.</param>
118 /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
119 /// <returns>A task that represents the asynchronous operation.</returns>
120 /// <exception cref="HttpRequestException">Thrown if the request fails.</exception>
121 public async Task SendAsync(
122 HttpMethod method,
123 string baseUrl,
124 string endpoint,
125 Dictionary<string, string?>? queryParams = null,
126 string? body = null,
127 BotRequestOptions? options = null,
128 CancellationToken cancellationToken = default)
129 {
130 await SendAsync<object>(method, baseUrl, endpoint, queryParams, body, options, cancellationToken).ConfigureAwait(false);
131 }
132
133 private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string? body, BotRequestOptions options)
134 {
135 HttpRequestMessage request = new(method, url);
136
137 if (body is not null)
138 {
139 request.Content = new StringContent(body, Encoding.UTF8, MediaTypeNames.Application.Json);
140 }
141
142 if (options.AgenticIdentity is not null)
143 {
144 request.Options.Set(BotAuthenticationHandler.AgenticIdentityKey, options.AgenticIdentity);
145 }
146
147 if (options.DefaultHeaders is not null)
148 {
149 foreach (KeyValuePair<string, string> header in options.DefaultHeaders)
150 {
151 request.Headers.TryAddWithoutValidation(header.Key, header.Value);
152 }
153 }
154
155 if (options.CustomHeaders is not null)
156 {
157 foreach (KeyValuePair<string, string> header in options.CustomHeaders)
158 {
159 request.Headers.Remove(header.Key);
160 request.Headers.TryAddWithoutValidation(header.Key, header.Value);
161 }
162 }
163
164 return request;
165 }
166
167 private async Task<T?> HandleResponseAsync<T>(
168 HttpResponseMessage response,
169 HttpMethod method,
170 string url,
171 BotRequestOptions options,
172 CancellationToken cancellationToken)
173 {
174 if (response.IsSuccessStatusCode)
175 {
176 return await DeserializeResponseAsync<T>(response, options, cancellationToken).ConfigureAwait(false);
177 }
178
179 if (response.StatusCode == HttpStatusCode.NotFound && options.ReturnNullOnNotFound)
180 {
181 logger?.LogWarning("Resource not found: {Url}", url);
182 return default;
183 }
184
185 string errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
186 string responseHeaders = FormatResponseHeaders(response);
187
188 logger?.LogWarning(
189 "HTTP request error {Method} {Url}\nStatus Code: {StatusCode}\nResponse Headers: {ResponseHeaders}\nResponse Body: {ResponseBody}",
190 method, url, response.StatusCode, responseHeaders, errorContent);
191
192 string operationDescription = options.OperationDescription ?? "request";
193 throw new HttpRequestException(
194 $"Error {operationDescription} {response.StatusCode}. {errorContent}",
195 inner: null,
196 statusCode: response.StatusCode);
197 }
198
199 private static async Task<T?> DeserializeResponseAsync<T>(
200 HttpResponseMessage response,
201 BotRequestOptions options,
202 CancellationToken cancellationToken)
203 {
204 string responseString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
205
206 if (string.IsNullOrWhiteSpace(responseString) || responseString.Length <= 2)
207 {
208 return default;
209 }
210
211 if (typeof(T) == typeof(string))
212 {
213 try
214 {
215 T? result = JsonSerializer.Deserialize<T>(responseString, DefaultJsonOptions);
216 return result ?? (T)(object)responseString;
217 }
218 catch (JsonException)
219 {
220 return (T)(object)responseString;
221 }
222 }
223
224 T? deserializedResult = JsonSerializer.Deserialize<T>(responseString, DefaultJsonOptions);
225
226 if (deserializedResult is null)
227 {
228 string operationDescription = options.OperationDescription ?? "request";
229 throw new InvalidOperationException($"Failed to deserialize response for {operationDescription}");
230 }
231
232 return deserializedResult;
233 }
234
235 private static string FormatResponseHeaders(HttpResponseMessage response)
236 {
237 StringBuilder sb = new();
238
239 foreach (KeyValuePair<string, IEnumerable<string>> header in response.Headers)
240 {
241 sb.AppendLine(CultureInfo.InvariantCulture, $"Response header: {header.Key} : {string.Join(",", header.Value)}");
242 }
243
244 foreach (KeyValuePair<string, IEnumerable<string>> header in response.TrailingHeaders)
245 {
246 sb.AppendLine(CultureInfo.InvariantCulture, $"Response trailing header: {header.Key} : {string.Join(",", header.Value)}");
247 }
248
249 return sb.ToString();
250 }
251}