microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v2.0.8

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

258lines · modecode

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