// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Workflows.Declarative; /// /// Default implementation of built on . /// /// /// /// This handler supports per-request authentication via an optional httpClientProvider callback that /// returns a pre-configured for a given request (e.g. authenticated, custom handler). /// When the provider returns , or no provider is supplied, a shared internal /// is used. /// /// /// The handler applies the per-request using a linked /// so it does not mutate on shared instances. /// /// public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable { private readonly Func>? _httpClientProvider; private readonly Lazy _ownedHttpClient; /// /// Initializes a new instance of the class that uses an /// internally owned for all requests. The internal client is disposed /// when is called. /// public DefaultHttpRequestHandler() : this(httpClientProvider: null) { } /// /// Initializes a new instance of the class that uses the /// supplied for all requests. /// /// /// The to use for all requests. The caller retains ownership of this /// instance; it is not disposed by . /// /// is . public DefaultHttpRequestHandler(HttpClient httpClient) : this(CreateSingleClientProvider(httpClient)) { } /// /// Initializes a new instance of the class that selects /// an per request via a caller-supplied callback — for example, to route /// different URLs through differently authenticated clients. /// /// /// An optional callback invoked for each request. The callback receives the /// and should return a pre-configured (e.g. with authentication or a custom /// transport). Return to fall back to the handler's shared internal /// . /// /// /// /// Ownership: the caller is solely responsible for the lifetime of clients returned by this /// callback. will not dispose provider-returned /// clients; only the handler's internally owned fallback client is disposed by . /// /// /// Reuse: callers are expected to cache and reuse clients (for example, keyed by base URL or /// auth scope) across requests. Returning a newly allocated on every /// invocation will leak sockets and handler resources. /// /// public DefaultHttpRequestHandler(Func>? httpClientProvider) { this._httpClientProvider = httpClientProvider; this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); } private static Func> CreateSingleClientProvider(HttpClient httpClient) { if (httpClient is null) { throw new ArgumentNullException(nameof(httpClient)); } return (_, _) => Task.FromResult(httpClient); } /// public async Task SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default) { if (request is null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrWhiteSpace(request.Url)) { throw new ArgumentException("Request URL must be provided.", nameof(request)); } if (string.IsNullOrWhiteSpace(request.Method)) { throw new ArgumentException("Request method must be provided.", nameof(request)); } HttpClient? providedClient = null; if (this._httpClientProvider is not null) { providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false); } HttpClient client = providedClient ?? this._ownedHttpClient.Value; using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request); using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) : null; timeoutCts?.CancelAfter(request.Timeout!.Value); CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; using HttpResponseMessage httpResponse = await client .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) .ConfigureAwait(false); string? body = httpResponse.Content is null ? null #if NET : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); #else : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); #endif Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); AppendHeaders(headers, httpResponse.Headers); if (httpResponse.Content is not null) { AppendHeaders(headers, httpResponse.Content.Headers); } return new HttpRequestResult { StatusCode = (int)httpResponse.StatusCode, IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, Body = body, Headers = headers, }; } /// public ValueTask DisposeAsync() { if (this._ownedHttpClient.IsValueCreated) { this._ownedHttpClient.Value.Dispose(); } return default; } private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request) { HttpMethod method = ResolveMethod(request.Method); string requestUri = ResolveRequestUri(request); HttpRequestMessage httpRequest = new(method, requestUri); if (request.Body is not null) { string contentType = string.IsNullOrWhiteSpace(request.BodyContentType) ? "text/plain" : request.BodyContentType!; httpRequest.Content = new StringContent(request.Body, Encoding.UTF8); // Replace the default content-type header (including charset) with the declared type. httpRequest.Content.Headers.Remove("Content-Type"); httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); } if (request.Headers is not null) { foreach (KeyValuePair header in request.Headers) { if (string.IsNullOrEmpty(header.Key)) { continue; } // Content-* headers belong on HttpContent; all others belong on the request. if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null) { httpRequest.Content.Headers.Remove(header.Key); httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); continue; } if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) { httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); } } } return httpRequest; } private static HttpMethod ResolveMethod(string method) { string normalized = method.Trim().ToUpperInvariant(); return normalized switch { "GET" => HttpMethod.Get, "POST" => HttpMethod.Post, "PUT" => HttpMethod.Put, "DELETE" => HttpMethod.Delete, #if NET "PATCH" => HttpMethod.Patch, #else "PATCH" => new HttpMethod("PATCH"), #endif _ => new HttpMethod(normalized), }; } private static string ResolveRequestUri(HttpRequestInfo request) { string baseUrl = request.Url; if (request.QueryParameters is null || request.QueryParameters.Count == 0) { return baseUrl; } StringBuilder queryBuilder = new(); foreach (KeyValuePair parameter in request.QueryParameters) { if (string.IsNullOrEmpty(parameter.Key)) { continue; } if (queryBuilder.Length > 0) { queryBuilder.Append('&'); } queryBuilder.Append(Uri.EscapeDataString(parameter.Key)) .Append('=') .Append(Uri.EscapeDataString(parameter.Value ?? string.Empty)); } if (queryBuilder.Length == 0) { return baseUrl; } char separator = baseUrl.Contains('?') ? '&' : '?'; return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString()); } private static void AppendHeaders( Dictionary> target, System.Net.Http.Headers.HttpHeaders source) { foreach (KeyValuePair> header in source) { string[] values = header.Value.ToArray(); if (target.TryGetValue(header.Key, out IReadOnlyList? existing)) { List combined = new(existing); combined.AddRange(values); target[header.Key] = combined; } else { target[header.Key] = values; } } } }