microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/sub-pr-338

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

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