From 0190045d89ed0921c340a730200edc832882dd09 Mon Sep 17 00:00:00 2001 From: Brandon Date: Mon, 12 Sep 2022 03:34:39 -0500 Subject: [PATCH] Azure Functions isolated process (#4988) --- src/Directory.Build.props | 7 +- .../AzureFunctions/Directory.Build.props | 1 + .../GraphQLRequestExecutorExtensions.cs | 52 +++++++ ...uncIsolatedProcessHostBuilderExtensions.cs | 61 ++++++++ .../Extensions/HttpRequestDataExtensions.cs | 34 +++++ ...late.AzureFunctions.IsolatedProcess.csproj | 21 +++ .../HttpContextShim.cs | 135 +++++++++++++++++ ...zureFunctionServiceCollectionExtensions.cs | 35 ++++- ...ChocolateFunctionsHostBuilderExtensions.cs | 63 ++++++-- .../Extensions/HttpContextExtensions.cs | 84 +++++++++++ .../GraphQLAzureFunctionsConstants.cs | 10 ++ .../HotChocolate.AzureFunctions.csproj | 6 + .../HttpContextBuilder.cs | 75 ++++++++++ .../PipelineBuilder.cs | 2 +- .../Helpers/MockFunctionContext.cs | 137 ++++++++++++++++++ .../Helpers/MockHttpRequestData.cs | 83 +++++++++++ .../Helpers/MockHttpResponseData.cs | 46 ++++++ .../Helpers/MockIsolatedProcessHostBuilder.cs | 78 ++++++++++ .../Helpers/TestHttpRequestDataHelper.cs | 43 ++++++ ...zureFunctions.IsolatedProcess.Tests.csproj | 8 + .../IsolatedProcessEndToEndTests.cs | 127 ++++++++++++++++ .../IsolatedProcessHostBuilderTests.cs | 39 +++++ .../CustomBindingTests.cs | 22 +++ .../FunctionsHostBuilderTests.cs | 43 ++++++ .../Helpers/AzFuncTestHelper.cs | 19 +++ .../MockInProcessFunctionsHostBuilder.cs | 11 ++ .../Helpers/TestHttpContextHelper.cs | 60 ++++++++ .../InProcessEndToEndTests.cs | 114 +++++++++++++++ .../ServiceTests.cs | 66 ++++----- 29 files changed, 1428 insertions(+), 54 deletions(-) create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/GraphQLRequestExecutorExtensions.cs create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HotChocolateAzFuncIsolatedProcessHostBuilderExtensions.cs create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HttpRequestDataExtensions.cs create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HttpContextShim.cs create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HttpContextExtensions.cs create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/GraphQLAzureFunctionsConstants.cs create mode 100644 src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HttpContextBuilder.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockFunctionContext.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpRequestData.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpResponseData.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockIsolatedProcessHostBuilder.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/HotChocolate.AzureFunctions.IsolatedProcess.Tests.csproj create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessHostBuilderTests.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/CustomBindingTests.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/FunctionsHostBuilderTests.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/AzFuncTestHelper.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/MockInProcessFunctionsHostBuilder.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d48e5924557..28d0d9d5c01 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -45,8 +45,11 @@ net7.0; net6.0 net7.0; net6.0; net5.0; netcoreapp3.1 net7.0; net6.0 - net6.0; net5.0; netcoreapp3.1 - net6.0 + net7.0; net6.0; net5.0; netcoreapp3.1 + net7.0; net6.0 + + net7.0; net6.0; net5.0; + net7.0; net6.0 net7.0; net6.0; netstandard2.0 diff --git a/src/HotChocolate/AzureFunctions/Directory.Build.props b/src/HotChocolate/AzureFunctions/Directory.Build.props index 9ff29a7d3f6..c0a4b7dbf90 100644 --- a/src/HotChocolate/AzureFunctions/Directory.Build.props +++ b/src/HotChocolate/AzureFunctions/Directory.Build.props @@ -3,6 +3,7 @@ $(AzfTargetFrameworks) + $(AzfIsoProcTargetFrameworks) enable diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/GraphQLRequestExecutorExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/GraphQLRequestExecutorExtensions.cs new file mode 100644 index 00000000000..2437170199e --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/GraphQLRequestExecutorExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.Azure.Functions.Worker.Http; + +namespace HotChocolate.AzureFunctions.IsolatedProcess; + +public static class GraphQLRequestExecutorExtensions +{ + public static Task 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 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); + } +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HotChocolateAzFuncIsolatedProcessHostBuilderExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HotChocolateAzFuncIsolatedProcessHostBuilderExtensions.cs new file mode 100644 index 00000000000..e9b2e11e018 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HotChocolateAzFuncIsolatedProcessHostBuilderExtensions.cs @@ -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; + +/// +/// Provides DI extension methods to configure a GraphQL server. +/// +public static class HotChocolateAzFuncIsolatedProcessHostBuilderExtensions +{ + /// + /// Adds a GraphQL server and Azure Functions integration services for + /// Azure Functions Isolated processing model. + /// + /// + /// The . + /// + /// + /// The GraphQL configuration function that will be invoked, for chained + /// configuration, when the Host is built. + /// + /// + /// The max allowed GraphQL request size. + /// + /// + /// The API route that was used in the GraphQL Azure Function. + /// + /// + /// Returns the so that host configuration can be chained. + /// + /// + /// The is null. + /// + public static IHostBuilder AddGraphQLFunction( + this IHostBuilder hostBuilder, + Action 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; + } +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HttpRequestDataExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HttpRequestDataExtensions.cs new file mode 100644 index 00000000000..a2538bbed6a --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/Extensions/HttpRequestDataExtensions.cs @@ -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 ReadResponseContentAsync( + this HttpResponseData httpResponseData) + => await httpResponseData.Body.ReadStreamAsStringAsync().ConfigureAwait(false); +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj new file mode 100644 index 00000000000..bdaac831bc2 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj @@ -0,0 +1,21 @@ + + + + HotChocolate.AzureFunctions.IsolatedProcess + HotChocolate.AzureFunctions.IsolatedProcess + HotChocolate.AzureFunctions.IsolatedProcess + This package contains the GraphQL AzureFunctions Isolated Process integration for Hot Chocolate. + enable + enable + + + + + + + + + + + + diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HttpContextShim.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HttpContextShim.cs new file mode 100644 index 00000000000..eb5e2b23c89 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HttpContextShim.cs @@ -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; } + + /// + /// 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. + /// + public static Task 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 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); + } + + /// + /// Create an HttpResponseData containing the proxied response content results; + /// marshaled back from the HttpContext. + /// + /// + public async Task 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; + } + } +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs index 60b8c3b21ee..85ed3efec28 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs @@ -33,23 +33,46 @@ public static class HotChocolateAzureFunctionServiceCollectionExtensions /// 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()); + //Add the Request Executor Dependency... + services.AddAzureFunctionsGraphQLRequestExecutorDependency(apiRoute); + + return executorBuilder; + } + + /// + /// 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. + /// + public static IServiceCollection AddAzureFunctionsGraphQLRequestExecutorDependency( + this IServiceCollection services, + string apiRoute = GraphQLAzureFunctionsConstants.DefaultGraphQLRoute + ) + { services.AddSingleton(sp => { - PathString path = apiRoute.TrimEnd('/'); + PathString path = apiRoute?.TrimEnd('/'); var fileProvider = CreateFileProvider(); var options = new GraphQLServerOptions(); @@ -74,7 +97,7 @@ public static class HotChocolateAzureFunctionServiceCollectionExtensions return new DefaultGraphQLRequestExecutor(pipeline, options); }); - return executorBuilder; + return services; } /// diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateFunctionsHostBuilderExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateFunctionsHostBuilderExtensions.cs index cf1c74e8ab8..17e5089ec10 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateFunctionsHostBuilderExtensions.cs +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateFunctionsHostBuilderExtensions.cs @@ -1,7 +1,5 @@ using HotChocolate.AzureFunctions; using HotChocolate.Execution.Configuration; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Hosting; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Azure.Functions.Extensions.DependencyInjection; @@ -10,8 +8,12 @@ public static class HotChocolateFunctionsHostBuilderExtensions { /// /// Adds a GraphQL server and Azure Functions integration services. + /// This specific configuration method is only supported by the Azure Functions + /// In-process model; + /// the overload offers compatibility with the isolated process model + /// for configuration code portability. /// - /// + /// /// The . /// /// @@ -27,15 +29,54 @@ public static class HotChocolateFunctionsHostBuilderExtensions /// The is null. /// public static IRequestExecutorBuilder AddGraphQLFunction( - this IFunctionsHostBuilder builder, - int maxAllowedRequestSize = 20 * 1000 * 1000, - string apiRoute = "/api/graphql") + this IFunctionsHostBuilder hostBuilder, + int maxAllowedRequestSize = GraphQLAzureFunctionsConstants.DefaultMaxRequests, + string apiRoute = GraphQLAzureFunctionsConstants.DefaultGraphQLRoute) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + if (hostBuilder is null) + throw new ArgumentNullException(nameof(hostBuilder)); - return builder.Services.AddGraphQLFunction(maxAllowedRequestSize, apiRoute); + return hostBuilder.Services.AddGraphQLFunction(maxAllowedRequestSize, apiRoute); + } + + /// + /// Adds a GraphQL server and Azure Functions integration services in an identical + /// way as the Azure Functions Isolated processing model; providing compatibility + /// and portability of configuration code. + /// + /// + /// The . + /// + /// + /// The GraphQL Configuration function that will be invoked, for chained configuration, + /// when the Host is built. + /// + /// + /// The max allowed GraphQL request size. + /// + /// + /// The API route that was used in the GraphQL Azure Function. + /// + /// + /// Returns the so that host configuration can be chained. + /// + /// + /// The is null. + /// + public static IFunctionsHostBuilder AddGraphQLFunction( + this IFunctionsHostBuilder hostBuilder, + Action graphqlConfigureFunc, + int maxAllowedRequestSize = GraphQLAzureFunctionsConstants.DefaultMaxRequests, + string apiRoute = GraphQLAzureFunctionsConstants.DefaultGraphQLRoute + ) + { + if (graphqlConfigureFunc is null) + throw new ArgumentNullException(nameof(graphqlConfigureFunc)); + + // NOTE: HostBuilder null check will be done by AddGraphQLFunction() ... + var executorBuilder = hostBuilder.AddGraphQLFunction(maxAllowedRequestSize, apiRoute); + graphqlConfigureFunc.Invoke(executorBuilder); + + return hostBuilder; } } diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HttpContextExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HttpContextExtensions.cs new file mode 100644 index 00000000000..b7f98928a60 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HttpContextExtensions.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Extensions; + +public static class HttpContextExtensions +{ + public static IServiceProvider SetCurrentHttpContext( + this IServiceProvider serviceProvider, + HttpContext httpContext) + { + // Ensure that we enable support for HttpContext injection within HotChocolate + // (e.g. into Resolvers) for low-level access. + // NOTE: This is leveraged in Unit Tests as well as in Azure Functions + // Isolated process flow. + var httpContextAccessor = serviceProvider.GetService(); + if (httpContextAccessor != null) + httpContextAccessor.HttpContext = httpContext; + + return serviceProvider; + } + + public static async Task ReadResponseBytesAsync(this HttpContext httpContext) + { + Stream? responseStream = httpContext?.Response?.Body; + switch (responseStream) + { + case null: + return null; + case MemoryStream alreadyMemoryStream: + return alreadyMemoryStream.ToArray(); + default: + await using (var memoryStream = new MemoryStream()) + { + await responseStream.CopyToAsync(memoryStream).ConfigureAwait(false); + return memoryStream.ToArray(); + } + } + } + + public static async Task ReadStreamAsStringAsync(this Stream responseStream) + { + string? responseContent = null; + var originalPosition = responseStream.Position; + + if (responseStream.CanSeek) + responseStream.Seek(0, SeekOrigin.Begin); + + using (var responseReader = new StreamReader(responseStream)) + responseContent = await responseReader.ReadToEndAsync().ConfigureAwait(false); + + if (responseStream.CanSeek) + responseStream.Seek(originalPosition, SeekOrigin.Begin); + + return responseContent; + } + + public static async Task ReadResponseContentAsync(this HttpContext httpContext) + { + Stream? responseStream = httpContext?.Response?.Body; + return responseStream != null + ? await responseStream.ReadStreamAsStringAsync().ConfigureAwait(false) + : null; + } + + public static Uri GetAbsoluteUri(this HttpRequest httpRequest) + { + var urlBuilder = new UriBuilder(httpRequest.Scheme, httpRequest.Host.Host); + + if (httpRequest.Host.Port != null) + urlBuilder.Port = (int)httpRequest.Host.Port; + + urlBuilder.Path = httpRequest.Path.Value; + urlBuilder.Query = httpRequest.QueryString.Value; + + return urlBuilder.Uri; + } + + public static void DisposeSafely(this HttpContext httpContext) + { + httpContext?.Request?.Body?.Dispose(); + httpContext?.Response?.Body?.Dispose(); + } +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/GraphQLAzureFunctionsConstants.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/GraphQLAzureFunctionsConstants.cs new file mode 100644 index 00000000000..18b6fc32d1d --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/GraphQLAzureFunctionsConstants.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.AzureFunctions; + +public static class GraphQLAzureFunctionsConstants +{ + public const string DefaultAzFuncHttpTriggerRoute = "graphql/{**slug}"; + public const string DefaultGraphQLRoute = "/api/graphql"; + public const string DefaultJsonContentType = "application/json"; + public const string DefaultBcpContentType = "text/html"; + public const int DefaultMaxRequests = 20 * 1000 * 1000; +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj index 95d44c96f3e..490cee45df7 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj @@ -9,6 +9,12 @@ enable + + + <_Parameter1>$(AssemblyName).Tests + + + diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HttpContextBuilder.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HttpContextBuilder.cs new file mode 100644 index 00000000000..5a01d34060f --- /dev/null +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HttpContextBuilder.cs @@ -0,0 +1,75 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace HotChocolate.AzureFunctions; + +public class HttpContextBuilder +{ + public virtual HttpContext CreateHttpContext( + string requestHttpMethod, + Uri requestUri, + string? requestBody = null, + string requestBodyContentType = GraphQLAzureFunctionsConstants.DefaultJsonContentType, + HttpHeaders? requestHeaders = null, + IEnumerable? claimsIdentities = null, + IDictionary? contextItems = null + ) + { + // Initialize the root Http Context (Container)... + var httpContext = new DefaultHttpContext(); + + // Initialize the Http Request... + var httpRequest = httpContext.Request; + httpRequest.Method = requestHttpMethod ?? + throw new ArgumentNullException(nameof(requestHttpMethod)); + httpRequest.Scheme = requestUri?.Scheme ?? + throw new ArgumentNullException(nameof(requestUri)); + httpRequest.Host = new HostString(requestUri.Host, requestUri.Port); + httpRequest.Path = new PathString(requestUri.AbsolutePath); + httpRequest.QueryString = new QueryString(requestUri.Query); + + // Ensure we marshall across all Headers from the Client Request... + // Note: This should also handle Cookies since Cookies are stored as a Header value.... + if (requestHeaders?.Any() == true) + foreach (var (key, value) in requestHeaders) + httpRequest.Headers.TryAdd(key, new StringValues(value.ToArray())); + + if (!string.IsNullOrEmpty(requestBody)) + { + // Initialize a valid Stream for the Request (must be tracked & Disposed of!) + var requestBodyBytes = Encoding.UTF8.GetBytes(requestBody); + httpRequest.Body = new MemoryStream(requestBodyBytes); + httpRequest.ContentType = requestBodyContentType; + httpRequest.ContentLength = requestBodyBytes.Length; + } + + // Initialize the Http Response... + var httpResponse = httpContext.Response; + + // Initialize a valid Stream for the Response (must be tracked & Disposed of!) + // NOTE: Default Body is a NullStream... which ignores all Reads/Writes. + httpResponse.Body = new MemoryStream(); + + // Proxy over any possible authentication claims if available + var identities = claimsIdentities as ClaimsIdentity[] ?? claimsIdentities?.ToArray(); + + if (identities?.Any() == true) + { + httpContext.User = new ClaimsPrincipal(identities); + } + + // Set the Custom Context Items if specified... + if(contextItems?.Any() == true) + { + foreach (var item in contextItems) + { + httpContext.Items.TryAdd(item.Key, item.Value); + } + } + + return httpContext; + } +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/PipelineBuilder.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/PipelineBuilder.cs index 359be413321..26c3e4a4d6a 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/PipelineBuilder.cs +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/PipelineBuilder.cs @@ -139,7 +139,7 @@ public RequestDelegate Compile(IServiceProvider services) Expression requestDelegate = Expression.Call(_compileInvoke, instance, invoke); return Expression.Lambda>( - requestDelegate, _services, _next) + requestDelegate, _services, _next) .Compile(); } diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockFunctionContext.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockFunctionContext.cs new file mode 100644 index 00000000000..1be21962d01 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockFunctionContext.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Azure.Functions.Worker; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; + +/// +/// Created Based on original (internal) class form Microsoft here: +/// https://github.com/Azure/azure-functions-dotnet-worker/blob/ +/// 7ffd5c48a08b6b95a7b2e5826105e39c49194a23/src/DotNetWorker.Core/Context/DefaultFunctionContext.cs +/// +public class MockFunctionContext : FunctionContext, IDisposable +{ + public MockFunctionContext( + IServiceProvider serviceProvider, + FunctionDefinition? functionDefinition= null, + IInvocationFeatures? features = null, + TraceContext? traceContext = null, + BindingContext? bindingContext = null, + RetryContext? retryContext = null + ) + { + InstanceServices = serviceProvider; + FunctionDefinition = functionDefinition ?? new MockFunctionDefinition(); + Features = features ?? new MockInvocationFeatures(); + TraceContext = traceContext ?? new MockTraceContext("Root", "Ok"); + BindingContext = bindingContext ?? new MockBindingContext(); + RetryContext = retryContext ?? new MockRetryContext(); + } + + public override string InvocationId => nameof(MockFunctionContext); + + public override string FunctionId + => string.Concat(nameof(MockFunctionContext), "-", Guid.NewGuid()); + + public override FunctionDefinition FunctionDefinition { get; } + + public override IDictionary Items { get; set; } = + new Dictionary(); + + public override IInvocationFeatures Features { get; } + + public override IServiceProvider InstanceServices { get; set; } + + public override TraceContext TraceContext { get; } + + public override BindingContext BindingContext { get; } + + public override RetryContext RetryContext { get; } + + public virtual void Dispose() + { + } +} + +public class MockTraceContext : TraceContext +{ + public MockTraceContext(string traceParent, string traceState) + { + TraceParent = traceParent; + TraceState = traceState; + } + public override string TraceParent { get; } + public override string TraceState { get; } +} + +public class MockBindingContext : BindingContext +{ + public override IReadOnlyDictionary BindingData { get; } = + new Dictionary(); +} + +public class MockRetryContext : RetryContext +{ + public MockRetryContext(int retryCount = 0, int maxRetryCount = 0) + { + RetryCount = retryCount; + MaxRetryCount = maxRetryCount; + } + public override int RetryCount { get; } + public override int MaxRetryCount { get; } +} + +public class MockInvocationFeatures : Dictionary, IInvocationFeatures +{ + public void Set(T instance) + { + if (instance == null) + return; + + TryAdd(typeof(T), instance); + } + + public T? Get() + { + return TryGetValue(typeof(T), out var result) + ? (T)result + : default; + } +} + +public class MockFunctionDefinition : FunctionDefinition +{ + public MockFunctionDefinition() + { } + + public MockFunctionDefinition( + string id, + string name, + string pathToAssembly, + string entryPoint, + ImmutableArray parameters, + IImmutableDictionary inputBindings, + IImmutableDictionary outputBindings + ) + { + Id = id; + Name = name; + PathToAssembly = pathToAssembly; + EntryPoint = entryPoint; + Parameters = parameters; + InputBindings = inputBindings; + OutputBindings = outputBindings; + } + + public override string Id { get; } = string.Empty; + public override string Name { get; } = string.Empty; + public override string PathToAssembly { get; } = string.Empty; + public override string EntryPoint { get; } = string.Empty; + public override ImmutableArray Parameters { get; } = + ImmutableArray.Empty; + public override IImmutableDictionary InputBindings { get; } = + ImmutableDictionary.Empty; + public override IImmutableDictionary OutputBindings { get; } = + ImmutableDictionary.Empty; +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpRequestData.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpRequestData.cs new file mode 100644 index 00000000000..056e496c68e --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpRequestData.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Net.Http.Headers; +using static Microsoft.Net.Http.Headers.HeaderNames; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; + +public sealed class MockHttpRequestData : HttpRequestData, IDisposable +{ + private readonly List _cookiesList = new(); + + public MockHttpRequestData( + FunctionContext functionContext, + string requestHttpMethod, + Uri requestUri, + string? requestBody = null, + string requestBodyContentType = GraphQLAzureFunctionsConstants.DefaultJsonContentType, + HttpHeaders? requestHeaders = null, + IEnumerable? claimsIdentities = null) + : base(functionContext) + { + Method = requestHttpMethod ?? throw new ArgumentNullException(nameof(requestHttpMethod)); + Url = requestUri ?? throw new ArgumentNullException(nameof(requestUri)); + + if(claimsIdentities != null) + Identities = claimsIdentities; + + if (!string.IsNullOrEmpty(requestBody)) + { + // Initialize a valid Stream for the Request (must be tracked & Disposed of!) + var requestBodyBytes = Encoding.UTF8.GetBytes(requestBody); + Body = new MemoryStream(requestBodyBytes); + Headers.TryAddWithoutValidation(ContentType, requestBodyContentType); + Headers.TryAddWithoutValidation(ContentLength, requestBodyBytes.Length.ToString()); + } + + // Ensure we marshall across all Headers from the HttpContext provided... + if (requestHeaders?.Any() == true) + { + foreach (var (key, values) in requestHeaders) + { + Headers.TryAddWithoutValidation(key, values); + } + } + + // Because we are mocking this manually we must handle cookies explicitly... + if (Headers.TryGetValues(Cookie, out var cookieHeaders)) + { + var parsedCookieHeaders = CookieHeaderValue.ParseList(cookieHeaders.ToList()); + _cookiesList.AddRange(parsedCookieHeaders.Select( + h => new HttpCookie(h.Name.ToString(), h.Value.ToString()))); + } + } + + public override Stream Body { get; } = new MemoryStream(); + + public override HttpHeadersCollection Headers { get; } = new(); + + public override IReadOnlyCollection Cookies => _cookiesList.AsReadOnly(); + + public override Uri Url { get; } + + public override IEnumerable Identities { get; } = + Enumerable.Empty(); + + public override string Method { get; } + + public override HttpResponseData CreateResponse() => new MockHttpResponseData(FunctionContext); + + public void Dispose() + { + Body.Dispose(); + } +} + + diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpResponseData.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpResponseData.cs new file mode 100644 index 00000000000..1006ad1eb46 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockHttpResponseData.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; + +public class MockHttpResponseData : HttpResponseData, IDisposable +{ + public MockHttpResponseData(FunctionContext functionContext) + : base(functionContext) + { } + + public override HttpStatusCode StatusCode { get; set; } + + public override HttpHeadersCollection Headers { get; set; } = new(); + + public override Stream Body { get; set; } = new MemoryStream(); + + public override HttpCookies Cookies { get; } = new MockHttpResponseDataCookies(); + + public void Dispose() + { + Body.Dispose(); + } +} + +/// +/// NOTE: Since HttpCookies base class doesn't offer any way to actually read the cookies, we +/// don't even worry about populating them, we just need the Mock for basic testing... +/// +public class MockHttpResponseDataCookies : HttpCookies +{ + private readonly Dictionary _cookieJar = new(); + + public override void Append(string name, string value) + => _cookieJar.TryAdd(name, new HttpCookie(name, value)); + + public override void Append(IHttpCookie cookie) + => _cookieJar.TryAdd(cookie.Name, cookie); + + public override IHttpCookie CreateNew() + => new HttpCookie(string.Empty, string.Empty); +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockIsolatedProcessHostBuilder.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockIsolatedProcessHostBuilder.cs new file mode 100644 index 00000000000..ad75a326593 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/MockIsolatedProcessHostBuilder.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; + +internal class MockIsolatedProcessHostBuilder : IHostBuilder +{ + public MockIsolatedProcessHostBuilder() + { + Services = new ServiceCollection(); + HostBuilderContext = new HostBuilderContext(Properties); + } + + protected virtual IServiceCollection Services { get; } + + protected virtual HostBuilderContext HostBuilderContext { get; } + + public IHost Build() + => new MockIsolatedProcessHost(Services.BuildServiceProvider()); + + public IHostBuilder ConfigureAppConfiguration( + Action configureDelegate) + => DoNothing(); + + public IHostBuilder ConfigureContainer( + Action configureDelegate) + => DoNothing(); + + public IHostBuilder ConfigureHostConfiguration( + Action configureDelegate) + => DoNothing(); + + public IHostBuilder ConfigureServices( + Action configureDelegate) + { + configureDelegate.Invoke(HostBuilderContext, Services); + return this; + } + + public IHostBuilder UseServiceProviderFactory( + IServiceProviderFactory factory) + where TContainerBuilder : notnull + => DoNothing(); + + public IHostBuilder UseServiceProviderFactory( + Func> factory) + where TContainerBuilder : notnull + => DoNothing(); + + protected virtual IHostBuilder DoNothing() { return this; } //DO NOTHING; + + public IDictionary Properties { get; } = new Dictionary(); +} + +internal class MockIsolatedProcessHost : IHost +{ + public MockIsolatedProcessHost(IServiceProvider serviceProvider) + => Services = serviceProvider; + + public void Dispose() { } //DO NOTHING; + + public Task StartAsync(CancellationToken cancellationToken = default) + => DoNothingAsync(); + + public Task StopAsync(CancellationToken cancellationToken = default) + => DoNothingAsync(); + + // DO NOTHING; + protected virtual Task DoNothingAsync() + => Task.FromResult((IHost)this); + + public IServiceProvider Services { get; } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs new file mode 100644 index 00000000000..554a764d87c --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs @@ -0,0 +1,43 @@ +using System; +using HotChocolate.AzureFunctions.Tests.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Worker.Http; +using static Microsoft.Net.Http.Headers.HeaderNames; +using IO = System.IO; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; + +public class TestHttpRequestDataHelper +{ + public static HttpRequestData NewGraphQLHttpRequestData( + IServiceProvider serviceProvider, + string graphqlQuery) + { + HttpRequestData httpRequestData = new MockHttpRequestData( + new MockFunctionContext(serviceProvider), + HttpMethods.Post, + TestHttpContextHelper.DefaultAzFuncGraphQLUri, + requestBody: TestHttpContextHelper.CreateGraphQLRequestBody(graphqlQuery)); + + //Ensure we accept Json for GraphQL requests... + httpRequestData.Headers.Add(Accept, GraphQLAzureFunctionsConstants.DefaultJsonContentType); + + return httpRequestData; + } + + public static HttpRequestData NewBcpHttpRequestData( + IServiceProvider serviceProvider, + string path) + { + HttpRequestData httpRequestData = new MockHttpRequestData( + new MockFunctionContext(serviceProvider), + HttpMethods.Get, + new Uri(IO.Path.Combine(TestHttpContextHelper.DefaultAzFuncGraphQLUri.ToString(), path)) + ); + + //Ensure we accept Text/Html for BCP requests... + httpRequestData.Headers.Add(Accept, GraphQLAzureFunctionsConstants.DefaultBcpContentType); + + return httpRequestData; + } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/HotChocolate.AzureFunctions.IsolatedProcess.Tests.csproj b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/HotChocolate.AzureFunctions.IsolatedProcess.Tests.csproj new file mode 100644 index 00000000000..3c30f9f33ce --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/HotChocolate.AzureFunctions.IsolatedProcess.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs new file mode 100644 index 00000000000..8eeb462151e --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs @@ -0,0 +1,127 @@ +using System.Threading.Tasks; +using HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; +using HotChocolate.Types; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Newtonsoft.Json.Linq; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests; + +public class IsolatedProcessEndToEndTests +{ + [Fact] + public async Task AzFuncIsolatedProcess_EndToEndTestAsync() + { + var host = new MockIsolatedProcessHostBuilder() + .AddGraphQLFunction(graphQL => + { + graphQL.AddQueryType( + d => d.Name("Query").Field("person").Resolve("Luke Skywalker")); + }) + .Build(); + + // The executor should resolve without error as a Required service... + var requestExecutor = host.Services.GetRequiredService(); + + // Build an HttpRequestData that is valid for the Isolated Process to execute with... + var httpRequestData = TestHttpRequestDataHelper.NewGraphQLHttpRequestData(host.Services, @" + query { + person + } + "); + + // Execute Query Test for end-to-end validation... + // NOTE: This uses the new Az Func Isolated Process extension + // to execute via HttpRequestData... + var httpResponseData = + await requestExecutor.ExecuteAsync(httpRequestData).ConfigureAwait(false); + + // Read, Parse & Validate the response... + var resultContent = await httpResponseData.ReadResponseContentAsync().ConfigureAwait(false); + Assert.False(string.IsNullOrWhiteSpace(resultContent)); + + dynamic json = JObject.Parse(resultContent!); + Assert.Null(json.errors); + Assert.Equal("Luke Skywalker",json.data.person.ToString()); + } + + [Fact] + public async Task AzFuncIsolatedProcess_HttpContextAccessorTestAsync() + { + var host = new MockIsolatedProcessHostBuilder() + .ConfigureServices(services => + { + services.AddHttpContextAccessor(); + }) + .AddGraphQLFunction(graphQL => + { + graphQL.AddQueryType( + d => d.Name("Query") + .Field("isHttpContextInjected") + .Resolve(context => + { + var httpContext = context.Services.GetService()? + .HttpContext; + return httpContext != null; + })); + }) + .Build(); + + // The executor should resolve without error as a Required service... + var requestExecutor = host.Services.GetRequiredService(); + + // Build an HttpRequestData that is valid for the Isolated Process to execute with... + var httpRequestData = TestHttpRequestDataHelper.NewGraphQLHttpRequestData( + host.Services, + @"query { + isHttpContextInjected + }"); + + // Execute Query Test for end-to-end validation... + // NOTE: This uses the new Az Func Isolated Process extension to execute + // via HttpRequestData... + var httpResponseData = + await requestExecutor.ExecuteAsync(httpRequestData).ConfigureAwait(false); + + // Read, Parse & Validate the response... + var resultContent = + await httpResponseData.ReadResponseContentAsync().ConfigureAwait(false); + Assert.False(string.IsNullOrWhiteSpace(resultContent)); + + dynamic json = JObject.Parse(resultContent!); + Assert.Null(json.errors); + Assert.True((bool)json.data.isHttpContextInjected); + } + + [Fact] + public async Task AzFuncIsolatedProcess_BananaCakePopTestAsync() + { + var host = new MockIsolatedProcessHostBuilder() + .AddGraphQLFunction( + b => b.AddQueryType( + d => d.Name("Query").Field("person").Resolve("Luke Skywalker"))) + .Build(); + + // The executor should resolve without error as a Required service... + var requestExecutor = host.Services.GetRequiredService(); + + // Build an HttpRequestData that is valid for the Isolated Process to execute with... + var httpRequestData = + TestHttpRequestDataHelper.NewBcpHttpRequestData(host.Services, "index.html"); + + // Execute Query Test for end-to-end validation... + // NOTE: This uses the new Az Func Isolated Process extension to execute + // via HttpRequestData... + var httpResponseData = + await requestExecutor.ExecuteAsync(httpRequestData).ConfigureAwait(false); + + // Read, Parse & Validate the response... + var resultContent = await httpResponseData.ReadResponseContentAsync().ConfigureAwait(false); + Assert.NotNull(resultContent); + Assert.False(string.IsNullOrWhiteSpace(resultContent)); + Assert.True(resultContent!.Contains("")); + } +} + diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessHostBuilderTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessHostBuilderTests.cs new file mode 100644 index 00000000000..2b169fbade1 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessHostBuilderTests.cs @@ -0,0 +1,39 @@ +using System; +using HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; +using HotChocolate.Types; +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests; + +public class IsolatedProcessHostBuilderTests +{ + [Fact] + public void AzFuncIsolatedProcess_HostBuilderSetupWithPortableConfigMatchingIsolatedProcess() + { + var hostBuilder = new MockIsolatedProcessHostBuilder(); + + // Register using the config func that matches the Isolated Process configuration + // so the config code is portable... + hostBuilder.AddGraphQLFunction( + b => b.AddQueryType( + d => d.Name("Query").Field("test").Resolve("test"))); + + AssertFunctionsHostBuilderIsValid(hostBuilder); + } + + private static void AssertFunctionsHostBuilderIsValid( + MockIsolatedProcessHostBuilder hostBuilder) + { + if (hostBuilder is null) + { + throw new ArgumentNullException(nameof(hostBuilder)); + } + + var host = hostBuilder.Build(); + + // The executor should resolve without error as a Required service... + host.Services.GetRequiredService(); + } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/CustomBindingTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/CustomBindingTests.cs new file mode 100644 index 00000000000..bc60e34545c --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/CustomBindingTests.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Azure.WebJobs.Host.Config; +using static HotChocolate.AzureFunctions.Tests.Helpers.AzFuncTestHelper; + +namespace HotChocolate.AzureFunctions.Tests; + +public class CustomBindingTests +{ + [Fact] + public void AzFuncGraphQLCustomBindings_RegisterBindingConfigProvider() + { + var serviceProvider = + CreateTestServiceCollectionWithGraphQLFunction() + .BuildServiceProvider(); + + // the Binding Config Provider should resolve without error and be the expected type... + var extensionConfigProvider = + serviceProvider.GetRequiredService(); + + Assert.Equal(nameof(GraphQLExtensions), extensionConfigProvider.GetType().Name); + } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/FunctionsHostBuilderTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/FunctionsHostBuilderTests.cs new file mode 100644 index 00000000000..4857472696c --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/FunctionsHostBuilderTests.cs @@ -0,0 +1,43 @@ +using HotChocolate.AzureFunctions.Tests.Helpers; +using HotChocolate.Types; +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.AzureFunctions.Tests; + +public class FunctionsHostBuilderTests +{ + [Fact] + public void AzFuncInProcess_OriginalHostBuilderSetup() + { + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + + hostBuilder + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("test").Resolve("test")); + + AssertFunctionsHostBuilderIsValid(hostBuilder); + } + + [Fact] + public void AzFuncInProcess_HostBuilderSetupWithPortableConfigMatchingIsolatedProcess() + { + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + + // Register using the config func that matches the Isolated Process + // configuration so the config code is portable... + hostBuilder.AddGraphQLFunction( + b => b.AddQueryType( + d => d.Name("Query").Field("test").Resolve("test"))); + + AssertFunctionsHostBuilderIsValid(hostBuilder); + } + + private void AssertFunctionsHostBuilderIsValid(MockInProcessFunctionsHostBuilder hostBuilder) + { + var serviceProvider = hostBuilder.BuildServiceProvider(); + + // The executor should resolve without error as a Required service... + serviceProvider.GetRequiredService(); + } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/AzFuncTestHelper.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/AzFuncTestHelper.cs new file mode 100644 index 00000000000..2843e45cb2c --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/AzFuncTestHelper.cs @@ -0,0 +1,19 @@ +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.AzureFunctions.Tests.Helpers; + +public static class AzFuncTestHelper +{ + public static ServiceCollection CreateTestServiceCollectionWithGraphQLFunction( + string resolveValue = "test") + { + var serviceCollection = new ServiceCollection(); + + serviceCollection + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("test").Resolve(resolveValue)); + + return serviceCollection; + } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/MockInProcessFunctionsHostBuilder.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/MockInProcessFunctionsHostBuilder.cs new file mode 100644 index 00000000000..41c93dc984e --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/MockInProcessFunctionsHostBuilder.cs @@ -0,0 +1,11 @@ +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.AzureFunctions.Tests.Helpers; + +internal class MockInProcessFunctionsHostBuilder : IFunctionsHostBuilder +{ + public IServiceCollection Services { get; } = new ServiceCollection(); + + public ServiceProvider BuildServiceProvider() => Services.BuildServiceProvider(); +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs new file mode 100644 index 00000000000..6eded8af069 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs @@ -0,0 +1,60 @@ +using System; +using HotChocolate.AzureFunctions.IsolatedProcess.Extensions; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Microsoft.Net.Http.Headers; +using IO = System.IO; + +namespace HotChocolate.AzureFunctions.Tests.Helpers; + +public class TestHttpContextHelper +{ + public static Uri DefaultAzFuncGraphQLUri { get; } = new( + new Uri("https://localhost/"), + GraphQLAzureFunctionsConstants.DefaultGraphQLRoute + ); + + public static HttpContext NewGraphQLHttpContext( + IServiceProvider serviceProvider, + string graphqlQuery) + { + var httpContext = new HttpContextBuilder() + .CreateHttpContext( + HttpMethods.Post, + DefaultAzFuncGraphQLUri, + requestBody: CreateGraphQLRequestBody(graphqlQuery)); + + // Ensure we accept Json for GraphQL requests... + httpContext.Request.Headers.Add( + HeaderNames.Accept, + GraphQLAzureFunctionsConstants.DefaultJsonContentType); + + // Ensure that we enable support for HttpContext injection for Unit Tests + serviceProvider.SetCurrentHttpContext(httpContext); + + return httpContext; + } + + public static HttpContext NewBcpHttpContext(IServiceProvider serviceProvider, string path) + { + var httpContext = new HttpContextBuilder() + .CreateHttpContext( + HttpMethods.Get, + new Uri(IO.Path.Combine(DefaultAzFuncGraphQLUri.ToString(), path)), + requestBodyContentType: GraphQLAzureFunctionsConstants.DefaultBcpContentType); + + // Ensure we accept Text/Html for BCP requests... + httpContext.Request.Headers.Add( + HeaderNames.Accept, + GraphQLAzureFunctionsConstants.DefaultBcpContentType); + + // Ensure that we enable support for HttpContext injection for Unit Tests + serviceProvider.SetCurrentHttpContext(httpContext); + + return httpContext; + } + + public static string CreateGraphQLRequestBody(string graphQLQuery) + => JObject.FromObject(new { query = graphQLQuery }).ToString(Formatting.Indented); +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs new file mode 100644 index 00000000000..9a3bdb70e70 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs @@ -0,0 +1,114 @@ +using System.Threading.Tasks; +using HotChocolate.AzureFunctions.IsolatedProcess.Extensions; +using HotChocolate.AzureFunctions.Tests.Helpers; +using HotChocolate.Types; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; + +namespace HotChocolate.AzureFunctions.Tests; + +public class InProcessEndToEndTests +{ + [Fact] + public async Task AzFuncInProcess_EndToEndTestAsync() + { + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + + hostBuilder + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("person").Resolve("Luke Skywalker")); + + var serviceProvider = hostBuilder.BuildServiceProvider(); + + // The executor should resolve without error as a Required service... + var requestExecutor = serviceProvider.GetRequiredService(); + + var httpContext = TestHttpContextHelper.NewGraphQLHttpContext( + serviceProvider, + @"query { + person + }"); + + // Execute Query Test for end-to-end validation... + await requestExecutor.ExecuteAsync(httpContext.Request); + + // Read, Parse & Validate the response... + var resultContent = await httpContext.ReadResponseContentAsync(); + Assert.False(string.IsNullOrWhiteSpace(resultContent)); + + dynamic json = JObject.Parse(resultContent!); + Assert.NotNull(json.errors); + Assert.Equal("Luke Skywalker",json.data.person.ToString()); + } + + [Fact] + public async Task AzFuncInProcess_HttpContextAccessorTestAsync() + { + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + + hostBuilder.Services.AddHttpContextAccessor(); + + hostBuilder + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("isHttpContextInjected").Resolve(context => + { + var httpContext = context.Services.GetService()?.HttpContext; + return httpContext != null; + })); + + var serviceProvider = hostBuilder.BuildServiceProvider(); + + // The executor should resolve without error as a Required service... + var requestExecutor = serviceProvider.GetRequiredService(); + + var httpContext = TestHttpContextHelper.NewGraphQLHttpContext(serviceProvider, @" + query { + isHttpContextInjected + } + "); + + // Execute Query Test for end-to-end validation... + await requestExecutor.ExecuteAsync(httpContext.Request); + + // Read, Parse & Validate the response... + var resultContent = await httpContext.ReadResponseContentAsync(); + Assert.False(string.IsNullOrWhiteSpace(resultContent)); + + dynamic json = JObject.Parse(resultContent!); + Assert.Null(json.errors); + Assert.True((bool)json.data.isHttpContextInjected); + } + + [Fact] + public async Task AzFuncInProcess_BananaCakePopTestAsync() + { + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + + hostBuilder.Services.AddHttpContextAccessor(); + + hostBuilder + .AddGraphQLFunction() + .AddQueryType( + d => d.Name("Query") + .Field("BcpTest") + .Resolve("This is a test for BCP File Serving...")); + + var serviceProvider = hostBuilder.BuildServiceProvider(); + + // The executor should resolve without error as a Required service... + var requestExecutor = serviceProvider.GetRequiredService(); + + var httpContext = TestHttpContextHelper.NewBcpHttpContext(serviceProvider, "index.html"); + + // Execute Query Test for end-to-end validation... + await requestExecutor.ExecuteAsync(httpContext.Request); + + // Read, Parse & Validate the response... + var resultContent = await httpContext.ReadResponseContentAsync(); + Assert.NotNull(resultContent); + Assert.False(string.IsNullOrWhiteSpace(resultContent)); + Assert.True(resultContent!.Contains("")); + } +} diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/ServiceTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/ServiceTests.cs index ff738fbaaf9..3921731d80b 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/ServiceTests.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/ServiceTests.cs @@ -1,34 +1,32 @@ -using System; -using Microsoft.Extensions.DependencyInjection; -using HotChocolate.Types; -using Xunit; - -namespace HotChocolate.AzureFunctions.Tests; - -public class ServiceTests -{ - [Fact] - public void AddGraphQLFunction_RegisterExecutor() - { - var serviceCollection = new ServiceCollection(); - - serviceCollection - .AddGraphQLFunction() - .AddQueryType(d => d.Name("Query").Field("test").Resolve("test")); - - var serviceProvider = serviceCollection.BuildServiceProvider(); - - // the executor should resolve without error - serviceProvider.GetRequiredService(); - } - - [Fact] - public void AddGraphQLFunction_ServicesIsNull() - { - void Fail() => ((ServiceCollection)default!) - .AddGraphQLFunction() - .AddQueryType(d => d.Name("Query").Field("test").Resolve("test")); - - Assert.Throws(Fail); - } -} \ No newline at end of file +using System; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Types; +using static HotChocolate.AzureFunctions.Tests.Helpers.AzFuncTestHelper; + +namespace HotChocolate.AzureFunctions.Tests; + +public class ServiceTests +{ + [Fact] + public void AddGraphQLFunction_RegisterExecutor() + { + var serviceProvider = + CreateTestServiceCollectionWithGraphQLFunction() + .BuildServiceProvider(); + + // The executor should resolve without error as a Required service... + var requestExecutor = serviceProvider.GetRequiredService(); + + Assert.Equal(nameof(DefaultGraphQLRequestExecutor), requestExecutor.GetType().Name); + } + + [Fact] + public void AddGraphQLFunction_ServicesIsNull() + { + void Fail() => ((ServiceCollection)default!) + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("test").Resolve("test")); + + Assert.Throws(Fail); + } +}