From 7984c0c8ac6376ac17e04ecc82ea18c4a3505440 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 2 Apr 2026 16:18:28 -0700 Subject: [PATCH 1/2] feat: Add built-in HTTP activity for standalone SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new extension package (Microsoft.DurableTask.Extensions.Http) that provides a built-in HTTP activity implementation, enabling CallHttpAsync to work in standalone mode without the Azure Functions host. New extension project: src/Extensions/Http/ - DurableHttpRequest/Response — wire-compatible with Azure Functions extension - BuiltInHttpActivity — executes HTTP requests with retry support - UseHttpActivities() — one-line registration on IDurableTaskWorkerBuilder - CallHttpAsync extension methods with 202 async polling - JSON converters for HttpMethod, headers, and TokenSource Closes #696 Co-Authored-By: Claude Opus 4.6 --- Directory.Packages.props | 1 + Microsoft.DurableTask.sln | 43 +++- src/Extensions/Http/BuiltInHttpActivity.cs | 196 +++++++++++++++ .../Http/Converters/HttpHeadersConverter.cs | 67 +++++ .../Http/Converters/HttpMethodConverter.cs | 27 +++ .../Http/Converters/TokenSourceConverter.cs | 81 +++++++ src/Extensions/Http/DurableHttpRequest.cs | 89 +++++++ src/Extensions/Http/DurableHttpResponse.cs | 47 ++++ .../Http/DurableTaskBuilderHttpExtensions.cs | 66 +++++ src/Extensions/Http/Http.csproj | 31 +++ src/Extensions/Http/HttpRetryOptions.cs | 60 +++++ .../TaskOrchestrationContextHttpExtensions.cs | 155 ++++++++++++ src/Extensions/Http/TokenSource.cs | 108 +++++++++ .../Http.Tests/BuiltInHttpActivityTests.cs | 228 ++++++++++++++++++ test/Extensions/Http.Tests/Http.Tests.csproj | 11 + .../Http.Tests/RegistrationTests.cs | 47 ++++ .../Http.Tests/SerializationTests.cs | 155 ++++++++++++ 17 files changed, 1409 insertions(+), 3 deletions(-) create mode 100644 src/Extensions/Http/BuiltInHttpActivity.cs create mode 100644 src/Extensions/Http/Converters/HttpHeadersConverter.cs create mode 100644 src/Extensions/Http/Converters/HttpMethodConverter.cs create mode 100644 src/Extensions/Http/Converters/TokenSourceConverter.cs create mode 100644 src/Extensions/Http/DurableHttpRequest.cs create mode 100644 src/Extensions/Http/DurableHttpResponse.cs create mode 100644 src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs create mode 100644 src/Extensions/Http/Http.csproj create mode 100644 src/Extensions/Http/HttpRetryOptions.cs create mode 100644 src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs create mode 100644 src/Extensions/Http/TokenSource.cs create mode 100644 test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs create mode 100644 test/Extensions/Http.Tests/Http.Tests.csproj create mode 100644 test/Extensions/Http.Tests/RegistrationTests.cs create mode 100644 test/Extensions/Http.Tests/SerializationTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d682b6c7..86710f30 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0b8ef935..d51f6ae4 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -115,6 +115,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NamespaceGenerationSample", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySample", "samples\ReplaySafeLoggerFactorySample\ReplaySafeLoggerFactorySample.csproj", "{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{21303FBF-2A2B-17C2-D2DF-3E924022E940}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Http", "src\Extensions\Http\Http.csproj", "{B4B672AC-7380-4E8F-B98D-22E28A1C0986}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{D4D9077D-1CEC-0E01-C5EE-AFAD11489446}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{00205C88-F000-28F2-A910-C6FA00E065EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Http.Tests", "test\Extensions\Http.Tests\Http.Tests.csproj", "{8287AE15-C11B-4A8B-B79C-A98D07566B43}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -701,7 +711,30 @@ Global {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x64.Build.0 = Release|Any CPU {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.ActiveCfg = Release|Any CPU {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.Build.0 = Release|Any CPU - + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x64.Build.0 = Debug|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x86.Build.0 = Debug|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|Any CPU.Build.0 = Release|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x64.ActiveCfg = Release|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x64.Build.0 = Release|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x86.ActiveCfg = Release|Any CPU + {B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x86.Build.0 = Release|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x64.ActiveCfg = Debug|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x64.Build.0 = Debug|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x86.ActiveCfg = Debug|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x86.Build.0 = Debug|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|Any CPU.Build.0 = Release|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x64.ActiveCfg = Release|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x64.Build.0 = Release|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x86.ActiveCfg = Release|Any CPU + {8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -759,7 +792,11 @@ Global {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - + {21303FBF-2A2B-17C2-D2DF-3E924022E940} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} + {B4B672AC-7380-4E8F-B98D-22E28A1C0986} = {21303FBF-2A2B-17C2-D2DF-3E924022E940} + {D4D9077D-1CEC-0E01-C5EE-AFAD11489446} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {00205C88-F000-28F2-A910-C6FA00E065EE} = {E5637F81-2FB9-4CD7-900D-455363B142A7} + {8287AE15-C11B-4A8B-B79C-A98D07566B43} = {00205C88-F000-28F2-A910-C6FA00E065EE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/src/Extensions/Http/BuiltInHttpActivity.cs b/src/Extensions/Http/BuiltInHttpActivity.cs new file mode 100644 index 00000000..2a8175c2 --- /dev/null +++ b/src/Extensions/Http/BuiltInHttpActivity.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Http; + +/// +/// Built-in activity that executes HTTP requests for the standalone Durable Task SDK. +/// This enables CallHttpAsync to work without the Azure Functions host. +/// +internal sealed class BuiltInHttpActivity : TaskActivity +{ + readonly HttpClient httpClient; + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client to use for requests. + /// The logger. + public BuiltInHttpActivity(HttpClient httpClient, ILogger logger) + { + this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public override async Task RunAsync( + TaskActivityContext context, DurableHttpRequest request) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.TokenSource != null) + { + throw new NotSupportedException( + "TokenSource-based authentication is not supported in standalone mode. " + + "Pass authentication tokens directly via the request Headers dictionary instead."); + } + + this.logger.LogInformation( + "Executing built-in HTTP activity: {Method} {Uri}", + request.Method, + request.Uri); + + HttpResponseMessage response = await this.ExecuteWithRetryAsync(request); + + string? body = response.Content != null + ? await response.Content.ReadAsStringAsync() + : null; + + IDictionary? responseHeaders = MapResponseHeaders(response); + + this.logger.LogInformation( + "Built-in HTTP activity completed: {Method} {Uri} → {StatusCode}", + request.Method, + request.Uri, + (int)response.StatusCode); + + return new DurableHttpResponse(response.StatusCode) + { + Headers = responseHeaders, + Content = body, + }; + } + + async Task ExecuteWithRetryAsync(DurableHttpRequest request) + { + HttpRetryOptions? retryOptions = request.HttpRetryOptions; + int maxAttempts = retryOptions?.MaxNumberOfAttempts ?? 1; + if (maxAttempts < 1) + { + maxAttempts = 1; + } + + TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero; + DateTime deadline = retryOptions != null && retryOptions.RetryTimeout < TimeSpan.MaxValue + ? DateTime.UtcNow + retryOptions.RetryTimeout + : DateTime.MaxValue; + + HttpResponseMessage? lastResponse = null; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + using HttpRequestMessage httpRequest = BuildHttpRequest(request); + + using var cts = new CancellationTokenSource(); + if (request.Timeout.HasValue) + { + cts.CancelAfter(request.Timeout.Value); + } + + lastResponse?.Dispose(); + lastResponse = await this.httpClient.SendAsync(httpRequest, cts.Token); + + // Check if we should retry + bool isLastAttempt = attempt >= maxAttempts || DateTime.UtcNow >= deadline; + if (isLastAttempt || !IsRetryableStatus(lastResponse.StatusCode, retryOptions)) + { + return lastResponse; + } + + this.logger.LogWarning( + "HTTP request to {Uri} returned {StatusCode}, retrying (attempt {Attempt}/{MaxAttempts})", + request.Uri, + (int)lastResponse.StatusCode, + attempt, + maxAttempts); + + lastResponse.Dispose(); + lastResponse = null; + + await Task.Delay(delay); + + // Calculate next delay with exponential backoff + double coefficient = retryOptions?.BackoffCoefficient ?? 1; + delay = TimeSpan.FromTicks((long)(delay.Ticks * coefficient)); + + TimeSpan maxInterval = retryOptions?.MaxRetryInterval ?? TimeSpan.FromDays(6); + if (delay > maxInterval) + { + delay = maxInterval; + } + } + + // Should not reach here, but return last response as a safety net + return lastResponse!; + } + + static HttpRequestMessage BuildHttpRequest(DurableHttpRequest request) + { + var httpRequest = new HttpRequestMessage(request.Method, request.Uri); + + if (request.Content != null) + { + httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, "application/json"); + } + + if (request.Headers != null) + { + foreach (KeyValuePair header in request.Headers) + { + // Try request headers first, then content headers + if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + return httpRequest; + } + + static bool IsRetryableStatus(HttpStatusCode statusCode, HttpRetryOptions? retryOptions) + { + if (retryOptions == null) + { + return false; + } + + if (retryOptions.StatusCodesToRetry.Count > 0) + { + return retryOptions.StatusCodesToRetry.Contains(statusCode); + } + + // Default: retry all 4xx and 5xx + int code = (int)statusCode; + return code >= 400; + } + + static IDictionary? MapResponseHeaders(HttpResponseMessage response) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (KeyValuePair> header in response.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + + if (response.Content?.Headers != null) + { + foreach (KeyValuePair> header in response.Content.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + } + + return headers.Count > 0 ? headers : null; + } +} diff --git a/src/Extensions/Http/Converters/HttpHeadersConverter.cs b/src/Extensions/Http/Converters/HttpHeadersConverter.cs new file mode 100644 index 00000000..1e6316e8 --- /dev/null +++ b/src/Extensions/Http/Converters/HttpHeadersConverter.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Http.Converters; + +/// +/// JSON converter for HTTP header dictionaries. Handles both single-value strings and +/// string arrays (takes the last value for simplicity since +/// is IDictionary<string, string>). +/// +internal sealed class HttpHeadersConverter : JsonConverter> +{ + /// + public override IDictionary Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (reader.TokenType != JsonTokenType.StartObject) + { + return headers; + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + string propertyName = reader.GetString()!; + reader.Read(); + + if (reader.TokenType == JsonTokenType.String) + { + headers[propertyName] = reader.GetString()!; + } + else if (reader.TokenType == JsonTokenType.StartArray) + { + string? lastValue = null; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + lastValue = reader.GetString(); + } + + if (lastValue != null) + { + headers[propertyName] = lastValue; + } + } + } + + return headers; + } + + /// + public override void Write( + Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (KeyValuePair pair in value) + { + writer.WriteString(pair.Key, pair.Value); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Extensions/Http/Converters/HttpMethodConverter.cs b/src/Extensions/Http/Converters/HttpMethodConverter.cs new file mode 100644 index 00000000..cc4cdfcf --- /dev/null +++ b/src/Extensions/Http/Converters/HttpMethodConverter.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Http.Converters; + +/// +/// JSON converter for . +/// +internal sealed class HttpMethodConverter : JsonConverter +{ + /// + public override HttpMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string value = reader.GetString() ?? string.Empty; + return new HttpMethod(value); + } + + /// + public override void Write(Utf8JsonWriter writer, HttpMethod value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Extensions/Http/Converters/TokenSourceConverter.cs b/src/Extensions/Http/Converters/TokenSourceConverter.cs new file mode 100644 index 00000000..d9583f73 --- /dev/null +++ b/src/Extensions/Http/Converters/TokenSourceConverter.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Http.Converters; + +/// +/// JSON converter for — handles serialization only. +/// Deserialization is not supported since token acquisition is not available in standalone mode. +/// +internal sealed class TokenSourceConverter : JsonConverter +{ + /// + public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Skip the token source object during deserialization — token acquisition is not supported. + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + JsonElement root = doc.RootElement; + + if (root.TryGetProperty("resource", out JsonElement resourceElement)) + { + string resource = resourceElement.GetString() ?? string.Empty; + ManagedIdentityOptions? opts = null; + + if (root.TryGetProperty("options", out JsonElement optionsElement)) + { + Uri? authorityHost = null; + string? tenantId = null; + + if (optionsElement.TryGetProperty("authorityhost", out JsonElement authHostElement)) + { + string? authHostStr = authHostElement.GetString(); + if (authHostStr != null) + { + authorityHost = new Uri(authHostStr); + } + } + + if (optionsElement.TryGetProperty("tenantid", out JsonElement tenantElement)) + { + tenantId = tenantElement.GetString(); + } + + opts = new ManagedIdentityOptions(authorityHost, tenantId); + } + + return new ManagedIdentityTokenSource(resource, opts); + } + + return null; + } + + /// + public override void Write(Utf8JsonWriter writer, TokenSource value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartObject(); + writer.WriteString("kind", "AzureManagedIdentity"); + writer.WriteString("resource", value.Resource); + + if (value is ManagedIdentityTokenSource managedIdentity && managedIdentity.Options != null) + { + writer.WritePropertyName("options"); + JsonSerializer.Serialize(writer, managedIdentity.Options, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Extensions/Http/DurableHttpRequest.cs b/src/Extensions/Http/DurableHttpRequest.cs new file mode 100644 index 00000000..9b9196b6 --- /dev/null +++ b/src/Extensions/Http/DurableHttpRequest.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http; +using System.Text.Json.Serialization; +using Microsoft.DurableTask.Http.Converters; + +namespace Microsoft.DurableTask.Http; + +/// +/// Represents an HTTP request that can be made by an orchestrator function using +/// . +/// +/// +/// The request is serialized and persisted in the orchestration history, making it safe for replay. +/// This type is wire-compatible with the Azure Functions Durable Task extension's DurableHttpRequest. +/// +public class DurableHttpRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The HTTP method. + /// The target URI. + public DurableHttpRequest(HttpMethod method, Uri uri) + { + this.Method = method ?? throw new ArgumentNullException(nameof(method)); + this.Uri = uri ?? throw new ArgumentNullException(nameof(uri)); + } + + /// + /// Gets the HTTP method for the request. + /// + [JsonPropertyName("method")] + [JsonConverter(typeof(HttpMethodConverter))] + public HttpMethod Method { get; } + + /// + /// Gets the target URI for the request. + /// + [JsonPropertyName("uri")] + public Uri Uri { get; } + + /// + /// Gets or sets the HTTP headers for the request. + /// + [JsonPropertyName("headers")] + [JsonConverter(typeof(HttpHeadersConverter))] + public IDictionary? Headers { get; set; } + + /// + /// Gets or sets the body content of the request. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Gets or sets the token source for authentication. + /// + /// + /// Token acquisition is not supported in standalone mode. If set, an exception will be thrown at execution time. + /// Pass authentication tokens directly via the dictionary instead. + /// + [JsonPropertyName("tokenSource")] + [JsonConverter(typeof(TokenSourceConverter))] + public TokenSource? TokenSource { get; set; } + + /// + /// Gets or sets a value indicating whether the asynchronous HTTP 202 polling pattern is enabled. + /// + /// + /// When enabled and the target returns HTTP 202 with a Location header, the framework will + /// automatically poll until a non-202 response is received. + /// + [JsonPropertyName("asynchronousPatternEnabled")] + public bool AsynchronousPatternEnabled { get; set; } + + /// + /// Gets or sets the retry options for the HTTP request. + /// + [JsonPropertyName("retryOptions")] + public HttpRetryOptions? HttpRetryOptions { get; set; } + + /// + /// Gets or sets the total timeout for the HTTP request and any asynchronous polling. + /// + [JsonPropertyName("timeout")] + public TimeSpan? Timeout { get; set; } +} diff --git a/src/Extensions/Http/DurableHttpResponse.cs b/src/Extensions/Http/DurableHttpResponse.cs new file mode 100644 index 00000000..5b96f5da --- /dev/null +++ b/src/Extensions/Http/DurableHttpResponse.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.DurableTask.Http.Converters; + +namespace Microsoft.DurableTask.Http; + +/// +/// Represents an HTTP response returned by a durable HTTP call made via +/// . +/// +/// +/// The response data is durably persisted in the orchestration history. +/// This type is wire-compatible with the Azure Functions Durable Task extension's DurableHttpResponse. +/// +public class DurableHttpResponse +{ + /// + /// Initializes a new instance of the class. + /// + /// The HTTP status code. + public DurableHttpResponse(HttpStatusCode statusCode) + { + this.StatusCode = statusCode; + } + + /// + /// Gets the HTTP status code. + /// + [JsonPropertyName("statusCode")] + public HttpStatusCode StatusCode { get; } + + /// + /// Gets or sets the HTTP response headers. + /// + [JsonPropertyName("headers")] + [JsonConverter(typeof(HttpHeadersConverter))] + public IDictionary? Headers { get; set; } + + /// + /// Gets or sets the body content of the response. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } +} diff --git a/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs b/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs new file mode 100644 index 00000000..6c649f65 --- /dev/null +++ b/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Http; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask; + +/// +/// Extension methods for adding built-in HTTP activity support to a Durable Task worker. +/// +public static class DurableTaskBuilderHttpExtensions +{ + /// + /// The well-known activity name for the built-in HTTP activity. + /// + public const string HttpTaskActivityName = "BuiltIn::HttpActivity"; + + /// + /// Adds the built-in HTTP activity to the worker, enabling + /// + /// in standalone (non-Azure Functions) scenarios. + /// + /// The worker builder. + /// The builder, for call chaining. + /// + /// + /// This registers an internal activity named "BuiltIn::HttpActivity" that uses + /// to execute HTTP requests. If an IHttpClientFactory + /// is registered in the service collection, a named client "DurableHttp" is used. + /// + /// + /// Example usage: + /// + /// builder.Services.AddDurableTaskWorker() + /// .AddTasks(registry => { /* your activities */ }) + /// .UseHttpActivities() + /// .UseGrpc(); + /// + /// + /// + public static IDurableTaskWorkerBuilder UseHttpActivities(this IDurableTaskWorkerBuilder builder) + { + Check.NotNull(builder); + + builder.Services.AddHttpClient("DurableHttp"); + + builder.AddTasks(registry => + { + registry.AddActivity( + new TaskName(HttpTaskActivityName), + sp => + { + IHttpClientFactory httpClientFactory = sp.GetRequiredService(); + HttpClient client = httpClientFactory.CreateClient("DurableHttp"); + ILogger logger = sp.GetRequiredService() + .CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity"); + return new BuiltInHttpActivity(client, logger); + }); + }); + + return builder; + } +} diff --git a/src/Extensions/Http/Http.csproj b/src/Extensions/Http/Http.csproj new file mode 100644 index 00000000..ca930b1d --- /dev/null +++ b/src/Extensions/Http/Http.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.0 + Built-in HTTP activity support for the Durable Task standalone SDK. +Enables CallHttpAsync in non-Azure Functions scenarios (e.g., with durabletask-go sidecar). + Microsoft.DurableTask.Extensions.Http + Microsoft.DurableTask.Http + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Extensions/Http/HttpRetryOptions.cs b/src/Extensions/Http/HttpRetryOptions.cs new file mode 100644 index 00000000..f1e233aa --- /dev/null +++ b/src/Extensions/Http/HttpRetryOptions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Http; + +/// +/// Defines retry policies for durable HTTP requests. +/// +public class HttpRetryOptions +{ + static readonly TimeSpan DefaultMaxRetryInterval = TimeSpan.FromDays(6); + + /// + /// Initializes a new instance of the class. + /// + /// The status codes to retry on, or null to retry all 4xx/5xx. + public HttpRetryOptions(IList? statusCodesToRetry = null) + { + this.StatusCodesToRetry = statusCodesToRetry ?? new List(); + } + + /// + /// Gets or sets the first retry interval. + /// + [JsonPropertyName("FirstRetryInterval")] + public TimeSpan FirstRetryInterval { get; set; } + + /// + /// Gets or sets the max retry interval. Defaults to 6 days. + /// + [JsonPropertyName("MaxRetryInterval")] + public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval; + + /// + /// Gets or sets the backoff coefficient. Defaults to 1. + /// + [JsonPropertyName("BackoffCoefficient")] + public double BackoffCoefficient { get; set; } = 1; + + /// + /// Gets or sets the timeout for retries. Defaults to . + /// + [JsonPropertyName("RetryTimeout")] + public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue; + + /// + /// Gets or sets the max number of attempts. + /// + [JsonPropertyName("MaxNumberOfAttempts")] + public int MaxNumberOfAttempts { get; set; } + + /// + /// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried. + /// + [JsonPropertyName("StatusCodesToRetry")] + public IList StatusCodesToRetry { get; } +} diff --git a/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs b/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs new file mode 100644 index 00000000..3ae56be7 --- /dev/null +++ b/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using Microsoft.DurableTask.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask; + +/// +/// Extension methods for making durable HTTP calls from orchestrator functions. +/// +public static class TaskOrchestrationContextHttpExtensions +{ + const int DefaultPollingIntervalMs = 30000; + + /// + /// Makes a durable HTTP call. When is + /// true and the target returns HTTP 202 with a Location header, the framework will + /// automatically poll until a non-202 response is received. + /// + /// The orchestration context. + /// The HTTP request to execute. + /// The HTTP response. + public static async Task CallHttpAsync( + this TaskOrchestrationContext context, DurableHttpRequest request) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + ILogger logger = context.CreateReplaySafeLogger("Microsoft.DurableTask.Http.CallHttp"); + + DurableHttpResponse response = await context.CallActivityAsync( + DurableTaskBuilderHttpExtensions.HttpTaskActivityName, request); + + // Handle 202 async polling pattern + while (response.StatusCode == HttpStatusCode.Accepted && request.AsynchronousPatternEnabled) + { + if (response.Headers == null) + { + logger.LogWarning( + "HTTP response headers are null; unable to retrieve 'Location' URL for polling."); + break; + } + + // Determine polling delay + DateTime fireAt; + var headers = new Dictionary(response.Headers, StringComparer.OrdinalIgnoreCase); + + if (headers.TryGetValue("Retry-After", out string? retryAfterStr) + && int.TryParse(retryAfterStr, out int retryAfterSeconds)) + { + fireAt = context.CurrentUtcDateTime.AddSeconds(retryAfterSeconds); + } + else + { + fireAt = context.CurrentUtcDateTime.AddMilliseconds(DefaultPollingIntervalMs); + } + + await context.CreateTimer(fireAt, CancellationToken.None); + + // Get location URL + if (!headers.TryGetValue("Location", out string? locationUrl) || locationUrl == null) + { + logger.LogWarning( + "HTTP 202 response missing 'Location' header; unable to poll for status."); + break; + } + + logger.LogInformation("Polling HTTP status at location: {LocationUrl}", locationUrl); + + // Build poll request: GET to Location URL with original headers + var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl)) + { + Headers = request.Headers, + AsynchronousPatternEnabled = request.AsynchronousPatternEnabled, + }; + + response = await context.CallActivityAsync( + DurableTaskBuilderHttpExtensions.HttpTaskActivityName, pollRequest); + } + + return response; + } + + /// + /// Makes a durable HTTP call to the specified URI. + /// + /// The orchestration context. + /// The HTTP method. + /// The target URI. + /// Optional request body content. + /// Optional retry options. + /// Whether to enable automatic HTTP 202 polling. + /// The HTTP response. + public static Task CallHttpAsync( + this TaskOrchestrationContext context, + HttpMethod method, + Uri uri, + string? content = null, + HttpRetryOptions? retryOptions = null, + bool asynchronousPatternEnabled = false) + { + var request = new DurableHttpRequest(method, uri) + { + Content = content, + HttpRetryOptions = retryOptions, + AsynchronousPatternEnabled = asynchronousPatternEnabled, + }; + + return context.CallHttpAsync(request); + } + + /// + /// Makes a durable HTTP call to the specified URI with full configuration options. + /// + /// The orchestration context. + /// The HTTP method. + /// The target URI. + /// Optional request body content. + /// Optional retry options. + /// Whether to enable automatic HTTP 202 polling. + /// Optional token source for authentication (not supported in standalone mode). + /// Optional request timeout. + /// The HTTP response. + public static Task CallHttpAsync( + this TaskOrchestrationContext context, + HttpMethod method, + Uri uri, + string? content = null, + HttpRetryOptions? retryOptions = null, + bool asynchronousPatternEnabled = false, + TokenSource? tokenSource = null, + TimeSpan? timeout = null) + { + var request = new DurableHttpRequest(method, uri) + { + Content = content, + HttpRetryOptions = retryOptions, + AsynchronousPatternEnabled = asynchronousPatternEnabled, + TokenSource = tokenSource, + Timeout = timeout, + }; + + return context.CallHttpAsync(request); + } +} diff --git a/src/Extensions/Http/TokenSource.cs b/src/Extensions/Http/TokenSource.cs new file mode 100644 index 00000000..3334b218 --- /dev/null +++ b/src/Extensions/Http/TokenSource.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Http; + +/// +/// Abstract base class for token sources used to authenticate durable HTTP requests. +/// +/// +/// Token acquisition is not supported in standalone mode. TokenSource types are included +/// for wire compatibility with the Azure Functions Durable Task extension. If a TokenSource +/// is set on a request, the built-in HTTP activity will throw . +/// Pass authentication tokens directly via request headers instead. +/// +public abstract class TokenSource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource identifier. + internal TokenSource(string resource) + { + this.Resource = resource ?? throw new ArgumentNullException(nameof(resource)); + } + + /// + /// Gets the resource identifier for the token source. + /// + [JsonPropertyName("resource")] + public string Resource { get; } +} + +/// +/// Token source implementation for Azure Managed Identities. +/// +/// +/// Included for wire compatibility only. Token acquisition is not supported in standalone mode. +/// +public class ManagedIdentityTokenSource : TokenSource +{ + /// + /// Initializes a new instance of the class. + /// + /// The Entra ID resource identifier of the web API being invoked. + /// Optional Azure credential options. + public ManagedIdentityTokenSource(string resource, ManagedIdentityOptions? options = null) + : base(NormalizeResource(resource)) + { + this.Options = options; + } + + /// + /// Gets the Azure credential options. + /// + [JsonPropertyName("options")] + public ManagedIdentityOptions? Options { get; } + + static string NormalizeResource(string resource) + { + if (resource == null) + { + throw new ArgumentNullException(nameof(resource)); + } + + if (resource == "https://management.core.windows.net" || resource == "https://management.core.windows.net/") + { + return "https://management.core.windows.net/.default"; + } + + if (resource == "https://graph.microsoft.com" || resource == "https://graph.microsoft.com/") + { + return "https://graph.microsoft.com/.default"; + } + + return resource; + } +} + +/// +/// Configuration options for . +/// +public class ManagedIdentityOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The Entra ID authority host. + /// The tenant ID. + public ManagedIdentityOptions(Uri? authorityHost = null, string? tenantId = null) + { + this.AuthorityHost = authorityHost; + this.TenantId = tenantId; + } + + /// + /// Gets or sets the Entra ID authority host. + /// + [JsonPropertyName("authorityhost")] + public Uri? AuthorityHost { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantid")] + public string? TenantId { get; set; } +} diff --git a/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs b/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs new file mode 100644 index 00000000..de174fb7 --- /dev/null +++ b/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using FluentAssertions; +using Microsoft.DurableTask.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.Extensions.Http.Tests; + +public class BuiltInHttpActivityTests +{ + static BuiltInHttpActivity CreateActivity(MockHttpHandler handler) + { + HttpClient client = new HttpClient(handler); + return new BuiltInHttpActivity(client, NullLogger.Instance); + } + + static Mock CreateContext() + { + Mock mock = new Mock(); + mock.Setup(c => c.InstanceId).Returns("test-instance"); + return mock; + } + + [Fact] + public async Task RunAsync_SimpleGet_ReturnsOk() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("hello-world"), + }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api")); + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Be("hello-world"); + handler.RequestsSent.Should().HaveCount(1); + handler.RequestsSent[0].Method.Should().Be(HttpMethod.Get); + handler.RequestsSent[0].RequestUri.Should().Be(new Uri("https://example.com/api")); + } + + [Fact] + public async Task RunAsync_PostWithBodyAndHeaders_SendsCorrectly() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent("created"), + }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Post, new Uri("https://example.com/api")) + { + Content = "test-body", + Headers = new Dictionary + { + ["X-Custom-Header"] = "custom-value", + }, + }; + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + handler.RequestsSent.Should().HaveCount(1); + + HttpRequestMessage sent = handler.RequestsSent[0]; + sent.Method.Should().Be(HttpMethod.Post); + sent.Headers.Contains("X-Custom-Header").Should().BeTrue(); + + string? body = handler.RequestBodies[0]; + body.Should().Contain("test-body"); + } + + [Fact] + public async Task RunAsync_TokenSourceSet_ThrowsNotSupportedException() + { + MockHttpHandler handler = new MockHttpHandler(new HttpResponseMessage(HttpStatusCode.OK)); + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api")) + { + TokenSource = new ManagedIdentityTokenSource("https://management.core.windows.net/.default"), + }; + + await Assert.ThrowsAsync( + () => activity.RunAsync(CreateContext().Object, request)); + } + + [Fact] + public async Task RunAsync_RetryOnServerError_RetriesAndSucceeds() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("success"), + }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api")) + { + HttpRetryOptions = new HttpRetryOptions + { + MaxNumberOfAttempts = 3, + FirstRetryInterval = TimeSpan.FromMilliseconds(1), + }, + }; + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Be("success"); + handler.RequestsSent.Should().HaveCount(2); + } + + [Fact] + public async Task RunAsync_RetryExhausted_ReturnsLastError() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error1") }, + new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error2") }, + new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error3") }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api")) + { + HttpRetryOptions = new HttpRetryOptions + { + MaxNumberOfAttempts = 3, + FirstRetryInterval = TimeSpan.FromMilliseconds(1), + }, + }; + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + handler.RequestsSent.Should().HaveCount(3); + } + + [Fact] + public async Task RunAsync_RetryWithSpecificStatusCodes_OnlyRetriesMatchingCodes() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.TooManyRequests), + new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("not found") }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api")) + { + HttpRetryOptions = new HttpRetryOptions + { + MaxNumberOfAttempts = 5, + FirstRetryInterval = TimeSpan.FromMilliseconds(1), + StatusCodesToRetry = { HttpStatusCode.TooManyRequests, HttpStatusCode.ServiceUnavailable }, + }, + }; + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + handler.RequestsSent.Should().HaveCount(2); + } + + [Fact] + public async Task RunAsync_ResponseHeaders_AreMapped() + { + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("body"), + }; + httpResponse.Headers.Add("X-Request-Id", "abc123"); + + MockHttpHandler handler = new MockHttpHandler(httpResponse); + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api")); + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.Headers.Should().NotBeNull(); + response.Headers.Should().ContainKey("X-Request-Id"); + response.Headers!["X-Request-Id"].Should().Be("abc123"); + } + + [Fact] + public async Task RunAsync_NullRequest_ThrowsArgumentNull() + { + MockHttpHandler handler = new MockHttpHandler(new HttpResponseMessage(HttpStatusCode.OK)); + BuiltInHttpActivity activity = CreateActivity(handler); + + await Assert.ThrowsAsync( + () => activity.RunAsync(CreateContext().Object, null!)); + } + + sealed class MockHttpHandler : HttpMessageHandler + { + readonly Queue responses; + + public MockHttpHandler(params HttpResponseMessage[] responses) + { + this.responses = new Queue(responses); + this.RequestsSent = new List(); + } + + public List RequestsSent { get; } + public List RequestBodies { get; } = new List(); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + this.RequestsSent.Add(request); + this.RequestBodies.Add(request.Content?.ReadAsStringAsync().Result); + + if (this.responses.Count == 0) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + return Task.FromResult(this.responses.Dequeue()); + } + } +} diff --git a/test/Extensions/Http.Tests/Http.Tests.csproj b/test/Extensions/Http.Tests/Http.Tests.csproj new file mode 100644 index 00000000..5d065b25 --- /dev/null +++ b/test/Extensions/Http.Tests/Http.Tests.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/test/Extensions/Http.Tests/RegistrationTests.cs b/test/Extensions/Http.Tests/RegistrationTests.cs new file mode 100644 index 00000000..d0f79969 --- /dev/null +++ b/test/Extensions/Http.Tests/RegistrationTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.DurableTask.Http; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.DurableTask.Tests.Http; + +public class RegistrationTests +{ + [Fact] + public void UseHttpActivities_DoesNotThrow() + { + // Verify that calling UseHttpActivities on a builder does not throw, + // confirming the registration code path is valid. + var services = new ServiceCollection(); + services.AddLogging(); + + // Act & Assert — no exception means registration succeeded + FluentActions.Invoking(() => + { + services.AddDurableTaskWorker(builder => + { + builder.UseHttpActivities(); + }); + }).Should().NotThrow(); + } + + [Fact] + public void UseHttpActivities_RegistersHttpClient() + { + // Verify that UseHttpActivities registers the named HttpClient + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDurableTaskWorker(builder => + { + builder.UseHttpActivities(); + }); + + ServiceProvider sp = services.BuildServiceProvider(); + var httpClientFactory = sp.GetService(); + httpClientFactory.Should().NotBeNull("UseHttpActivities should register IHttpClientFactory"); + } +} diff --git a/test/Extensions/Http.Tests/SerializationTests.cs b/test/Extensions/Http.Tests/SerializationTests.cs new file mode 100644 index 00000000..d39e0d11 --- /dev/null +++ b/test/Extensions/Http.Tests/SerializationTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using FluentAssertions; +using Microsoft.DurableTask.Http; +using Xunit; + +namespace Microsoft.DurableTask.Tests.Http; + +public class SerializationTests +{ + static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + [Fact] + public void DurableHttpRequest_RoundTrip_PreservesAllFields() + { + // Arrange + var request = new DurableHttpRequest(HttpMethod.Post, new Uri("https://example.com/api")) + { + Content = "{\"key\":\"value\"}", + Headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer token123", + }, + AsynchronousPatternEnabled = true, + Timeout = TimeSpan.FromMinutes(5), + HttpRetryOptions = new HttpRetryOptions + { + MaxNumberOfAttempts = 3, + FirstRetryInterval = TimeSpan.FromSeconds(5), + BackoffCoefficient = 2.0, + }, + }; + + // Act + string json = JsonSerializer.Serialize(request, Options); + DurableHttpRequest? deserialized = JsonSerializer.Deserialize(json, Options); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.Method.Should().Be(HttpMethod.Post); + deserialized.Uri.Should().Be(new Uri("https://example.com/api")); + deserialized.Content.Should().Be("{\"key\":\"value\"}"); + deserialized.Headers.Should().HaveCount(2); + deserialized.AsynchronousPatternEnabled.Should().BeTrue(); + deserialized.Timeout.Should().Be(TimeSpan.FromMinutes(5)); + deserialized.HttpRetryOptions.Should().NotBeNull(); + deserialized.HttpRetryOptions!.MaxNumberOfAttempts.Should().Be(3); + } + + [Fact] + public void DurableHttpResponse_RoundTrip_PreservesAllFields() + { + // Arrange + var response = new DurableHttpResponse(HttpStatusCode.OK) + { + Content = "{\"result\":true}", + Headers = new Dictionary + { + ["X-Request-Id"] = "abc-123", + }, + }; + + // Act + string json = JsonSerializer.Serialize(response, Options); + DurableHttpResponse? deserialized = JsonSerializer.Deserialize(json, Options); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.StatusCode.Should().Be(HttpStatusCode.OK); + deserialized.Content.Should().Be("{\"result\":true}"); + deserialized.Headers.Should().ContainKey("X-Request-Id"); + } + + [Fact] + public void DurableHttpRequest_MinimalGet_SerializesCorrectly() + { + // Arrange + var request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com")); + + // Act + string json = JsonSerializer.Serialize(request, Options); + + // Assert + json.Should().Contain("\"method\":\"GET\""); + json.Should().Contain("\"uri\":\"https://example.com\""); + } + + [Fact] + public void DurableHttpResponse_MinimalResponse_DeserializesCorrectly() + { + // Arrange + string json = "{\"statusCode\":404}"; + + // Act + DurableHttpResponse? response = JsonSerializer.Deserialize(json, Options); + + // Assert + response.Should().NotBeNull(); + response!.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.Content.Should().BeNull(); + response.Headers.Should().BeNull(); + } + + [Fact] + public void TokenSource_ManagedIdentity_SerializesCorrectly() + { + // Arrange + var request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com")) + { + TokenSource = new ManagedIdentityTokenSource("https://management.core.windows.net"), + }; + + // Act + string json = JsonSerializer.Serialize(request, Options); + + // Assert + json.Should().Contain("\"kind\":\"AzureManagedIdentity\""); + json.Should().Contain("\"resource\":\"https://management.core.windows.net/.default\""); + } + + [Fact] + public void HttpRetryOptions_RoundTrip_PreservesAllFields() + { + // Arrange + var options = new HttpRetryOptions( + statusCodesToRetry: new List { HttpStatusCode.TooManyRequests, HttpStatusCode.ServiceUnavailable }) + { + FirstRetryInterval = TimeSpan.FromSeconds(1), + MaxRetryInterval = TimeSpan.FromMinutes(5), + BackoffCoefficient = 1.5, + RetryTimeout = TimeSpan.FromMinutes(30), + MaxNumberOfAttempts = 10, + }; + + // Act + string json = JsonSerializer.Serialize(options); + HttpRetryOptions? deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.FirstRetryInterval.Should().Be(TimeSpan.FromSeconds(1)); + deserialized.MaxRetryInterval.Should().Be(TimeSpan.FromMinutes(5)); + deserialized.BackoffCoefficient.Should().Be(1.5); + deserialized.MaxNumberOfAttempts.Should().Be(10); + deserialized.StatusCodesToRetry.Should().HaveCount(2); + } +} From 12466cc442d3c648b2f6e76a8aaedaadd29569a9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 2 Apr 2026 17:03:45 -0700 Subject: [PATCH 2/2] chore: Add CHANGELOG entry and remove spurious BOM from solution file Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- Microsoft.DurableTask.sln | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6e340a..3a38e97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased - +- Add built-in HTTP activity extension (`Microsoft.DurableTask.Extensions.Http`) for standalone SDK — enables `CallHttpAsync` without Azure Functions host ([#697](https://github.com/microsoft/durabletask-dotnet/pull/697)) ## v1.23.2 - fix: improve large payload error handling — better error message and prevent infinite retry and fix conflict with auto chunking ([#691](https://github.com/microsoft/durabletask-dotnet/pull/691)) diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index d51f6ae4..23c53472 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215