From 9f842be1004d434427186988e5ab6e082617f79b Mon Sep 17 00:00:00 2001 From: Ivan Maximov Date: Mon, 24 Apr 2023 19:59:00 +0300 Subject: [PATCH 01/18] Add APQ support --- src/GraphQL.Client/GraphQL.Client.csproj | 4 ++ src/GraphQL.Client/GraphQLHttpClient.cs | 63 ++++++++++++++++++- .../GraphQLHttpClientOptions.cs | 9 ++- src/GraphQL.Client/Hash.cs | 44 +++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/GraphQL.Client/Hash.cs diff --git a/src/GraphQL.Client/GraphQL.Client.csproj b/src/GraphQL.Client/GraphQL.Client.csproj index 19a2bb7d..946e6b41 100644 --- a/src/GraphQL.Client/GraphQL.Client.csproj +++ b/src/GraphQL.Client/GraphQL.Client.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 10076b8a..3ec2d76e 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -18,6 +18,11 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable private readonly bool _disposeHttpClient = false; + /// + /// This flag is used to completely disable APQ when GraphQL server does not support it. + /// + private bool _useAPQ = true; + /// /// the json serializer /// @@ -89,7 +94,7 @@ public async Task> SendQueryAsync(GraphQLR { return Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme() ? await GraphQlHttpWebSocket.SendRequestAsync(request, cancellationToken).ConfigureAwait(false) - : await SendHttpRequestAsync(request, cancellationToken).ConfigureAwait(false); + : await SendAPQHttpRequestAsync(request, cancellationToken).ConfigureAwait(false); } /// @@ -124,6 +129,62 @@ public IObservable> CreateSubscriptionStream> SendAPQHttpRequestAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var savedQuery = request.Query; + bool useAPQ = false; + + if (request.Query != null && _useAPQ && Options.EnableAutomaticPersistedQueries(request)) + { + // https://www.apollographql.com/docs/react/api/link/persisted-queries/ + const int APQ_SUPPORTED_VERSION = 1; + useAPQ = true; + // TODO: I suggest to change request.Extension type to public Dictionary? + //request.Extensions ??= new(); + //request.Extensions["persistedQuery"] = new Dictionary + //{ + // ["version"] = APQ_SUPPORTED_VERSION, + // ["sha256Hash"] = Hash.Compute(request.Query), + //}; + request.Extensions ??= new + { + version = APQ_SUPPORTED_VERSION, + sha256Hash = Hash.Compute(request.Query), + }; + request.Query = null; + } + + var response = await SendHttpRequestAsync(request, cancellationToken); + + if (useAPQ && response.Errors?.Length > 0) + { + if (response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotFound", StringComparison.CurrentCultureIgnoreCase))) + { + // GraphQL server supports APQ! + + // Alas, for the first time we did not guess and in vain removed Query, so we return Query and + // send request again. This is one-time "cache miss", not so scary. By the way, after a second call, + // there is a probability of receiving PERSISTED_QUERY_NOT_FOUND error again, but it is so ghostly + // that this can and should be neglect so not to complicate the code without the need. + request.Query = savedQuery; + return await SendHttpRequestAsync(request, cancellationToken); + } + else if (response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported") || + response.Errors.Any(error => string.Equals(error.Message, "GraphQL query is missing.")))) // GraphQL.NET specific error message + { + // GraphQL server either supports APQ of some other version, or does not support it at all. + // Send a request for the second time. This is better than returning an error. Let the client work with APQ disabled. + _useAPQ = false; + request.Query = savedQuery; + return await SendHttpRequestAsync(request, cancellationToken); + } + } + + return response; + } + private async Task> SendHttpRequestAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { var preprocessedRequest = await Options.PreprocessRequest(request, this).ConfigureAwait(false); diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index 6c3b30d1..9192d8fa 100644 --- a/src/GraphQL.Client/GraphQLHttpClientOptions.cs +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -25,7 +25,7 @@ public class GraphQLHttpClientOptions public Uri? WebSocketEndPoint { get; set; } /// - /// The GraphQL websocket protocol to be used. Defaults to the older "graphql-ws" protocol to not break old code. + /// The GraphQL websocket protocol to be used. Defaults to the older "graphql-ws" protocol to not break old code. /// public string? WebSocketProtocol { get; set; } = WebSocketProtocols.AUTO_NEGOTIATE; @@ -99,4 +99,11 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r) /// public ProductInfoHeaderValue? DefaultUserAgentRequestHeader { get; set; } = new ProductInfoHeaderValue(typeof(GraphQLHttpClient).Assembly.GetName().Name, typeof(GraphQLHttpClient).Assembly.GetName().Version.ToString()); + + /// + /// Delegate permitting use of Automatic Persisted Queries (APQ). + /// By default returns for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely + /// after an unsuccessful attempt to send an APQ request and then send only regular requests. + /// + public Func EnableAutomaticPersistedQueries { get; set; } = _ => true; } diff --git a/src/GraphQL.Client/Hash.cs b/src/GraphQL.Client/Hash.cs new file mode 100644 index 00000000..ae0cda85 --- /dev/null +++ b/src/GraphQL.Client/Hash.cs @@ -0,0 +1,44 @@ +using System.Buffers; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace GraphQL.Client.Http; + +internal static class Hash +{ + private static SHA256? _sha256; + + internal static string Compute(string query) + { + var expected = Encoding.UTF8.GetByteCount(query); + var inputBytes = ArrayPool.Shared.Rent(expected); + var written = Encoding.UTF8.GetBytes(query, 0, query.Length, inputBytes, 0); + Debug.Assert(written == expected, $"Encoding.UTF8.GetBytes returned unexpected bytes: {written} instead of {expected}"); + + var shaShared = Interlocked.Exchange(ref _sha256, null) ?? SHA256.Create(); + +#if NET5_0_OR_GREATER + Span bytes = stackalloc byte[32]; + if (!shaShared.TryComputeHash(inputBytes.AsSpan().Slice(0, written), bytes, out int bytesWritten)) // bytesWritten ignored since it is always 32 + throw new InvalidOperationException("Too small buffer for hash"); +#else + byte[] bytes = shaShared.ComputeHash(inputBytes, 0, written); +#endif + + ArrayPool.Shared.Return(inputBytes); + Interlocked.CompareExchange(ref _sha256, shaShared, null); + +#if NET5_0_OR_GREATER + return Convert.ToHexString(bytes); +#else + var builder = new StringBuilder(bytes.Length * 2); + foreach (var item in bytes) + { + builder.Append(item.ToString("x2")); + } + + return builder.ToString(); +#endif + } +} From ed549152eef003eea14d7eb6961d63b216ca5784 Mon Sep 17 00:00:00 2001 From: Ivan Maximov Date: Tue, 25 Apr 2023 16:31:01 +0300 Subject: [PATCH 02/18] changes --- src/GraphQL.Client/GraphQLHttpClient.cs | 8 ++++++-- src/GraphQL.Client/GraphQLHttpClientOptions.cs | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 3ec2d76e..98ebfb41 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -150,9 +150,13 @@ private async Task> SendAPQHttpRequestAsync /// Delegate permitting use of Automatic Persisted Queries (APQ). - /// By default returns for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely + /// By default returns for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely /// after an unsuccessful attempt to send an APQ request and then send only regular requests. /// - public Func EnableAutomaticPersistedQueries { get; set; } = _ => true; + public Func EnableAutomaticPersistedQueries { get; set; } = _ => false; } From 57920cd6b6f97cddf0989585ac9485379c3cc473 Mon Sep 17 00:00:00 2001 From: Ivan Maximov Date: Tue, 25 Apr 2023 16:51:54 +0300 Subject: [PATCH 03/18] rem --- src/GraphQL.Client/GraphQLHttpClient.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 98ebfb41..1796afd3 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -169,9 +169,7 @@ private async Task> SendAPQHttpRequestAsync(request, cancellationToken); } From b25557cb7189f6ca6fbad9d1103ab1626731fe0e Mon Sep 17 00:00:00 2001 From: Ivan Maximov Date: Tue, 25 Apr 2023 17:12:16 +0300 Subject: [PATCH 04/18] note --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index fe859ced..1838956e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ The Library will try to follow the following standards and documents: ## Usage +The intended use of `GraphQLHttpClient` is to keep one instance alive per endpoint (obvious in case you're +operating full websocket, but also true for regular requests) and is built with thread-safety in mind. + ### Create a GraphQLHttpClient ```csharp From 058d2e646e915588c9bd7595bfc78035143a333b Mon Sep 17 00:00:00 2001 From: Ivan Maximov Date: Wed, 26 Apr 2023 11:26:55 +0300 Subject: [PATCH 05/18] progress --- src/GraphQL.Client/GraphQLHttpClient.cs | 27 ++++++------------- .../GraphQLHttpClientOptions.cs | 10 +++++++ src/GraphQL.Client/GraphQLHttpResponse.cs | 9 ++++++- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 1796afd3..584bd65a 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -21,7 +21,7 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable /// /// This flag is used to completely disable APQ when GraphQL server does not support it. /// - private bool _useAPQ = true; + private bool _APQdisabledPerSession; /// /// the json serializer @@ -136,27 +136,17 @@ private async Task> SendAPQHttpRequestAsync? - //request.Extensions ??= new(); - //request.Extensions["persistedQuery"] = new Dictionary - //{ - // ["version"] = APQ_SUPPORTED_VERSION, - // ["sha256Hash"] = Hash.Compute(request.Query), - //}; - request.Extensions ??= new + request.Extensions ??= new(); + request.Extensions["persistedQuery"] = new Dictionary { - persistedQuery = new - { - version = APQ_SUPPORTED_VERSION, - sha256Hash = Hash.Compute(request.Query), - } + ["version"] = APQ_SUPPORTED_VERSION, + ["sha256Hash"] = Hash.Compute(request.Query), }; - request.Query = null; } @@ -173,12 +163,11 @@ private async Task> SendAPQHttpRequestAsync(request, cancellationToken); } - else if (response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported") || - response.Errors.Any(error => string.Equals(error.Message, "GraphQL query is missing.")))) // GraphQL.NET specific error message + else { // GraphQL server either supports APQ of some other version, or does not support it at all. // Send a request for the second time. This is better than returning an error. Let the client work with APQ disabled. - _useAPQ = false; + _APQdisabledPerSession = Options.DisableAPQ(response); request.Query = savedQuery; return await SendHttpRequestAsync(request, cancellationToken); } diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index 4bffd5b0..2544847c 100644 --- a/src/GraphQL.Client/GraphQLHttpClientOptions.cs +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -106,4 +106,14 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r) /// after an unsuccessful attempt to send an APQ request and then send only regular requests. /// public Func EnableAutomaticPersistedQueries { get; set; } = _ => false; + + /// + /// A delegate which takes an and returns a boolean to disable any future persisted queries for that session. + /// This defaults to disabling on PersistedQueryNotSupported or a 400 or 500 HTTP error. + /// + public Func DisableAPQ { get; set; } = response => + { + return ((int)response.StatusCode >= 400 && (int)response.StatusCode < 600) || + response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported")); + }; } diff --git a/src/GraphQL.Client/GraphQLHttpResponse.cs b/src/GraphQL.Client/GraphQLHttpResponse.cs index cc676851..8b4f53ba 100644 --- a/src/GraphQL.Client/GraphQLHttpResponse.cs +++ b/src/GraphQL.Client/GraphQLHttpResponse.cs @@ -3,7 +3,7 @@ namespace GraphQL.Client.Http; -public class GraphQLHttpResponse : GraphQLResponse +public class GraphQLHttpResponse : GraphQLResponse, IGraphQLHttpResponse { public GraphQLHttpResponse(GraphQLResponse response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode) { @@ -19,6 +19,13 @@ public GraphQLHttpResponse(GraphQLResponse response, HttpResponseHeaders resp public HttpStatusCode StatusCode { get; set; } } +public interface IGraphQLHttpResponse : IGraphQLResponse +{ + HttpResponseHeaders ResponseHeaders { get; set; } + + HttpStatusCode StatusCode { get; set; } +} + public static class GraphQLResponseExtensions { public static GraphQLHttpResponse ToGraphQLHttpResponse(this GraphQLResponse response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode) => new(response, responseHeaders, statusCode); From cf7c7c07198fccc4c603a37098ede88ab9aaf406 Mon Sep 17 00:00:00 2001 From: Ivan Maximov Date: Wed, 26 Apr 2023 11:33:16 +0300 Subject: [PATCH 06/18] progress --- src/GraphQL.Client/GraphQLHttpClient.cs | 4 ++-- src/GraphQL.Client/GraphQLHttpClientOptions.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 584bd65a..af63a5ec 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -152,9 +152,9 @@ private async Task> SendAPQHttpRequestAsync(request, cancellationToken); - if (useAPQ && response.Errors?.Length > 0) + if (useAPQ) { - if (response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotFound", StringComparison.CurrentCultureIgnoreCase))) + if (response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotFound", StringComparison.CurrentCultureIgnoreCase)) == true) { // GraphQL server supports APQ! diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index 2544847c..31d55960 100644 --- a/src/GraphQL.Client/GraphQLHttpClientOptions.cs +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -114,6 +114,6 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r) public Func DisableAPQ { get; set; } = response => { return ((int)response.StatusCode >= 400 && (int)response.StatusCode < 600) || - response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported")); + response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported", StringComparison.CurrentCultureIgnoreCase)) == true; }; } From d199fceb8e78bc0855e1c490361cf2206cbb4c83 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Mon, 22 Apr 2024 17:42:23 +0200 Subject: [PATCH 07/18] fix variable name --- GraphQL.Client.sln.DotSettings | 1 + src/GraphQL.Client/GraphQLHttpClient.cs | 6 +++--- src/GraphQL.Client/GraphQLHttpClientOptions.cs | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/GraphQL.Client.sln.DotSettings b/GraphQL.Client.sln.DotSettings index 9e5ec22f..230ed27f 100644 --- a/GraphQL.Client.sln.DotSettings +++ b/GraphQL.Client.sln.DotSettings @@ -1,2 +1,3 @@  + APQ QL \ No newline at end of file diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index af63a5ec..c925f1af 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -21,7 +21,7 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable /// /// This flag is used to completely disable APQ when GraphQL server does not support it. /// - private bool _APQdisabledPerSession; + private bool _apqDisabledPerSession; /// /// the json serializer @@ -136,7 +136,7 @@ private async Task> SendAPQHttpRequestAsync> SendAPQHttpRequestAsync(request, cancellationToken); } diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index 31d55960..e1804845 100644 --- a/src/GraphQL.Client/GraphQLHttpClientOptions.cs +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -102,7 +102,7 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r) /// /// Delegate permitting use of Automatic Persisted Queries (APQ). - /// By default returns for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely + /// By default, returns for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely /// after an unsuccessful attempt to send an APQ request and then send only regular requests. /// public Func EnableAutomaticPersistedQueries { get; set; } = _ => false; From bbdf13c2421459b8ad4676ee44bf94f1940032cb Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Mon, 22 Apr 2024 17:55:31 +0200 Subject: [PATCH 08/18] move APQ code to SendQueryAsync method to allow usage over websocket, too --- src/GraphQL.Client/GraphQLHttpClient.cs | 86 +++++++++---------- .../GraphQLHttpClientOptions.cs | 6 +- src/GraphQL.Primitives/GraphQLRequest.cs | 2 +- 3 files changed, 46 insertions(+), 48 deletions(-) diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index c925f1af..18d656ea 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -89,57 +89,19 @@ public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serial #region IGraphQLClient - /// - public async Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) - { - return Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme() - ? await GraphQlHttpWebSocket.SendRequestAsync(request, cancellationToken).ConfigureAwait(false) - : await SendAPQHttpRequestAsync(request, cancellationToken).ConfigureAwait(false); - } - - /// - public Task> SendMutationAsync(GraphQLRequest request, - CancellationToken cancellationToken = default) - => SendQueryAsync(request, cancellationToken); - - /// - public IObservable> CreateSubscriptionStream(GraphQLRequest request) - => CreateSubscriptionStream(request, null); + private const int APQ_SUPPORTED_VERSION = 1; /// - public IObservable> CreateSubscriptionStream(GraphQLRequest request, Action? exceptionHandler) - { - if (_disposed) - throw new ObjectDisposedException(nameof(GraphQLHttpClient)); - - var observable = GraphQlHttpWebSocket.CreateSubscriptionStream(request, exceptionHandler); - return observable; - } - - #endregion - - /// - public Task InitializeWebsocketConnection() => GraphQlHttpWebSocket.InitializeWebSocket(); - - /// - public Task SendPingAsync(object? payload) => GraphQlHttpWebSocket.SendPingAsync(payload); - - /// - public Task SendPongAsync(object? payload) => GraphQlHttpWebSocket.SendPongAsync(payload); - - #region Private Methods - - private async Task> SendAPQHttpRequestAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + public async Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var savedQuery = request.Query; + string? savedQuery = request.Query; bool useAPQ = false; if (request.Query != null && !_apqDisabledPerSession && Options.EnableAutomaticPersistedQueries(request)) { // https://www.apollographql.com/docs/react/api/link/persisted-queries/ - const int APQ_SUPPORTED_VERSION = 1; useAPQ = true; request.Extensions ??= new(); request.Extensions["persistedQuery"] = new Dictionary @@ -150,7 +112,7 @@ private async Task> SendAPQHttpRequestAsync(request, cancellationToken); + var response = await SendQueryInternalAsync(request, cancellationToken); if (useAPQ) { @@ -161,7 +123,7 @@ private async Task> SendAPQHttpRequestAsync(request, cancellationToken); + return await SendQueryInternalAsync(request, cancellationToken); } else { @@ -169,13 +131,49 @@ private async Task> SendAPQHttpRequestAsync(request, cancellationToken); + return await SendQueryInternalAsync(request, cancellationToken); } } return response; } + /// + public Task> SendMutationAsync(GraphQLRequest request, + CancellationToken cancellationToken = default) + => SendQueryAsync(request, cancellationToken); + + /// + public IObservable> CreateSubscriptionStream(GraphQLRequest request) + => CreateSubscriptionStream(request, null); + + /// + public IObservable> CreateSubscriptionStream(GraphQLRequest request, Action? exceptionHandler) + { + if (_disposed) + throw new ObjectDisposedException(nameof(GraphQLHttpClient)); + + var observable = GraphQlHttpWebSocket.CreateSubscriptionStream(request, exceptionHandler); + return observable; + } + + #endregion + + /// + public Task InitializeWebsocketConnection() => GraphQlHttpWebSocket.InitializeWebSocket(); + + /// + public Task SendPingAsync(object? payload) => GraphQlHttpWebSocket.SendPingAsync(payload); + + /// + public Task SendPongAsync(object? payload) => GraphQlHttpWebSocket.SendPongAsync(payload); + + #region Private Methods + private async Task> SendQueryInternalAsync(GraphQLRequest request, CancellationToken cancellationToken = default) => + Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme() + ? await GraphQlHttpWebSocket.SendRequestAsync(request, cancellationToken).ConfigureAwait(false) + : await SendHttpRequestAsync(request, cancellationToken).ConfigureAwait(false); + private async Task> SendHttpRequestAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { var preprocessedRequest = await Options.PreprocessRequest(request, this).ConfigureAwait(false); diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index e1804845..eda0d024 100644 --- a/src/GraphQL.Client/GraphQLHttpClientOptions.cs +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -111,9 +111,9 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r) /// A delegate which takes an and returns a boolean to disable any future persisted queries for that session. /// This defaults to disabling on PersistedQueryNotSupported or a 400 or 500 HTTP error. /// - public Func DisableAPQ { get; set; } = response => + public Func DisableAPQ { get; set; } = response => { - return ((int)response.StatusCode >= 400 && (int)response.StatusCode < 600) || - response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported", StringComparison.CurrentCultureIgnoreCase)) == true; + return response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported", StringComparison.CurrentCultureIgnoreCase)) == true + || response is IGraphQLHttpResponse httpResponse && (int)httpResponse.StatusCode >= 400 && (int)httpResponse.StatusCode < 600; }; } diff --git a/src/GraphQL.Primitives/GraphQLRequest.cs b/src/GraphQL.Primitives/GraphQLRequest.cs index c208a90f..d931c3ed 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -16,7 +16,7 @@ public class GraphQLRequest : Dictionary, IEquatable [StringSyntax("GraphQL")] - public string Query + public string? Query { get => TryGetValue(QUERY_KEY, out object value) ? (string)value : null; set => this[QUERY_KEY] = value; From 5429f64fc120e8887ea377d8534e08a2043fdb72 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Mon, 22 Apr 2024 18:13:58 +0200 Subject: [PATCH 09/18] make the APQDisabledForSession flag public (helps for testing) --- src/GraphQL.Client/GraphQLHttpClient.cs | 16 ++++++++-------- tests/IntegrationTestServer/Startup.cs | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 18d656ea..e157662f 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -17,12 +17,6 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly bool _disposeHttpClient = false; - - /// - /// This flag is used to completely disable APQ when GraphQL server does not support it. - /// - private bool _apqDisabledPerSession; - /// /// the json serializer /// @@ -38,6 +32,12 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable /// public GraphQLHttpClientOptions Options { get; } + /// + /// This flag is set to when an error has occurred on an APQ and + /// has returned . To reset this, the instance of has to be disposed and a new one must be created. + /// + public bool APQDisabledForSession { get; private set; } + /// public IObservable WebSocketReceiveErrors => GraphQlHttpWebSocket.ReceiveErrors; @@ -99,7 +99,7 @@ public async Task> SendQueryAsync(GraphQLR string? savedQuery = request.Query; bool useAPQ = false; - if (request.Query != null && !_apqDisabledPerSession && Options.EnableAutomaticPersistedQueries(request)) + if (request.Query != null && !APQDisabledForSession && Options.EnableAutomaticPersistedQueries(request)) { // https://www.apollographql.com/docs/react/api/link/persisted-queries/ useAPQ = true; @@ -129,7 +129,7 @@ public async Task> SendQueryAsync(GraphQLR { // GraphQL server either supports APQ of some other version, or does not support it at all. // Send a request for the second time. This is better than returning an error. Let the client work with APQ disabled. - _apqDisabledPerSession = Options.DisableAPQ(response); + APQDisabledForSession = Options.DisableAPQ(response); request.Query = savedQuery; return await SendQueryInternalAsync(request, cancellationToken); } diff --git a/tests/IntegrationTestServer/Startup.cs b/tests/IntegrationTestServer/Startup.cs index ee8526c1..1b671dfc 100644 --- a/tests/IntegrationTestServer/Startup.cs +++ b/tests/IntegrationTestServer/Startup.cs @@ -38,6 +38,7 @@ public void ConfigureServices(IServiceCollection services) }) .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = Environment.IsDevelopment()) .AddSystemTextJson() + .UseAutomaticPersistedQueries() .AddGraphTypes(typeof(ChatSchema).Assembly)); } From 97312ee1f6de706af11807a7c7ecca0ab6a98ec8 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Mon, 22 Apr 2024 18:37:37 +0200 Subject: [PATCH 10/18] create a test that uses the APQ feature --- .../APQ/APQViaHttpRequests.cs | 54 +++++++++++++++++++ tests/IntegrationTestServer/Startup.cs | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs diff --git a/tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs b/tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs new file mode 100644 index 00000000..5d31f1d5 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Http; +using GraphQL.Client.Tests.Common.StarWars.TestData; +using GraphQL.Integration.Tests.Helpers; +using Xunit; + +namespace GraphQL.Integration.Tests.APQ; + +[SuppressMessage("ReSharper", "UseConfigureAwaitFalse")] +public class APQViaHttpRequests : IAsyncLifetime, IClassFixture +{ + public SystemTextJsonAutoNegotiateServerTestFixture Fixture { get; } + protected GraphQLHttpClient StarWarsClient; + + public APQViaHttpRequests(SystemTextJsonAutoNegotiateServerTestFixture fixture) + { + Fixture = fixture; + } + + public async Task InitializeAsync() + { + await Fixture.CreateServer(); + StarWarsClient = Fixture.GetStarWarsClient(options => options.EnableAutomaticPersistedQueries = _ => true); + } + + public Task DisposeAsync() + { + StarWarsClient?.Dispose(); + return Task.CompletedTask; + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void After_querying_all_starwars_humans_the_APQDisabledForSession_is_still_false_Async(int id, string name) + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + + var graphQLRequest = new GraphQLRequest(query, new { id = id.ToString() }); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + StarWarsClient.APQDisabledForSession.Should().BeFalse("if APQ has worked it won't get disabled"); + } +} diff --git a/tests/IntegrationTestServer/Startup.cs b/tests/IntegrationTestServer/Startup.cs index 1b671dfc..a907d623 100644 --- a/tests/IntegrationTestServer/Startup.cs +++ b/tests/IntegrationTestServer/Startup.cs @@ -38,7 +38,7 @@ public void ConfigureServices(IServiceCollection services) }) .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = Environment.IsDevelopment()) .AddSystemTextJson() - .UseAutomaticPersistedQueries() + .UseAutomaticPersistedQueries(options => options.TrackLinkedCacheEntries = true) .AddGraphTypes(typeof(ChatSchema).Assembly)); } From 77a6d7ed8f8a3c5e9f8b72e0a92cdb6829cb1b3b Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Mon, 22 Apr 2024 18:41:29 +0200 Subject: [PATCH 11/18] test APQ with websocket transport --- ...ts.cs => AdvancedPersistentQueriesTest.cs} | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) rename tests/GraphQL.Integration.Tests/APQ/{APQViaHttpRequests.cs => AdvancedPersistentQueriesTest.cs} (54%) diff --git a/tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs b/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs similarity index 54% rename from tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs rename to tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs index 5d31f1d5..1aafe7d8 100644 --- a/tests/GraphQL.Integration.Tests/APQ/APQViaHttpRequests.cs +++ b/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs @@ -9,12 +9,13 @@ namespace GraphQL.Integration.Tests.APQ; [SuppressMessage("ReSharper", "UseConfigureAwaitFalse")] -public class APQViaHttpRequests : IAsyncLifetime, IClassFixture +public class AdvancedPersistentQueriesTest : IAsyncLifetime, IClassFixture { public SystemTextJsonAutoNegotiateServerTestFixture Fixture { get; } protected GraphQLHttpClient StarWarsClient; + protected GraphQLHttpClient StarWarsWebsocketClient; - public APQViaHttpRequests(SystemTextJsonAutoNegotiateServerTestFixture fixture) + public AdvancedPersistentQueriesTest(SystemTextJsonAutoNegotiateServerTestFixture fixture) { Fixture = fixture; } @@ -23,6 +24,11 @@ public async Task InitializeAsync() { await Fixture.CreateServer(); StarWarsClient = Fixture.GetStarWarsClient(options => options.EnableAutomaticPersistedQueries = _ => true); + StarWarsWebsocketClient = Fixture.GetStarWarsClient(options => + { + options.EnableAutomaticPersistedQueries = _ => true; + options.UseWebSocketForQueriesAndMutations = true; + }); } public Task DisposeAsync() @@ -51,4 +57,25 @@ query Human($id: String!){ Assert.Equal(name, response.Data.Human.Name); StarWarsClient.APQDisabledForSession.Should().BeFalse("if APQ has worked it won't get disabled"); } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void After_querying_all_starwars_humans_using_websocket_transport_the_APQDisabledForSession_is_still_false_Async(int id, string name) + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + + var graphQLRequest = new GraphQLRequest(query, new { id = id.ToString() }); + + var response = await StarWarsWebsocketClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + StarWarsWebsocketClient.APQDisabledForSession.Should().BeFalse("if APQ has worked it won't get disabled"); + } } From 0b47264ece83f4ec879ab178295de1cb58a0bae8 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Thu, 25 Apr 2024 08:47:39 +0200 Subject: [PATCH 12/18] move code for generation of the APQ extension into GraphQLRequest --- .../GraphQLClientExtensions.cs | 4 --- src/GraphQL.Client/GraphQL.Client.csproj | 4 --- src/GraphQL.Client/GraphQLHttpClient.cs | 10 ++---- src/GraphQL.Client/GraphQLHttpRequest.cs | 3 -- .../GraphQL.Primitives.csproj | 3 ++ src/GraphQL.Primitives/GraphQLQuery.cs | 33 +++++++++++++++---- src/GraphQL.Primitives/GraphQLRequest.cs | 32 +++++++++++++++--- .../Hash.cs | 12 +++---- .../APQ/AdvancedPersistentQueriesTest.cs | 29 ++++++++++++++++ 9 files changed, 94 insertions(+), 36 deletions(-) rename src/{GraphQL.Client => GraphQL.Primitives}/Hash.cs (73%) diff --git a/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs b/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs index 92b03980..c2e4bb7c 100644 --- a/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs +++ b/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs @@ -13,14 +13,12 @@ public static Task> SendQueryAsync(this IG cancellationToken: cancellationToken); } -#if NET6_0_OR_GREATER public static Task> SendQueryAsync(this IGraphQLClient client, GraphQLQuery query, object? variables = null, string? operationName = null, Func? defineResponseType = null, CancellationToken cancellationToken = default) => SendQueryAsync(client, query.Text, variables, operationName, defineResponseType, cancellationToken); -#endif public static Task> SendMutationAsync(this IGraphQLClient client, [StringSyntax("GraphQL")] string query, object? variables = null, @@ -31,13 +29,11 @@ public static Task> SendMutationAsync(this cancellationToken: cancellationToken); } -#if NET6_0_OR_GREATER public static Task> SendMutationAsync(this IGraphQLClient client, GraphQLQuery query, object? variables = null, string? operationName = null, Func? defineResponseType = null, CancellationToken cancellationToken = default) => SendMutationAsync(client, query.Text, variables, operationName, defineResponseType, cancellationToken); -#endif public static Task> SendQueryAsync(this IGraphQLClient client, GraphQLRequest request, Func defineResponseType, CancellationToken cancellationToken = default) diff --git a/src/GraphQL.Client/GraphQL.Client.csproj b/src/GraphQL.Client/GraphQL.Client.csproj index 946e6b41..19a2bb7d 100644 --- a/src/GraphQL.Client/GraphQL.Client.csproj +++ b/src/GraphQL.Client/GraphQL.Client.csproj @@ -22,10 +22,6 @@ - - - - diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index e157662f..5f54d21b 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -96,19 +96,15 @@ public async Task> SendQueryAsync(GraphQLR { cancellationToken.ThrowIfCancellationRequested(); - string? savedQuery = request.Query; + string? savedQuery = null; bool useAPQ = false; if (request.Query != null && !APQDisabledForSession && Options.EnableAutomaticPersistedQueries(request)) { // https://www.apollographql.com/docs/react/api/link/persisted-queries/ useAPQ = true; - request.Extensions ??= new(); - request.Extensions["persistedQuery"] = new Dictionary - { - ["version"] = APQ_SUPPORTED_VERSION, - ["sha256Hash"] = Hash.Compute(request.Query), - }; + request.GeneratePersistedQueryExtension(); + savedQuery = request.Query; request.Query = null; } diff --git a/src/GraphQL.Client/GraphQLHttpRequest.cs b/src/GraphQL.Client/GraphQLHttpRequest.cs index 3882195a..67f37892 100644 --- a/src/GraphQL.Client/GraphQLHttpRequest.cs +++ b/src/GraphQL.Client/GraphQLHttpRequest.cs @@ -19,13 +19,10 @@ public GraphQLHttpRequest([StringSyntax("GraphQL")] string query, object? variab : base(query, variables, operationName, extensions) { } - -#if NET6_0_OR_GREATER public GraphQLHttpRequest(GraphQLQuery query, object? variables = null, string? operationName = null, Dictionary? extensions = null) : base(query, variables, operationName, extensions) { } -#endif public GraphQLHttpRequest(GraphQLRequest other) : base(other) diff --git a/src/GraphQL.Primitives/GraphQL.Primitives.csproj b/src/GraphQL.Primitives/GraphQL.Primitives.csproj index 44f6e6fe..0176f2ea 100644 --- a/src/GraphQL.Primitives/GraphQL.Primitives.csproj +++ b/src/GraphQL.Primitives/GraphQL.Primitives.csproj @@ -6,4 +6,7 @@ netstandard2.0;net6.0;net7.0;net8.0 + + + diff --git a/src/GraphQL.Primitives/GraphQLQuery.cs b/src/GraphQL.Primitives/GraphQLQuery.cs index b4ccf635..f5acb278 100644 --- a/src/GraphQL.Primitives/GraphQLQuery.cs +++ b/src/GraphQL.Primitives/GraphQLQuery.cs @@ -1,15 +1,34 @@ -#if NET6_0_OR_GREATER using System.Diagnostics.CodeAnalysis; - namespace GraphQL; /// -/// Value record for a GraphQL query string +/// Value object representing a GraphQL query string and storing the corresponding APQ hash.
+/// Use this to hold query strings you want to use more than once. ///
-/// the actual query string -public readonly record struct GraphQLQuery([StringSyntax("GraphQL")] string Text) +public class GraphQLQuery : IEquatable { + /// + /// The actual query string + /// + public string Text { get; } + + /// + /// The SHA256 hash used for the advanced persisted queries feature (APQ) + /// + public string Sha256Hash { get; } + + public GraphQLQuery([StringSyntax("GraphQL")] string text) + { + Text = text; + Sha256Hash = Hash.Compute(Text); + } + public static implicit operator string(GraphQLQuery query) => query.Text; -}; -#endif + + public bool Equals(GraphQLQuery other) => Sha256Hash == other.Sha256Hash; + + public override bool Equals(object? obj) => obj is GraphQLQuery other && Equals(other); + + public override int GetHashCode() => Sha256Hash.GetHashCode(); +} diff --git a/src/GraphQL.Primitives/GraphQLRequest.cs b/src/GraphQL.Primitives/GraphQLRequest.cs index d931c3ed..3e91fa2c 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -11,19 +11,29 @@ public class GraphQLRequest : Dictionary, IEquatable - /// The Query + /// The query string /// [StringSyntax("GraphQL")] public string? Query { get => TryGetValue(QUERY_KEY, out object value) ? (string)value : null; - set => this[QUERY_KEY] = value; + set + { + this[QUERY_KEY] = value; + // if the query string gets overwritten, reset the hash value + if (_sha265Hash is not null) + _sha265Hash = null; + } } /// - /// The name of the Operation + /// The operation to execute /// public string? OperationName { @@ -59,16 +69,28 @@ public GraphQLRequest([StringSyntax("GraphQL")] string query, object? variables Extensions = extensions; } -#if NET6_0_OR_GREATER public GraphQLRequest(GraphQLQuery query, object? variables = null, string? operationName = null, Dictionary? extensions = null) : this(query.Text, variables, operationName, extensions) { + _sha265Hash = query.Sha256Hash; } -#endif public GraphQLRequest(GraphQLRequest other) : base(other) { } + public void GeneratePersistedQueryExtension() + { + if (Query is null) + throw new InvalidOperationException($"{nameof(Query)} is null"); + + Extensions ??= new(); + Extensions[EXTENSIONS_PERSISTED_QUERY_KEY] = new Dictionary + { + ["version"] = APQ_SUPPORTED_VERSION, + ["sha256Hash"] = _sha265Hash ?? Hash.Compute(Query), + }; + } + /// /// Returns a value that indicates whether this instance is equal to a specified object /// diff --git a/src/GraphQL.Client/Hash.cs b/src/GraphQL.Primitives/Hash.cs similarity index 73% rename from src/GraphQL.Client/Hash.cs rename to src/GraphQL.Primitives/Hash.cs index ae0cda85..e360dd65 100644 --- a/src/GraphQL.Client/Hash.cs +++ b/src/GraphQL.Primitives/Hash.cs @@ -3,7 +3,7 @@ using System.Security.Cryptography; using System.Text; -namespace GraphQL.Client.Http; +namespace GraphQL; internal static class Hash { @@ -11,10 +11,10 @@ internal static class Hash internal static string Compute(string query) { - var expected = Encoding.UTF8.GetByteCount(query); - var inputBytes = ArrayPool.Shared.Rent(expected); - var written = Encoding.UTF8.GetBytes(query, 0, query.Length, inputBytes, 0); - Debug.Assert(written == expected, $"Encoding.UTF8.GetBytes returned unexpected bytes: {written} instead of {expected}"); + int expected = Encoding.UTF8.GetByteCount(query); + byte[]? inputBytes = ArrayPool.Shared.Rent(expected); + int written = Encoding.UTF8.GetBytes(query, 0, query.Length, inputBytes, 0); + Debug.Assert(written == expected, (string)$"Encoding.UTF8.GetBytes returned unexpected bytes: {written} instead of {expected}"); var shaShared = Interlocked.Exchange(ref _sha256, null) ?? SHA256.Create(); @@ -33,7 +33,7 @@ internal static string Compute(string query) return Convert.ToHexString(bytes); #else var builder = new StringBuilder(bytes.Length * 2); - foreach (var item in bytes) + foreach (byte item in bytes) { builder.Append(item.ToString("x2")); } diff --git a/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs b/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs index 1aafe7d8..7205e54d 100644 --- a/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs +++ b/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs @@ -78,4 +78,33 @@ query Human($id: String!){ Assert.Equal(name, response.Data.Human.Name); StarWarsWebsocketClient.APQDisabledForSession.Should().BeFalse("if APQ has worked it won't get disabled"); } + + [Fact] + public void Verify_the_persisted_query_extension_object() + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + query.Sha256Hash.Should().NotBeNullOrEmpty(); + + var request = new GraphQLRequest(query); + request.Extensions.Should().BeNull(); + request.GeneratePersistedQueryExtension(); + request.Extensions.Should().NotBeNull(); + + string expectedKey = "persistedQuery"; + var expectedExtensionValue = new Dictionary + { + ["version"] = 1, + ["sha256Hash"] = query.Sha256Hash, + }; + + request.Extensions.Should().ContainKey(expectedKey); + request.Extensions![expectedKey].As>() + .Should().NotBeNull().And.BeEquivalentTo(expectedExtensionValue); + } } From 2b77d59def2fbb1f3bde62e8c482c36ab35d5d97 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Thu, 25 Apr 2024 09:32:36 +0200 Subject: [PATCH 13/18] fix naming --- src/GraphQL.Primitives/GraphQLQuery.cs | 2 +- ...istentQueriesTest.cs => AutomaticPersistentQueriesTest.cs} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename tests/GraphQL.Integration.Tests/APQ/{AdvancedPersistentQueriesTest.cs => AutomaticPersistentQueriesTest.cs} (94%) diff --git a/src/GraphQL.Primitives/GraphQLQuery.cs b/src/GraphQL.Primitives/GraphQLQuery.cs index f5acb278..df6eded8 100644 --- a/src/GraphQL.Primitives/GraphQLQuery.cs +++ b/src/GraphQL.Primitives/GraphQLQuery.cs @@ -13,7 +13,7 @@ public class GraphQLQuery : IEquatable public string Text { get; } /// - /// The SHA256 hash used for the advanced persisted queries feature (APQ) + /// The SHA256 hash used for the automatic persisted queries feature (APQ) /// public string Sha256Hash { get; } diff --git a/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs b/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs similarity index 94% rename from tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs rename to tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs index 7205e54d..0c71a4ee 100644 --- a/tests/GraphQL.Integration.Tests/APQ/AdvancedPersistentQueriesTest.cs +++ b/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs @@ -9,13 +9,13 @@ namespace GraphQL.Integration.Tests.APQ; [SuppressMessage("ReSharper", "UseConfigureAwaitFalse")] -public class AdvancedPersistentQueriesTest : IAsyncLifetime, IClassFixture +public class AutomaticPersistentQueriesTest : IAsyncLifetime, IClassFixture { public SystemTextJsonAutoNegotiateServerTestFixture Fixture { get; } protected GraphQLHttpClient StarWarsClient; protected GraphQLHttpClient StarWarsWebsocketClient; - public AdvancedPersistentQueriesTest(SystemTextJsonAutoNegotiateServerTestFixture fixture) + public AutomaticPersistentQueriesTest(SystemTextJsonAutoNegotiateServerTestFixture fixture) { Fixture = fixture; } From 4fadbff11cb9ab8aee98d84728679733eb1f7ac2 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Sun, 28 Apr 2024 12:04:44 +0200 Subject: [PATCH 14/18] replace system.memory reference with narrower system.buffers reference --- src/GraphQL.Primitives/GraphQL.Primitives.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphQL.Primitives/GraphQL.Primitives.csproj b/src/GraphQL.Primitives/GraphQL.Primitives.csproj index 0176f2ea..9cbacbe6 100644 --- a/src/GraphQL.Primitives/GraphQL.Primitives.csproj +++ b/src/GraphQL.Primitives/GraphQL.Primitives.csproj @@ -7,6 +7,6 @@ - + From ddc0268a1ad8e666fe06df35e74c37b6d7c7185c Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 30 Apr 2024 07:35:55 +0200 Subject: [PATCH 15/18] Update src/GraphQL.Primitives/GraphQLRequest.cs Co-authored-by: Shane Krueger --- src/GraphQL.Primitives/GraphQLRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphQL.Primitives/GraphQLRequest.cs b/src/GraphQL.Primitives/GraphQLRequest.cs index 3e91fa2c..7948b3ab 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -87,7 +87,7 @@ public void GeneratePersistedQueryExtension() Extensions[EXTENSIONS_PERSISTED_QUERY_KEY] = new Dictionary { ["version"] = APQ_SUPPORTED_VERSION, - ["sha256Hash"] = _sha265Hash ?? Hash.Compute(Query), + ["sha256Hash"] = _sha265Hash ??= Hash.Compute(Query), }; } From 3f50777588bf0a050724ed4be0c327d213fcc781 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 30 Apr 2024 07:36:30 +0200 Subject: [PATCH 16/18] Update src/GraphQL.Primitives/GraphQLRequest.cs Co-authored-by: Shane Krueger --- src/GraphQL.Primitives/GraphQLRequest.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/GraphQL.Primitives/GraphQLRequest.cs b/src/GraphQL.Primitives/GraphQLRequest.cs index 7948b3ab..2d3e13ce 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -27,8 +27,7 @@ public string? Query { this[QUERY_KEY] = value; // if the query string gets overwritten, reset the hash value - if (_sha265Hash is not null) - _sha265Hash = null; + _sha265Hash = null; } } From c8b625470f72b8beca0a7373d86e84d2c6bedf97 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 30 Apr 2024 09:37:12 +0200 Subject: [PATCH 17/18] document APQ feature +semver: feature --- README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1838956e..acf2e3a3 100644 --- a/README.md +++ b/README.md @@ -162,17 +162,22 @@ var subscription = subscriptionStream.Subscribe(response => subscription.Dispose(); ``` -## Syntax Highlighting for GraphQL strings in IDEs +### Automatic persisted queries (APQ) -.NET 7.0 introduced the [StringSyntaxAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.stringsyntaxattribute?view=net-8.0) to have a unified way of telling what data is expected in a given `string` or `ReadOnlySpan`. IDEs like Visual Studio and Rider can then use this to provide syntax highlighting and checking. +[Automatic persisted queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/) are supported since client version 6.1.0. -From v6.0.4 on all GraphQL string parameters in this library are decorated with the `[StringSyntax("GraphQL")]` attribute. +APQ can be enabled by configuring `GraphQLHttpClientOptions.EnableAutomaticPersistedQueries` to resolve to `true`. -Currently, there is no native support for GraphQL formatting and syntax highlighting in Visual Studio, but the [GraphQLTools Extension](https://marketplace.visualstudio.com/items?itemName=codearchitects-research.GraphQLTools) provides that for you. +The client will automatically disable APQ if the server responds with a `PersistedQueryNotSupported` error or if a HTTP status code between 400 and 600 is returned. +This can be customized by configuring `GraphQLHttpClientOptions.DisableAPQ`. -For Rider, JetBrains provides a [Plugin](https://plugins.jetbrains.com/plugin/8097-graphql), too. +To re-enable APQ after it has been automatically disabled, `GraphQLHttpClient` needs to be disposed an recreated. -To leverage syntax highlighting in variable declarations, the `GraphQLQuery` value record type is provided: +APQ works by first sending a hash of the query string to the server, and only sending the full query string if the server has not yet cached a query with a matching hash. +With queries supplied as a string parameter to `GraphQLRequest`, the hash gets computed each time the request is sent. + +When you want to reuse a query string (propably to leverage APQ :wink:), declare the query using the `GraphQLQuery` class. This way, the hash gets computed once on construction +of the `GraphQLQuery` object and handed down to each `GraphQLRequest` using the query. ```csharp GraphQLQuery query = new(""" @@ -194,6 +199,19 @@ var graphQLResponse = await graphQLClient.SendQueryAsync( new { id = "cGVvcGxlOjE=" }); ``` +### Syntax Highlighting for GraphQL strings in IDEs + +.NET 7.0 introduced the [StringSyntaxAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.stringsyntaxattribute?view=net-8.0) to have a unified way of telling what data is expected in a given `string` or `ReadOnlySpan`. IDEs like Visual Studio and Rider can then use this to provide syntax highlighting and checking. + +From v6.0.4 on all GraphQL string parameters in this library are decorated with the `[StringSyntax("GraphQL")]` attribute. + +Currently, there is no native support for GraphQL formatting and syntax highlighting in Visual Studio, but the [GraphQLTools Extension](https://marketplace.visualstudio.com/items?itemName=codearchitects-research.GraphQLTools) provides that for you. + +For Rider, JetBrains provides a [Plugin](https://plugins.jetbrains.com/plugin/8097-graphql), too. + +To leverage syntax highlighting in variable declarations, use the `GraphQLQuery` class. + + ## Useful Links * [StarWars Example Server (GitHub)](https://github.com/graphql/swapi-graphql) From 6be7d9c1ffb3c7293870aedbd446c4f10b1284fa Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 30 Apr 2024 09:47:58 +0200 Subject: [PATCH 18/18] optimize docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index acf2e3a3..52ed68d7 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ subscription.Dispose(); APQ can be enabled by configuring `GraphQLHttpClientOptions.EnableAutomaticPersistedQueries` to resolve to `true`. -The client will automatically disable APQ if the server responds with a `PersistedQueryNotSupported` error or if a HTTP status code between 400 and 600 is returned. +By default, the client will automatically disable APQ for the current session if the server responds with a `PersistedQueryNotSupported` error or a 400 or 600 HTTP status code. This can be customized by configuring `GraphQLHttpClientOptions.DisableAPQ`. To re-enable APQ after it has been automatically disabled, `GraphQLHttpClient` needs to be disposed an recreated.