microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
c92c1919ec3b5a0b25a067ad4ad46da598a2fdca

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

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