Skip to content

Commit

Permalink
HTTP APIs for .NET Isolated Parity (#2653)
Browse files Browse the repository at this point in the history
This PR adds HTTP API parity (CallHttpAsync()) for .NET Isolated.
 
Changes include:

New DurableHttpRequest and DurableHttpResponse types in the Worker project
New TaskOrchestrationContext extension method for CallHttpAsync()
Additional changes in OutOfProcMiddleware to execute the BuiltIn::HttpActivity activity function by using the existing TaskHttpActivityShim (BuiltIn::HttpActivity is the reserved name to know when a TaskActivity should be an HTTP activity).
  • Loading branch information
bachuv authored Nov 13, 2023
1 parent f02cdd6 commit 392fde4
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 1 deletion.
6 changes: 6 additions & 0 deletions src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,12 @@ public async Task CallActivityAsync(DispatchMiddlewareContext dispatchContext, F
throw new InvalidOperationException($"An activity was scheduled but no {nameof(TaskScheduledEvent)} was found!");
}

if (scheduledEvent.Name?.StartsWith("BuiltIn::", StringComparison.OrdinalIgnoreCase) ?? false)
{
await next();
return;
}

FunctionName functionName = new FunctionName(scheduledEvent.Name);

OrchestrationInstance? instance = dispatchContext.GetProperty<OrchestrationInstance>();
Expand Down
2 changes: 2 additions & 0 deletions src/Worker.Extensions.DurableTask/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ internal static class Constants
public const string IllegalAwaitErrorMessage =
"An invalid asynchronous invocation was detected. This can be caused by awaiting non-durable tasks " +
"in an orchestrator function's implementation or by middleware that invokes asynchronous code.";

public const string HttpTaskActivityReservedName = "BuiltIn::HttpActivity";
}
72 changes: 72 additions & 0 deletions src/Worker.Extensions.DurableTask/HTTP/DurableHttpRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Primitives;

namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask.Http;

/// <summary>
/// Request used to make an HTTP call through Durable Functions.
/// </summary>
public class DurableHttpRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableHttpRequest"/> class.
/// </summary>
public DurableHttpRequest(HttpMethod method, Uri uri)
{
this.Method = method;
this.Uri = uri;
}

/// <summary>
/// HttpMethod used in the HTTP request made by the Durable Function.
/// </summary>
[JsonPropertyName("method")]
[JsonConverter(typeof(HttpMethodConverter))]
public HttpMethod Method { get; }

/// <summary>
/// Uri used in the HTTP request made by the Durable Function.
/// </summary>
[JsonPropertyName("uri")]
public Uri Uri { get; }

/// <summary>
/// Headers passed with the HTTP request made by the Durable Function.
/// </summary>
[JsonPropertyName("headers")]
[JsonConverter(typeof(HttpHeadersConverter))]
public IDictionary<string, StringValues>? Headers { get; set; }

/// <summary>
/// Content passed with the HTTP request made by the Durable Function.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; set; }

/// <summary>
/// Specifies whether the Durable HTTP APIs should automatically
/// handle the asynchronous HTTP pattern.
/// </summary>
[JsonPropertyName("asynchronousPatternEnabled")]
public bool AsynchronousPatternEnabled { get; set; }

/// <summary>
/// Defines retry policy for handling of failures in making the HTTP Request. These could be non-successful HTTP status codes
/// in the response, a timeout in making the HTTP call, or an exception raised from the HTTP Client library.
/// </summary>
[JsonPropertyName("retryOptions")]
public HttpRetryOptions? HttpRetryOptions { get; set; }

/// <summary>
/// The total timeout for the original HTTP request and any
/// asynchronous polling.
/// </summary>
[JsonPropertyName("timeout")]
public TimeSpan? Timeout { get; set; }
}
43 changes: 43 additions & 0 deletions src/Worker.Extensions.DurableTask/HTTP/DurableHttpResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Primitives;

namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask.Http;

/// <summary>
/// Response received from the HTTP request made by the Durable Function.
/// </summary>
public class DurableHttpResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableHttpResponse"/> class.
/// </summary>
/// <param name="statusCode">HTTP Status code returned from the HTTP call.</param>
public DurableHttpResponse(HttpStatusCode statusCode)
{
this.StatusCode = statusCode;
}

/// <summary>
/// Status code returned from an HTTP request.
/// </summary>
[JsonPropertyName("statusCode")]
public HttpStatusCode StatusCode { get; }

/// <summary>
/// Headers in the response from an HTTP request.
/// </summary>
[JsonPropertyName("headers")]
[JsonConverter(typeof(HttpHeadersConverter))]
public IDictionary<string, StringValues>? Headers { get; init; }

/// <summary>
/// Content returned from an HTTP request.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; init; }
}
84 changes: 84 additions & 0 deletions src/Worker.Extensions.DurableTask/HTTP/HttpHeadersConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Primitives;

namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask.Http;

// StringValues does not deserialize as you would expect, so we need a custom mechanism
// for serializing HTTP header collections
internal class HttpHeadersConverter : JsonConverter<IDictionary<string, StringValues>>
{
public override IDictionary<string, StringValues> Read(
ref Utf8JsonReader reader,
Type objectType,
JsonSerializerOptions options)
{
var headers = new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);

if (reader.TokenType != JsonTokenType.StartObject)
{
return headers;
}

var valueList = new List<string>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
string? propertyName = reader.GetString();

reader.Read();

// Header values can be either individual strings or string arrays
StringValues values = default(StringValues);
if (reader.TokenType == JsonTokenType.String)
{
values = new StringValues(reader.GetString());
}
else if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
valueList.Add(reader.GetString());
}

values = new StringValues(valueList.ToArray());
valueList.Clear();
}

headers[propertyName] = values;
}

return headers;
}

public override void Write(
Utf8JsonWriter writer,
IDictionary<string, StringValues> value,
JsonSerializerOptions options)
{
writer.WriteStartObject();

var headers = (IDictionary<string, StringValues>)value;
foreach (var pair in headers)
{
if (pair.Value.Count == 1)
{
// serialize as a single string value
writer.WriteString(pair.Key, pair.Value[0]);
}
else
{
// serializes as an array
writer.WriteStartArray(pair.Key);
writer.WriteStringValue(pair.Value);
writer.WriteEndArray();
}
}

writer.WriteEndObject();
}
}
33 changes: 33 additions & 0 deletions src/Worker.Extensions.DurableTask/HTTP/HttpMethodConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask.Http;

internal class HttpMethodConverter : JsonConverter<HttpMethod>
{
public override bool CanConvert(Type objectType)
{
return typeof(HttpMethod).IsAssignableFrom(objectType);
}

public override HttpMethod Read(
ref Utf8JsonReader reader,
Type objectType,
JsonSerializerOptions options)
{
return new HttpMethod(reader.GetString());
}

public override void Write(
Utf8JsonWriter writer,
HttpMethod value,
JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
81 changes: 81 additions & 0 deletions src/Worker.Extensions.DurableTask/HTTP/HttpRetryOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Net;
using System.Text.Json.Serialization;

namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask.Http;

/// <summary>
/// Defines retry policies that can be passed as parameters to various operations.
/// </summary>
public class HttpRetryOptions
{
// Would like to make this durability provider specific, but since this is a developer
// facing type, that is difficult.
private static readonly TimeSpan DefaultMaxRetryinterval = TimeSpan.FromDays(6);

/// <summary>
/// Creates a new instance SerializableRetryOptions with the supplied first retry and max attempts.
/// </summary>
public HttpRetryOptions(IList<HttpStatusCode>? statusCodesToRetry = null)
{
this.StatusCodesToRetry = statusCodesToRetry ?? new List<HttpStatusCode>();
}

/// <summary>
/// Gets or sets the first retry interval.
/// </summary>
/// <value>
/// The TimeSpan to wait for the first retries.
/// </value>
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }

/// <summary>
/// Gets or sets the max retry interval.
/// </summary>
/// <value>
/// The TimeSpan of the max retry interval, defaults to 6 days.
/// </value>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryinterval;

/// <summary>
/// Gets or sets the backoff coefficient.
/// </summary>
/// <value>
/// The backoff coefficient used to determine rate of increase of backoff. Defaults to 1.
/// </value>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;

/// <summary>
/// Gets or sets the timeout for retries.
/// </summary>
/// <value>
/// The TimeSpan timeout for retries, defaults to <see cref="TimeSpan.MaxValue"/>.
/// </value>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;

/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
/// <value>
/// The maximum number of retry attempts.
/// </value>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }

/// <summary>
/// Gets or sets the list of status codes upon which the
/// retry logic specified by this object shall be triggered.
/// If none are provided, all 4xx and 5xx status codes
/// will be retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
public IList<HttpStatusCode> StatusCodesToRetry { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Azure.Functions.Worker.Extensions.DurableTask;
using Microsoft.Azure.Functions.Worker.Extensions.DurableTask.Http;

namespace Microsoft.DurableTask;

/// <summary>
/// Extensions for <see cref="TaskOrchestrationContext"/>.
/// </summary>
public static class TaskOrchestrationContextExtensionMethods
{
/// <summary>
/// Makes an HTTP call using the information in the DurableHttpRequest.
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="request">The DurableHttpRequest used to make the HTTP call.</param>
/// <returns>DurableHttpResponse</returns>
public static Task<DurableHttpResponse> CallHttpAsync(this TaskOrchestrationContext context, DurableHttpRequest request)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}

return context.CallActivityAsync<DurableHttpResponse>(Constants.HttpTaskActivityReservedName, request);
}

/// <summary>
/// Makes an HTTP call to the specified uri.
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="method">HttpMethod used for api call.</param>
/// <param name="uri">uri used to make the HTTP call.</param>
/// <param name="content">Content passed in the HTTP request.</param>
/// <param name="retryOptions">The retry option for the HTTP task.</param>
/// <returns>A <see cref="Task{DurableHttpResponse}"/>Result of the HTTP call.</returns>
public static Task<DurableHttpResponse> CallHttpAsync(this TaskOrchestrationContext context, HttpMethod method, Uri uri, string? content = null, HttpRetryOptions? retryOptions = null)
{
DurableHttpRequest request = new DurableHttpRequest(method, uri)
{
Content = content,
HttpRetryOptions = retryOptions,
};

return context.CallHttpAsync(request);
}
}
Loading

0 comments on commit 392fde4

Please sign in to comment.