Skip to content

Commit 40e90c9

Browse files
authored
.NET: Add HttpRequestAction support to declarative workflows (#5474)
* Add declarative HttpRequestAction support to workflows * Clean up response body for diagnostics and fix tests. * Fix merge with main. * Remove redundant fallback for request content headers.
1 parent 1e1eda6 commit 40e90c9

12 files changed

Lines changed: 2150 additions & 6 deletions

File tree

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
2626
/// </summary>
2727
public IMcpToolHandler? McpToolHandler { get; init; }
2828

29+
/// <summary>
30+
/// Gets or sets the HTTP request handler for executing <c>HttpRequestAction</c> actions within workflows.
31+
/// If not set, HTTP request actions will fail with an appropriate error message.
32+
/// </summary>
33+
public IHttpRequestHandler? HttpRequestHandler { get; init; }
34+
2935
/// <summary>
3036
/// Defines the configuration settings for the workflow.
3137
/// </summary>
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Net.Http;
7+
using System.Text;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
namespace Microsoft.Agents.AI.Workflows.Declarative;
12+
13+
/// <summary>
14+
/// Default implementation of <see cref="IHttpRequestHandler"/> built on <see cref="HttpClient"/>.
15+
/// </summary>
16+
/// <remarks>
17+
/// <para>
18+
/// This handler supports per-request authentication via an optional <c>httpClientProvider</c> callback that
19+
/// returns a pre-configured <see cref="HttpClient"/> for a given request (e.g. authenticated, custom handler).
20+
/// When the provider returns <see langword="null"/>, or no provider is supplied, a shared internal <see cref="HttpClient"/>
21+
/// is used.
22+
/// </para>
23+
/// <para>
24+
/// The handler applies the per-request <see cref="HttpRequestInfo.Timeout"/> using a linked <see cref="CancellationTokenSource"/>
25+
/// so it does not mutate <see cref="HttpClient.Timeout"/> on shared instances.
26+
/// </para>
27+
/// </remarks>
28+
public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
29+
{
30+
private readonly Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
31+
private readonly Lazy<HttpClient> _ownedHttpClient;
32+
33+
/// <summary>
34+
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses an
35+
/// internally owned <see cref="HttpClient"/> for all requests. The internal client is disposed
36+
/// when <see cref="DisposeAsync"/> is called.
37+
/// </summary>
38+
public DefaultHttpRequestHandler()
39+
: this(httpClientProvider: null)
40+
{
41+
}
42+
43+
/// <summary>
44+
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses the
45+
/// supplied <see cref="HttpClient"/> for all requests.
46+
/// </summary>
47+
/// <param name="httpClient">
48+
/// The <see cref="HttpClient"/> to use for all requests. The caller retains ownership of this
49+
/// instance; it is not disposed by <see cref="DisposeAsync"/>.
50+
/// </param>
51+
/// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is <see langword="null"/>.</exception>
52+
public DefaultHttpRequestHandler(HttpClient httpClient)
53+
: this(CreateSingleClientProvider(httpClient))
54+
{
55+
}
56+
57+
/// <summary>
58+
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that selects
59+
/// an <see cref="HttpClient"/> per request via a caller-supplied callback — for example, to route
60+
/// different URLs through differently authenticated clients.
61+
/// </summary>
62+
/// <param name="httpClientProvider">
63+
/// An optional callback invoked for each request. The callback receives the <see cref="HttpRequestInfo"/>
64+
/// and should return a pre-configured <see cref="HttpClient"/> (e.g. with authentication or a custom
65+
/// transport). Return <see langword="null"/> to fall back to the handler's shared internal
66+
/// <see cref="HttpClient"/>.
67+
/// </param>
68+
/// <remarks>
69+
/// <para>
70+
/// <b>Ownership</b>: the caller is solely responsible for the lifetime of clients returned by this
71+
/// callback. <see cref="DefaultHttpRequestHandler"/> will <b>not</b> dispose provider-returned
72+
/// clients; only the handler's internally owned fallback client is disposed by <see cref="DisposeAsync"/>.
73+
/// </para>
74+
/// <para>
75+
/// <b>Reuse</b>: callers are expected to cache and reuse clients (for example, keyed by base URL or
76+
/// auth scope) across requests. Returning a newly allocated <see cref="HttpClient"/> on every
77+
/// invocation will leak sockets and handler resources.
78+
/// </para>
79+
/// </remarks>
80+
public DefaultHttpRequestHandler(Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? httpClientProvider)
81+
{
82+
this._httpClientProvider = httpClientProvider;
83+
this._ownedHttpClient = new Lazy<HttpClient>(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
84+
}
85+
86+
private static Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>> CreateSingleClientProvider(HttpClient httpClient)
87+
{
88+
if (httpClient is null)
89+
{
90+
throw new ArgumentNullException(nameof(httpClient));
91+
}
92+
93+
return (_, _) => Task.FromResult<HttpClient?>(httpClient);
94+
}
95+
96+
/// <inheritdoc/>
97+
public async Task<HttpRequestResult> SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default)
98+
{
99+
if (request is null)
100+
{
101+
throw new ArgumentNullException(nameof(request));
102+
}
103+
104+
if (string.IsNullOrWhiteSpace(request.Url))
105+
{
106+
throw new ArgumentException("Request URL must be provided.", nameof(request));
107+
}
108+
109+
if (string.IsNullOrWhiteSpace(request.Method))
110+
{
111+
throw new ArgumentException("Request method must be provided.", nameof(request));
112+
}
113+
114+
HttpClient? providedClient = null;
115+
if (this._httpClientProvider is not null)
116+
{
117+
providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false);
118+
}
119+
120+
HttpClient client = providedClient ?? this._ownedHttpClient.Value;
121+
122+
using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request);
123+
124+
using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero
125+
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
126+
: null;
127+
128+
timeoutCts?.CancelAfter(request.Timeout!.Value);
129+
130+
CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken;
131+
132+
using HttpResponseMessage httpResponse = await client
133+
.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken)
134+
.ConfigureAwait(false);
135+
136+
string? body = httpResponse.Content is null
137+
? null
138+
#if NET
139+
: await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
140+
#else
141+
: await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
142+
#endif
143+
144+
Dictionary<string, IReadOnlyList<string>> headers = new(StringComparer.OrdinalIgnoreCase);
145+
AppendHeaders(headers, httpResponse.Headers);
146+
if (httpResponse.Content is not null)
147+
{
148+
AppendHeaders(headers, httpResponse.Content.Headers);
149+
}
150+
151+
return new HttpRequestResult
152+
{
153+
StatusCode = (int)httpResponse.StatusCode,
154+
IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
155+
Body = body,
156+
Headers = headers,
157+
};
158+
}
159+
160+
/// <inheritdoc/>
161+
public ValueTask DisposeAsync()
162+
{
163+
if (this._ownedHttpClient.IsValueCreated)
164+
{
165+
this._ownedHttpClient.Value.Dispose();
166+
}
167+
168+
return default;
169+
}
170+
171+
private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request)
172+
{
173+
HttpMethod method = ResolveMethod(request.Method);
174+
string requestUri = ResolveRequestUri(request);
175+
HttpRequestMessage httpRequest = new(method, requestUri);
176+
177+
if (request.Body is not null)
178+
{
179+
string contentType = string.IsNullOrWhiteSpace(request.BodyContentType)
180+
? "text/plain"
181+
: request.BodyContentType!;
182+
183+
httpRequest.Content = new StringContent(request.Body, Encoding.UTF8);
184+
// Replace the default content-type header (including charset) with the declared type.
185+
httpRequest.Content.Headers.Remove("Content-Type");
186+
httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
187+
}
188+
189+
if (request.Headers is not null)
190+
{
191+
foreach (KeyValuePair<string, string> header in request.Headers)
192+
{
193+
if (string.IsNullOrEmpty(header.Key))
194+
{
195+
continue;
196+
}
197+
198+
// Content-* headers belong on HttpContent; all others belong on the request.
199+
if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null)
200+
{
201+
httpRequest.Content.Headers.Remove(header.Key);
202+
httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
203+
continue;
204+
}
205+
206+
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
207+
{
208+
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
209+
}
210+
}
211+
}
212+
213+
return httpRequest;
214+
}
215+
216+
private static HttpMethod ResolveMethod(string method)
217+
{
218+
string normalized = method.Trim().ToUpperInvariant();
219+
return normalized switch
220+
{
221+
"GET" => HttpMethod.Get,
222+
"POST" => HttpMethod.Post,
223+
"PUT" => HttpMethod.Put,
224+
"DELETE" => HttpMethod.Delete,
225+
#if NET
226+
"PATCH" => HttpMethod.Patch,
227+
#else
228+
"PATCH" => new HttpMethod("PATCH"),
229+
#endif
230+
_ => new HttpMethod(normalized),
231+
};
232+
}
233+
234+
private static string ResolveRequestUri(HttpRequestInfo request)
235+
{
236+
string baseUrl = request.Url;
237+
if (request.QueryParameters is null || request.QueryParameters.Count == 0)
238+
{
239+
return baseUrl;
240+
}
241+
242+
StringBuilder queryBuilder = new();
243+
foreach (KeyValuePair<string, string> parameter in request.QueryParameters)
244+
{
245+
if (string.IsNullOrEmpty(parameter.Key))
246+
{
247+
continue;
248+
}
249+
250+
if (queryBuilder.Length > 0)
251+
{
252+
queryBuilder.Append('&');
253+
}
254+
255+
queryBuilder.Append(Uri.EscapeDataString(parameter.Key))
256+
.Append('=')
257+
.Append(Uri.EscapeDataString(parameter.Value ?? string.Empty));
258+
}
259+
260+
if (queryBuilder.Length == 0)
261+
{
262+
return baseUrl;
263+
}
264+
265+
char separator = baseUrl.Contains('?') ? '&' : '?';
266+
return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString());
267+
}
268+
269+
private static void AppendHeaders(
270+
Dictionary<string, IReadOnlyList<string>> target,
271+
System.Net.Http.Headers.HttpHeaders source)
272+
{
273+
foreach (KeyValuePair<string, IEnumerable<string>> header in source)
274+
{
275+
string[] values = header.Value.ToArray();
276+
277+
if (target.TryGetValue(header.Key, out IReadOnlyList<string>? existing))
278+
{
279+
List<string> combined = new(existing);
280+
combined.AddRange(values);
281+
target[header.Key] = combined;
282+
}
283+
else
284+
{
285+
target[header.Key] = values;
286+
}
287+
}
288+
}
289+
}

0 commit comments

Comments
 (0)