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/README.md b/README.md index fe859ced..52ed68d7 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 @@ -159,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. +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`. -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(""" @@ -191,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) 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/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 10076b8a..5f54d21b 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -17,7 +17,6 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly bool _disposeHttpClient = false; - /// /// the json serializer /// @@ -33,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; @@ -84,12 +89,49 @@ public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serial #region IGraphQLClient + private const int APQ_SUPPORTED_VERSION = 1; + /// 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 SendHttpRequestAsync(request, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + 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.GeneratePersistedQueryExtension(); + savedQuery = request.Query; + request.Query = null; + } + + var response = await SendQueryInternalAsync(request, cancellationToken); + + if (useAPQ) + { + if (response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotFound", StringComparison.CurrentCultureIgnoreCase)) == true) + { + // 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. + request.Query = savedQuery; + return await SendQueryInternalAsync(request, cancellationToken); + } + 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. + APQDisabledForSession = Options.DisableAPQ(response); + request.Query = savedQuery; + return await SendQueryInternalAsync(request, cancellationToken); + } + } + + return response; } /// @@ -123,6 +165,10 @@ public IObservable> CreateSubscriptionStream 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) { diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index 6c3b30d1..eda0d024 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,21 @@ 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; } = _ => 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 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.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.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); diff --git a/src/GraphQL.Primitives/GraphQL.Primitives.csproj b/src/GraphQL.Primitives/GraphQL.Primitives.csproj index 44f6e6fe..9cbacbe6 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..df6eded8 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 automatic 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 c208a90f..2d3e13ce 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -11,19 +11,28 @@ public class GraphQLRequest : Dictionary, IEquatable - /// The Query + /// The query string /// [StringSyntax("GraphQL")] - public string Query + 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 + _sha265Hash = null; + } } /// - /// The name of the Operation + /// The operation to execute /// public string? OperationName { @@ -59,16 +68,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.Primitives/Hash.cs b/src/GraphQL.Primitives/Hash.cs new file mode 100644 index 00000000..e360dd65 --- /dev/null +++ b/src/GraphQL.Primitives/Hash.cs @@ -0,0 +1,44 @@ +using System.Buffers; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace GraphQL; + +internal static class Hash +{ + private static SHA256? _sha256; + + internal static string Compute(string query) + { + 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(); + +#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 (byte item in bytes) + { + builder.Append(item.ToString("x2")); + } + + return builder.ToString(); +#endif + } +} diff --git a/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs b/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs new file mode 100644 index 00000000..0c71a4ee --- /dev/null +++ b/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs @@ -0,0 +1,110 @@ +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 AutomaticPersistentQueriesTest : IAsyncLifetime, IClassFixture +{ + public SystemTextJsonAutoNegotiateServerTestFixture Fixture { get; } + protected GraphQLHttpClient StarWarsClient; + protected GraphQLHttpClient StarWarsWebsocketClient; + + public AutomaticPersistentQueriesTest(SystemTextJsonAutoNegotiateServerTestFixture fixture) + { + Fixture = fixture; + } + + 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() + { + 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"); + } + + [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"); + } + + [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); + } +} diff --git a/tests/IntegrationTestServer/Startup.cs b/tests/IntegrationTestServer/Startup.cs index ee8526c1..a907d623 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(options => options.TrackLinkedCacheEntries = true) .AddGraphTypes(typeof(ChatSchema).Assembly)); }