microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
next/core

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

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