From 9bd8a89cfaa5b3e09f989eee665ad509c03c8994 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 22 May 2024 22:02:31 +0200 Subject: [PATCH] edits --- .../DefaultGraphQLHttpClient.cs | 10 +- .../GraphQLHttpClientExtensions.cs | 262 +++++++++++++++-- .../src/Transport.Http/GraphQLHttpResponse.cs | 42 ++- .../GraphQLHttpClientConfigurationTests.cs | 6 +- .../VariableBatchRequestTests.cs | 268 +++++++++++++++++- .../Core/src/Execution/RequestExecutor.cs | 113 +++++++- 6 files changed, 657 insertions(+), 44 deletions(-) diff --git a/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs b/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs index 7ab6f559c7e..6b5985b67b3 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs @@ -119,7 +119,7 @@ public DefaultGraphQLHttpClient(HttpClient httpClient) Uri requestUri) { var method = request.Method; - + if(method == GraphQLHttpMethod.Get) { if (request.Body is not OperationRequest) @@ -263,7 +263,7 @@ private static void WriteOperationJson(ArrayWriter arrayWriter, GraphQLHttpReque throw new InvalidOperationException( HttpResources.DefaultGraphQLHttpClient_BatchNotAllowed); } - + var sb = new StringBuilder(); var appendAmpersand = false; @@ -295,8 +295,7 @@ private static void WriteOperationJson(ArrayWriter arrayWriter, GraphQLHttpReque { AppendAmpersand(sb, ref appendAmpersand); sb.Append("variables="); - sb.Append( - Uri.EscapeDataString(FormatDocumentAsJson(arrayWriter, or.VariablesNode))); + sb.Append(Uri.EscapeDataString(FormatDocumentAsJson(arrayWriter, or.VariablesNode))); } else if (or.Variables is not null) { @@ -309,8 +308,7 @@ private static void WriteOperationJson(ArrayWriter arrayWriter, GraphQLHttpReque { AppendAmpersand(sb, ref appendAmpersand); sb.Append("extensions="); - sb.Append( - Uri.EscapeDataString(FormatDocumentAsJson(arrayWriter, or.ExtensionsNode))); + sb.Append(Uri.EscapeDataString(FormatDocumentAsJson(arrayWriter, or.ExtensionsNode))); } else if (or.Extensions is not null) { diff --git a/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpClientExtensions.cs b/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpClientExtensions.cs index 227eb16b2d5..101506153a3 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpClientExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpClientExtensions.cs @@ -95,7 +95,7 @@ public static class GraphQLHttpClientExtensions ? GetAsync(client, operation, cancellationToken) : GetAsync(client, operation, uri, cancellationToken); } - + /// /// Sends a GraphQL GET request to the specified GraphQL endpoint. /// @@ -129,7 +129,7 @@ public static class GraphQLHttpClientExtensions ? GetAsync(client, operation, cancellationToken) : GetAsync(client, operation, uri, cancellationToken); } - + /// /// Sends a GraphQL GET request to the specified GraphQL endpoint. /// @@ -159,7 +159,7 @@ public static class GraphQLHttpClientExtensions ? GetAsync(client, operation, cancellationToken) : GetAsync(client, operation, uri, cancellationToken); } - + /// /// Sends a GraphQL GET request to the specified GraphQL endpoint. /// @@ -214,12 +214,12 @@ public static class GraphQLHttpClientExtensions { throw new ArgumentNullException(nameof(client)); } - + var request = new GraphQLHttpRequest(operation) { Method = GraphQLHttpMethod.Get, }; - + return client.SendAsync(request, cancellationToken); } @@ -256,12 +256,12 @@ public static class GraphQLHttpClientExtensions { throw new ArgumentNullException(nameof(uri)); } - + var request = new GraphQLHttpRequest(operation, new Uri(uri)) { Method = GraphQLHttpMethod.Get, }; - + return client.SendAsync(request, cancellationToken); } @@ -298,12 +298,12 @@ public static class GraphQLHttpClientExtensions { throw new ArgumentNullException(nameof(uri)); } - + var request = new GraphQLHttpRequest(operation, uri) { Method = GraphQLHttpMethod.Get, }; - + return client.SendAsync(request, cancellationToken); } @@ -418,7 +418,7 @@ public static class GraphQLHttpClientExtensions ? PostAsync(client, operation, cancellationToken) : PostAsync(client, operation, uri, cancellationToken); } - + /// /// Sends a GraphQL POST request to the specified GraphQL endpoint. /// @@ -452,7 +452,7 @@ public static class GraphQLHttpClientExtensions ? PostAsync(client, operation, cancellationToken) : PostAsync(client, operation, uri, cancellationToken); } - + /// /// Sends a GraphQL POST request to the specified GraphQL endpoint. /// @@ -511,11 +511,69 @@ public static class GraphQLHttpClientExtensions { throw new ArgumentNullException(nameof(client)); } - + var request = new GraphQLHttpRequest(operation) { Method = GraphQLHttpMethod.Post, }; return client.SendAsync(request, cancellationToken); } + /// + /// Sends a GraphQL POST request to the specified GraphQL endpoint. + /// + /// + /// The to send the request with. + /// + /// + /// The GraphQL variable batch request. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A representing the asynchronous operation. + /// + public static Task PostAsync( + this GraphQLHttpClient client, + VariableBatchRequest batch, + CancellationToken cancellationToken = default) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + var request = new GraphQLHttpRequest(batch) { Method = GraphQLHttpMethod.Post, }; + return client.SendAsync(request, cancellationToken); + } + + /// + /// Sends a GraphQL POST request to the specified GraphQL endpoint. + /// + /// + /// The to send the request with. + /// + /// + /// The GraphQL operation batch request. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A representing the asynchronous operation. + /// + public static Task PostAsync( + this GraphQLHttpClient client, + OperationBatchRequest batch, + CancellationToken cancellationToken = default) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + var request = new GraphQLHttpRequest(batch) { Method = GraphQLHttpMethod.Post, }; + return client.SendAsync(request, cancellationToken); + } + /// /// Sends a GraphQL POST request to the specified GraphQL endpoint. /// @@ -549,12 +607,96 @@ public static class GraphQLHttpClientExtensions { throw new ArgumentNullException(nameof(uri)); } - + var request = new GraphQLHttpRequest(operation, new Uri(uri)) { Method = GraphQLHttpMethod.Post, }; - + + return client.SendAsync(request, cancellationToken); + } + + /// + /// Sends a GraphQL POST request to the specified GraphQL endpoint. + /// + /// + /// The to send the request with. + /// + /// + /// The GraphQL variable batch request. + /// + /// + /// The GraphQL request URI. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A representing the asynchronous operation. + /// + public static Task PostAsync( + this GraphQLHttpClient client, + VariableBatchRequest batch, + string uri, + CancellationToken cancellationToken = default) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + var request = new GraphQLHttpRequest(batch, new Uri(uri)) + { + Method = GraphQLHttpMethod.Post, + }; + + return client.SendAsync(request, cancellationToken); + } + + /// + /// Sends a GraphQL POST request to the specified GraphQL endpoint. + /// + /// + /// The to send the request with. + /// + /// + /// The GraphQL operation batch request. + /// + /// + /// The GraphQL request URI. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A representing the asynchronous operation. + /// + public static Task PostAsync( + this GraphQLHttpClient client, + OperationBatchRequest batch, + string uri, + CancellationToken cancellationToken = default) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + var request = new GraphQLHttpRequest(batch, new Uri(uri)) + { + Method = GraphQLHttpMethod.Post, + }; + return client.SendAsync(request, cancellationToken); } @@ -591,12 +733,96 @@ public static class GraphQLHttpClientExtensions { throw new ArgumentNullException(nameof(uri)); } - + var request = new GraphQLHttpRequest(operation, uri) { Method = GraphQLHttpMethod.Post, }; - + return client.SendAsync(request, cancellationToken); - } -} \ No newline at end of file + } + + /// + /// Sends a GraphQL POST request to the specified GraphQL endpoint. + /// + /// + /// The to send the request with. + /// + /// + /// The GraphQL variable batch request. + /// + /// + /// The GraphQL request URI. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A representing the asynchronous operation. + /// + public static Task PostAsync( + this GraphQLHttpClient client, + VariableBatchRequest batch, + Uri uri, + CancellationToken cancellationToken = default) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + var request = new GraphQLHttpRequest(batch, uri) + { + Method = GraphQLHttpMethod.Post, + }; + + return client.SendAsync(request, cancellationToken); + } + + /// + /// Sends a GraphQL POST request to the specified GraphQL endpoint. + /// + /// + /// The to send the request with. + /// + /// + /// The GraphQL operation batch request. + /// + /// + /// The GraphQL request URI. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A representing the asynchronous operation. + /// + public static Task PostAsync( + this GraphQLHttpClient client, + OperationBatchRequest batch, + Uri uri, + CancellationToken cancellationToken = default) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + var request = new GraphQLHttpRequest(batch, uri) + { + Method = GraphQLHttpMethod.Post, + }; + + return client.SendAsync(request, cancellationToken); + } +} diff --git a/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs b/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs index dc0747a6ff1..1de962a08d8 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs @@ -6,6 +6,7 @@ using System.IO; #endif using System.Net.Http; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; #if NET6_0_OR_GREATER using System.Text; @@ -43,6 +44,16 @@ public GraphQLHttpResponse(HttpResponseMessage message) _message = message ?? throw new ArgumentNullException(nameof(message)); } + /// + /// Gets the underlying . + /// + public HttpResponseMessage HttpResponseMessage => _message; + + /// + /// Gets the HTTP response version. + /// + public Version Version => _message.Version; + /// /// Gets the HTTP response status code. /// @@ -57,12 +68,41 @@ public GraphQLHttpResponse(HttpResponseMessage message) /// Gets the reason phrase which typically is sent by servers together with the status code. /// public string? ReasonPhrase => _message.ReasonPhrase; - + /// /// Throws an exception if the HTTP response was unsuccessful. /// public void EnsureSuccessStatusCode() => _message.EnsureSuccessStatusCode(); + /// + /// Gets the collection of HTTP response headers. + /// + /// + /// The collection of HTTP response headers. + /// + public HttpResponseHeaders Headers => _message.Headers; + + /// + /// Gets the HTTP content headers as defined in RFC 2616. + /// + /// + /// The content headers as defined in RFC 2616. + /// + public HttpContentHeaders ContentHeaders => _message.Content.Headers; + + #if NET6_0_OR_GREATER + /// + /// Gets the collection of trailing headers included in an HTTP response. + /// + /// + /// PROTOCOL_ERROR: The HTTP/2 response contains pseudo-headers in the Trailing Headers Frame. + /// + /// + /// The collection of trailing headers in the HTTP response. + /// + public HttpResponseHeaders TrailingHeaders => _message.TrailingHeaders; + #endif + /// /// Reads the GraphQL response as a . /// diff --git a/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientConfigurationTests.cs b/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientConfigurationTests.cs index a12cd23f40a..9428add03ee 100644 --- a/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientConfigurationTests.cs +++ b/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientConfigurationTests.cs @@ -25,9 +25,11 @@ public async Task DefaultRequestVersion() await client.SendAsync(new("{ __typename }", new(CreateUrl(default))), default); } - class TestHttpMessageHandler(Func sender) : HttpMessageHandler + internal class TestHttpMessageHandler(Func sender) : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) { return Task.FromResult(sender.Invoke(request)); } diff --git a/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/VariableBatchRequestTests.cs b/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/VariableBatchRequestTests.cs index 50c217e6db2..4562a0cc1aa 100644 --- a/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/VariableBatchRequestTests.cs +++ b/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/VariableBatchRequestTests.cs @@ -1,10 +1,12 @@ using System.Text; using System.Text.Json; using CookieCrumble; +using HotChocolate.AspNetCore.Tests.Utilities; +using static HotChocolate.AspNetCore.Tests.Utilities.TestServerExtensions; -namespace HotChocolate.Transport.Http.Tests; +namespace HotChocolate.Transport.Http; -public class VariableBatchRequestTests +public class VariableBatchRequestTestss(TestServerFactory serverFactory) : ServerTestBase(serverFactory) { [Fact] public async Task Should_WriteNullValues() @@ -14,7 +16,7 @@ public async Task Should_WriteNullValues() null, "abc", "myOperation", - variables: + variables: [ new Dictionary { @@ -36,8 +38,262 @@ public async Task Should_WriteNullValues() await writer.FlushAsync(); // assert - var result = Encoding.UTF8.GetString(memory.ToArray()); + var result = JsonDocument.Parse(Encoding.UTF8.GetString(memory.ToArray())).RootElement; result.MatchInlineSnapshot( - """{"id":"abc","operationName":"myOperation","variables":[{"abc":"def","hij":null},{"abc":"xyz","hij":null}]}"""); + """ + { + "id": "abc", + "operationName": "myOperation", + "variables": [ + { + "abc": "def", + "hij": null + }, + { + "abc": "xyz", + "hij": null + } + ] + } + """); + } + + [Fact] + public async Task Post_Variable_Batch() + { + // arrange + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var testServer = CreateStarWarsServer(); + var httpClient = testServer.CreateClient(); + var client = new DefaultGraphQLHttpClient(httpClient); + + var query = + """ + query($episode: Episode!) { + hero(episode: $episode) { + name + } + } + """; + + var variables1 = new Dictionary + { + ["episode"] = "JEDI", + }; + + var variables2 = new Dictionary + { + ["episode"] = "EMPIRE", + }; + + var requestUri = new Uri(CreateUrl("/graphql")); + + // act + var request = new VariableBatchRequest( + query, + variables: new[] { variables1, variables2 }); + + var response = await client.PostAsync(request, requestUri, cts.Token); + + // assert + var snapshot = new Snapshot(); + + await foreach(var result in response.ReadAsResultStreamAsync(cts.Token)) + { + snapshot.Add(result); + } + + snapshot.MatchInline( + """ + --------------- + VariableIndex: 0 + Data: {"hero":{"name":"R2-D2"}} + --------------- + + --------------- + VariableIndex: 1 + Data: {"hero":{"name":"Luke Skywalker"}} + --------------- + + """); + } + + [Fact] + public async Task Post_Request_With_Nested_Variable_Batch() + { + // arrange + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1000)); + using var testServer = CreateStarWarsServer(); + var httpClient = testServer.CreateClient(); + var client = new DefaultGraphQLHttpClient(httpClient); + + var query = + """ + query($episode: Episode!) { + hero(episode: $episode) { + name + } + } + """; + + var variables1 = new Dictionary + { + ["episode"] = "JEDI", + }; + + var variables2 = new Dictionary + { + ["episode"] = "EMPIRE", + }; + + var requestUri = new Uri(CreateUrl("/graphql")); + + // act + var nestedVariableBatchRequest = new VariableBatchRequest( + query, + variables: new[] { variables1, variables2 }); + + var nestedSingleRequest = new OperationRequest( + """ + { + __typename + } + """); + + var batch = new OperationBatchRequest([nestedVariableBatchRequest, nestedSingleRequest]); + + var response = await client.PostAsync(batch, requestUri, cts.Token); + + // assert + response.EnsureSuccessStatusCode(); + + var sortedResults = new SortedList<(int?, int?), OperationResult>(); + + await foreach(var result in response.ReadAsResultStreamAsync(cts.Token)) + { + sortedResults.Add((result.RequestIndex, result.VariableIndex), result); + } + + var snapshot = new Snapshot(); + + foreach (var item in sortedResults.Values) + { + snapshot.Add(item); + } + + snapshot.MatchInline( + """ + --------------- + RequestIndex: 0 + VariableIndex: 0 + Data: {"hero":{"name":"R2-D2"}} + --------------- + + --------------- + RequestIndex: 0 + VariableIndex: 1 + Data: {"hero":{"name":"Luke Skywalker"}} + --------------- + + --------------- + RequestIndex: 1 + Data: {"__typename":"Query"} + --------------- + + """); + } + + [Fact] + public async Task Post_Request_Batch() + { + // arrange + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1000)); + using var testServer = CreateStarWarsServer(); + var httpClient = testServer.CreateClient(); + var client = new DefaultGraphQLHttpClient(httpClient); + + var query = + """ + query($episode: Episode!) { + hero(episode: $episode) { + name + } + } + """; + + var variables1 = new Dictionary + { + ["episode"] = "JEDI", + }; + + var variables2 = new Dictionary + { + ["episode"] = "EMPIRE", + }; + + var requestUri = new Uri(CreateUrl("/graphql")); + + // act + var request1 = new OperationRequest( + query, + variables: variables1); + + var request2 = new OperationRequest( + query, + variables: variables2); + + var request3 = new OperationRequest( + """ + { + __typename + } + """); + + var batch = new OperationBatchRequest([request1, request2, request3]); + + var response = await client.PostAsync(batch, requestUri, cts.Token); + + // assert + response.EnsureSuccessStatusCode(); + + var sortedResults = new SortedList<(int?, int?), OperationResult>(); + + await foreach(var result in response.ReadAsResultStreamAsync(cts.Token)) + { + sortedResults.Add((result.RequestIndex, result.VariableIndex), result); + } + + var snapshot = new Snapshot(); + + snapshot.Add(response.ContentHeaders.ContentType?.ToString(), "ContentType"); + + foreach (var item in sortedResults.Values) + { + snapshot.Add(item); + } + + snapshot.MatchInline( + """ + ContentType + --------------- + text/event-stream; charset=utf-8 + --------------- + + --------------- + RequestIndex: 0 + Data: {"hero":{"name":"R2-D2"}} + --------------- + + --------------- + RequestIndex: 1 + Data: {"hero":{"name":"Luke Skywalker"}} + --------------- + + --------------- + RequestIndex: 2 + Data: {"__typename":"Query"} + --------------- + + """); } -} \ No newline at end of file +} diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutor.cs b/src/HotChocolate/Core/src/Execution/RequestExecutor.cs index 2907fc1e3f2..e6400bffac2 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutor.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutor.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; @@ -181,32 +183,121 @@ static void CollectEnricher(IServiceProvider services, List[requestCount]; + var tasks = new List(requestCount); + + var completed = new ConcurrentStack(); for (var i = 0; i < requestCount; i++) { - tasks[i] = ExecuteAsync(requests[i], false, i, cancellationToken); + tasks.Add(ExecuteBatchItemAsync(requests[i], i, completed, cancellationToken)); } - for (var i = 0; i < requestCount; i++) + var buffer = new IOperationResult[8]; + int bufferCount; + + do { - var task = tasks[i]; + bufferCount = completed.TryPopRange(buffer); - if (task.Status is TaskStatus.RanToCompletion) + for (var i = 0; i < bufferCount; i++) { - yield return task.Result.ExpectQueryResult(); + yield return buffer[i]; } - else + + if (bufferCount == 0) { - var result = await task.ConfigureAwait(false); - yield return result.ExpectQueryResult(); + if(tasks.Any(t => !t.IsCompleted)) + { + var task = await Task.WhenAny(tasks); + + if (task.Status is not TaskStatus.RanToCompletion) + { + // we await to throw if its not successful. + await task; + } + + tasks.Remove(task); + } + else + { + foreach (var task in tasks) + { + if (task.Status is not TaskStatus.RanToCompletion) + { + // we await to throw if it's not successful. + await task; + } + } + + tasks.Clear(); + } } } + while (tasks.Count > 0 || bufferCount > 0); + } + + private async Task ExecuteBatchItemAsync( + IOperationRequest request, + int requestIndex, + ConcurrentStack completed, + CancellationToken cancellationToken) + { + var result = await ExecuteAsync(request, false, requestIndex, cancellationToken).ConfigureAwait(false); + await UnwrapBatchItemResultAsync(result, completed, cancellationToken); + } + + private static async Task UnwrapBatchItemResultAsync( + IExecutionResult result, + ConcurrentStack completed, + CancellationToken cancellationToken) + { + switch (result) + { + case OperationResult singleResult: + completed.Push(singleResult); + break; + + case IResponseStream stream: + { + await foreach (var item in stream.ReadResultsAsync().WithCancellation(cancellationToken)) + { + completed.Push(item); + } + + break; + } + + case OperationResultBatch resultBatch: + { + List? tasks = null; + foreach (var item in resultBatch.Results) + { + if (item is OperationResult singleItem) + { + completed.Push(singleItem); + } + else + { + (tasks ??= []).Add(UnwrapBatchItemResultAsync(item, completed, cancellationToken)); + } + } + + if (tasks is not null) + { + await Task.WhenAll(tasks); + } + + break; + } + + default: + throw new InvalidOperationException(); + } } private void EnrichContext(IRequestContext context) @@ -233,4 +324,4 @@ private void EnrichContext(IRequestContext context) #pragma warning restore CS8619 } } -} \ No newline at end of file +}