Skip to content

Commit

Permalink
Azure Functions isolated process (#4988)
Browse files Browse the repository at this point in the history
  • Loading branch information
cajuncoding authored Sep 12, 2022
1 parent deec15d commit 0190045
Show file tree
Hide file tree
Showing 29 changed files with 1,428 additions and 54 deletions.
7 changes: 5 additions & 2 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@
<TestTargetFrameworks Condition="'$(IsMacOsArm)' == 'true'">net7.0; net6.0</TestTargetFrameworks>
<AspNetTargetFrameworks Condition="'$(IsMacOsArm)' != 'true'">net7.0; net6.0; net5.0; netcoreapp3.1</AspNetTargetFrameworks>
<AspNetTargetFrameworks Condition="'$(IsMacOsArm)' == 'true'">net7.0; net6.0</AspNetTargetFrameworks>
<AzfTargetFrameworks Condition="'$(IsMacOsArm)' != 'true'">net6.0; net5.0; netcoreapp3.1</AzfTargetFrameworks>
<AzfTargetFrameworks Condition="'$(IsMacOsArm)' == 'true'">net6.0</AzfTargetFrameworks>
<AzfTargetFrameworks Condition="'$(IsMacOsArm)' != 'true'">net7.0; net6.0; net5.0; netcoreapp3.1</AzfTargetFrameworks>
<AzfTargetFrameworks Condition="'$(IsMacOsArm)' == 'true'">net7.0; net6.0</AzfTargetFrameworks>
<!-- Azure Functions Isolated Process does not support netcoreapp3.1 (on net5.0+) -->
<AzfIsoProcTargetFrameworks Condition="'$(IsMacOsArm)' != 'true'">net7.0; net6.0; net5.0;</AzfIsoProcTargetFrameworks>
<AzfIsoProcTargetFrameworks Condition="'$(IsMacOsArm)' == 'true'">net7.0; net6.0</AzfIsoProcTargetFrameworks>
<SourceGenTargetFrameworks>net7.0; net6.0; netstandard2.0</SourceGenTargetFrameworks>
</PropertyGroup>

Expand Down
1 change: 1 addition & 0 deletions src/HotChocolate/AzureFunctions/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

<PropertyGroup>
<TargetFrameworks>$(AzfTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="$(MSBuildProjectName.Contains('Isolated'))">$(AzfIsoProcTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.Azure.Functions.Worker.Http;

namespace HotChocolate.AzureFunctions.IsolatedProcess;

public static class GraphQLRequestExecutorExtensions
{
public static Task<HttpResponseData> ExecuteAsync(
this IGraphQLRequestExecutor graphqlRequestExecutor,
HttpRequestData httpRequestData)
{
if (graphqlRequestExecutor is null)
{
throw new ArgumentNullException(nameof(graphqlRequestExecutor));
}

if (httpRequestData is null)
{
throw new ArgumentNullException(nameof(httpRequestData));
}

// Factored out Async logic to Address SonarCloud concern for exceptions in Async flow ...
return ExecuteGraphQLRequestInternalAsync(graphqlRequestExecutor, httpRequestData);
}

private static async Task<HttpResponseData> ExecuteGraphQLRequestInternalAsync(
IGraphQLRequestExecutor graphqlRequestExecutor,
HttpRequestData httpRequestData)
{
// Adapt the Isolated Process HttpRequestData to the HttpContext needed by
// HotChocolate and execute the Pipeline...
// NOTE: This must be disposed of properly to ensure our request/response
// resources are managed efficiently.
using var shim =
await HttpContextShim.CreateHttpContextAsync(httpRequestData).ConfigureAwait(false);

// Isolated Process doesn't natively support HttpContext so we must manually enable
// support for HttpContext injection within HotChocolate (e.g. into Resolvers) for
// low-level access.
httpRequestData.SetCurrentHttpContext(shim.HttpContext);

// Now we can execute the request by marshalling the HttpContext into the
// DefaultGraphQLRequestExecutor which will handle pre & post processing as needed ...
// NOTE: We discard the result returned (likely an EmptyResult) as all content is already
// written to the HttpContext Response.
await graphqlRequestExecutor.ExecuteAsync(shim.HttpContext.Request).ConfigureAwait(false);

// Last, in the Isolated Process model we marshall all data back to the HttpResponseData
// model and return it to the AzureFunctions process ...
// Therefore we need to marshall the Response back to the Isolated Process model ...
return await shim.CreateHttpResponseDataAsync().ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using HotChocolate.AzureFunctions;
using HotChocolate.Execution.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

// ReSharper disable once CheckNamespace
namespace Microsoft.Azure.Functions.Extensions.DependencyInjection;

/// <summary>
/// Provides DI extension methods to configure a GraphQL server.
/// </summary>
public static class HotChocolateAzFuncIsolatedProcessHostBuilderExtensions
{
/// <summary>
/// Adds a GraphQL server and Azure Functions integration services for
/// Azure Functions Isolated processing model.
/// </summary>
/// <param name="hostBuilder">
/// The <see cref="IFunctionsHostBuilder"/>.
/// </param>
/// <param name="configure">
/// The GraphQL configuration function that will be invoked, for chained
/// configuration, when the Host is built.
/// </param>
/// <param name="maxAllowedRequestSize">
/// The max allowed GraphQL request size.
/// </param>
/// <param name="apiRoute">
/// The API route that was used in the GraphQL Azure Function.
/// </param>
/// <returns>
/// Returns the <see cref="IHostBuilder"/> so that host configuration can be chained.
/// </returns>
/// <exception cref="ArgumentNullException">
/// The <see cref="IServiceCollection"/> is <c>null</c>.
/// </exception>
public static IHostBuilder AddGraphQLFunction(
this IHostBuilder hostBuilder,
Action<IRequestExecutorBuilder> configure,
int maxAllowedRequestSize = GraphQLAzureFunctionsConstants.DefaultMaxRequests,
string apiRoute = GraphQLAzureFunctionsConstants.DefaultGraphQLRoute)
{
if (hostBuilder is null)
{
throw new ArgumentNullException(nameof(hostBuilder));
}

if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}

hostBuilder.ConfigureServices(services =>
{
var executorBuilder = services.AddGraphQLFunction(maxAllowedRequestSize, apiRoute);
configure(executorBuilder);
});

return hostBuilder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using HotChocolate.AzureFunctions.IsolatedProcess.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Net.Http.Headers;

namespace HotChocolate.AzureFunctions.IsolatedProcess;

public static class HttpRequestDataExtensions
{
public static string GetContentType(
this HttpRequestData httpRequestData,
string defaultValue = GraphQLAzureFunctionsConstants.DefaultJsonContentType)
{
var contentType = httpRequestData.Headers.TryGetValues(
HeaderNames.ContentType,
out var contentTypeHeaders)
? contentTypeHeaders.FirstOrDefault()
: defaultValue;

return contentType ?? defaultValue;
}

public static HttpRequestData SetCurrentHttpContext(
this HttpRequestData httpRequestData,
HttpContext httpContext)
{
httpRequestData.FunctionContext.InstanceServices.SetCurrentHttpContext(httpContext);
return httpRequestData;
}

public static async Task<string?> ReadResponseContentAsync(
this HttpResponseData httpResponseData)
=> await httpResponseData.Body.ReadStreamAsStringAsync().ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<PackageId>HotChocolate.AzureFunctions.IsolatedProcess</PackageId>
<AssemblyName>HotChocolate.AzureFunctions.IsolatedProcess</AssemblyName>
<RootNamespace>HotChocolate.AzureFunctions.IsolatedProcess</RootNamespace>
<Description>This package contains the GraphQL AzureFunctions Isolated Process integration for Hot Chocolate.</Description>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Abstractions" Version="1.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Core" Version="1.4.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\HotChocolate.AzureFunctions\HotChocolate.AzureFunctions.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System.Net;
using HotChocolate.AzureFunctions.IsolatedProcess.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Primitives;

namespace HotChocolate.AzureFunctions.IsolatedProcess;

public sealed class HttpContextShim : IDisposable
{
private bool _disposed;

public HttpContextShim(HttpContext httpContext)
{
HttpContext = httpContext ??
throw new ArgumentNullException(nameof(httpContext));
IsolatedProcessHttpRequestData = null;
}

public HttpContextShim(HttpContext httpContext, HttpRequestData? httpRequestData)
{
HttpContext = httpContext ??
throw new ArgumentNullException(nameof(httpContext));
IsolatedProcessHttpRequestData = httpRequestData ??
throw new ArgumentNullException(nameof(httpRequestData));
}

private HttpRequestData? IsolatedProcessHttpRequestData { get; set; }

// Must keep the Reference so we can safely Dispose!
public HttpContext HttpContext { get; }

/// <summary>
/// Factory method to Create an HttpContext that is AspNetCore compatible.
/// All pertinent data from the HttpRequestData provided by the
/// Azure Functions Isolated Process will be marshaled
/// into the HttpContext for HotChocolate to consume.
/// NOTE: This is done as Factory method (and not in the Constructor)
/// to support optimized Async reading of incoming Request Content/Stream.
/// </summary>
public static Task<HttpContextShim> CreateHttpContextAsync(HttpRequestData httpRequestData)
{
if (httpRequestData == null)
{
throw new ArgumentNullException(nameof(httpRequestData));
}

// Factored out Async logic to Address SonarCloud concern for exceptions in Async flow...
return CreateHttpContextInternalAsync(httpRequestData);
}

private static async Task<HttpContextShim> CreateHttpContextInternalAsync(
HttpRequestData httpRequestData)
{
var requestBody = await httpRequestData.ReadAsStringAsync().ConfigureAwait(false);

var httpContext = new HttpContextBuilder().CreateHttpContext(
requestHttpMethod: httpRequestData.Method,
requestUri: httpRequestData.Url,
requestBody: requestBody,
requestBodyContentType: httpRequestData.GetContentType(),
requestHeaders: httpRequestData.Headers,
claimsIdentities: httpRequestData.Identities,
contextItems: httpRequestData.FunctionContext.Items
);

// Ensure we track the HttpContext internally for cleanup when disposed!
return new HttpContextShim(httpContext, httpRequestData);
}

/// <summary>
/// Create an HttpResponseData containing the proxied response content results;
/// marshaled back from the HttpContext.
/// </summary>
/// <returns></returns>
public async Task<HttpResponseData> CreateHttpResponseDataAsync()
{
var httpContext = HttpContext
?? throw new NullReferenceException(
"The HttpContext has not been initialized correctly.");

var httpRequestData = IsolatedProcessHttpRequestData
?? throw new NullReferenceException(
"The HttpRequestData has not been initialized correctly.");

var httpStatusCode = (HttpStatusCode)httpContext.Response.StatusCode;

// Initialize the Http Response...
var httpResponseData = httpRequestData.CreateResponse(httpStatusCode);

// Marshall over all Headers from the HttpContext...
// Note: This should also handle Cookies since Cookies are stored as a Header value ....
var responseHeaders = httpContext.Response.Headers;

if (responseHeaders.Count > 0)
{
foreach (var (key, value) in responseHeaders)
{
httpResponseData.Headers.TryAddWithoutValidation(
key,
value.Select(sv => sv?.ToString()));
}
}

// Marshall the original response Bytes from HotChocolate...
// Note: This enables full support for GraphQL Json results/errors,
// binary downloads, SDL, & BCP binary data.
var responseBytes = await httpContext.ReadResponseBytesAsync().ConfigureAwait(false);

if (responseBytes != null)
{
await httpResponseData.WriteBytesAsync(responseBytes).ConfigureAwait(false);
}

return httpResponseData;
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

private void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
HttpContext.DisposeSafely();
}
_disposed = true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,46 @@ public static class HotChocolateAzureFunctionServiceCollectionExtensions
/// </exception>
public static IRequestExecutorBuilder AddGraphQLFunction(
this IServiceCollection services,
int maxAllowedRequestSize = 20 * 1000 * 1000,
string apiRoute = "/api/graphql")
int maxAllowedRequestSize = GraphQLAzureFunctionsConstants.DefaultMaxRequests,
string apiRoute = GraphQLAzureFunctionsConstants.DefaultGraphQLRoute)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}

var executorBuilder =
services.AddGraphQLServer(maxAllowedRequestSize: maxAllowedRequestSize);

// Register AzFunc Custom Binding Extensions for In-Process Functions.
// NOTE: This does not work for Isolated Process due to (but is not harmful at all of
// isolated process; it just remains dormant):
// 1) Bindings always execute in-process and values must be marshaled between
// the Host Process & the Isolated Process Worker!
// 2) Currently only String values are supported (obviously due to above complexities).
// More Info. here (using Blob binding docs):
// https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-
// blob-input?tabs=isolated-process%2Cextensionv5&pivots=programming-language-csharp#usage
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IExtensionConfigProvider, GraphQLExtensions>());

//Add the Request Executor Dependency...
services.AddAzureFunctionsGraphQLRequestExecutorDependency(apiRoute);

return executorBuilder;
}

/// <summary>
/// Internal method to adds the Request Executor dependency for Azure Functions both
/// in-process and isolate-process. Normal configuration should use AddGraphQLFunction()
/// extension instead which correctly call this internally.
/// </summary>
public static IServiceCollection AddAzureFunctionsGraphQLRequestExecutorDependency(
this IServiceCollection services,
string apiRoute = GraphQLAzureFunctionsConstants.DefaultGraphQLRoute
)
{
services.AddSingleton<IGraphQLRequestExecutor>(sp =>
{
PathString path = apiRoute.TrimEnd('/');
PathString path = apiRoute?.TrimEnd('/');
var fileProvider = CreateFileProvider();
var options = new GraphQLServerOptions();

Expand All @@ -74,7 +97,7 @@ public static IRequestExecutorBuilder AddGraphQLFunction(
return new DefaultGraphQLRequestExecutor(pipeline, options);
});

return executorBuilder;
return services;
}

/// <summary>
Expand Down
Loading

0 comments on commit 0190045

Please sign in to comment.