Skip to content

Commit 6236c9b

Browse files
sungam3rrose-aShane32
authored
Add APQ support (#555)
* Add APQ support * changes * rem * note * progress * progress * fix variable name * move APQ code to SendQueryAsync method to allow usage over websocket, too * make the APQDisabledForSession flag public (helps for testing) * create a test that uses the APQ feature * test APQ with websocket transport * move code for generation of the APQ extension into GraphQLRequest * fix naming * replace system.memory reference with narrower system.buffers reference * Update src/GraphQL.Primitives/GraphQLRequest.cs Co-authored-by: Shane Krueger <[email protected]> * Update src/GraphQL.Primitives/GraphQLRequest.cs Co-authored-by: Shane Krueger <[email protected]> * document APQ feature +semver: feature * optimize docs --------- Co-authored-by: Alexander Rose <[email protected]> Co-authored-by: Alexander Rose <[email protected]> Co-authored-by: Shane Krueger <[email protected]>
1 parent dbd9c20 commit 6236c9b

File tree

13 files changed

+315
-32
lines changed

13 files changed

+315
-32
lines changed

GraphQL.Client.sln.DotSettings

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
2+
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=APQ/@EntryIndexedValue">APQ</s:String>
23
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=QL/@EntryIndexedValue">QL</s:String></wpf:ResourceDictionary>

README.md

+27-6
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ The Library will try to follow the following standards and documents:
2222

2323
## Usage
2424

25+
The intended use of `GraphQLHttpClient` is to keep one instance alive per endpoint (obvious in case you're
26+
operating full websocket, but also true for regular requests) and is built with thread-safety in mind.
27+
2528
### Create a GraphQLHttpClient
2629

2730
```csharp
@@ -159,17 +162,22 @@ var subscription = subscriptionStream.Subscribe(response =>
159162
subscription.Dispose();
160163
```
161164

162-
## Syntax Highlighting for GraphQL strings in IDEs
165+
### Automatic persisted queries (APQ)
163166

164-
.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<char>`. IDEs like Visual Studio and Rider can then use this to provide syntax highlighting and checking.
167+
[Automatic persisted queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/) are supported since client version 6.1.0.
165168

166-
From v6.0.4 on all GraphQL string parameters in this library are decorated with the `[StringSyntax("GraphQL")]` attribute.
169+
APQ can be enabled by configuring `GraphQLHttpClientOptions.EnableAutomaticPersistedQueries` to resolve to `true`.
167170

168-
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.
171+
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.
172+
This can be customized by configuring `GraphQLHttpClientOptions.DisableAPQ`.
169173

170-
For Rider, JetBrains provides a [Plugin](https://plugins.jetbrains.com/plugin/8097-graphql), too.
174+
To re-enable APQ after it has been automatically disabled, `GraphQLHttpClient` needs to be disposed an recreated.
171175

172-
To leverage syntax highlighting in variable declarations, the `GraphQLQuery` value record type is provided:
176+
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.
177+
With queries supplied as a string parameter to `GraphQLRequest`, the hash gets computed each time the request is sent.
178+
179+
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
180+
of the `GraphQLQuery` object and handed down to each `GraphQLRequest` using the query.
173181

174182
```csharp
175183
GraphQLQuery query = new("""
@@ -191,6 +199,19 @@ var graphQLResponse = await graphQLClient.SendQueryAsync<ResponseType>(
191199
new { id = "cGVvcGxlOjE=" });
192200
```
193201

202+
### Syntax Highlighting for GraphQL strings in IDEs
203+
204+
.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<char>`. IDEs like Visual Studio and Rider can then use this to provide syntax highlighting and checking.
205+
206+
From v6.0.4 on all GraphQL string parameters in this library are decorated with the `[StringSyntax("GraphQL")]` attribute.
207+
208+
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.
209+
210+
For Rider, JetBrains provides a [Plugin](https://plugins.jetbrains.com/plugin/8097-graphql), too.
211+
212+
To leverage syntax highlighting in variable declarations, use the `GraphQLQuery` class.
213+
214+
194215
## Useful Links
195216

196217
* [StarWars Example Server (GitHub)](https://github.com/graphql/swapi-graphql)

src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs

-4
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,12 @@ public static Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(this IG
1313
cancellationToken: cancellationToken);
1414
}
1515

16-
#if NET6_0_OR_GREATER
1716
public static Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(this IGraphQLClient client,
1817
GraphQLQuery query, object? variables = null,
1918
string? operationName = null, Func<TResponse>? defineResponseType = null,
2019
CancellationToken cancellationToken = default)
2120
=> SendQueryAsync(client, query.Text, variables, operationName, defineResponseType,
2221
cancellationToken);
23-
#endif
2422

2523
public static Task<GraphQLResponse<TResponse>> SendMutationAsync<TResponse>(this IGraphQLClient client,
2624
[StringSyntax("GraphQL")] string query, object? variables = null,
@@ -31,13 +29,11 @@ public static Task<GraphQLResponse<TResponse>> SendMutationAsync<TResponse>(this
3129
cancellationToken: cancellationToken);
3230
}
3331

34-
#if NET6_0_OR_GREATER
3532
public static Task<GraphQLResponse<TResponse>> SendMutationAsync<TResponse>(this IGraphQLClient client,
3633
GraphQLQuery query, object? variables = null, string? operationName = null, Func<TResponse>? defineResponseType = null,
3734
CancellationToken cancellationToken = default)
3835
=> SendMutationAsync(client, query.Text, variables, operationName, defineResponseType,
3936
cancellationToken);
40-
#endif
4137

4238
public static Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(this IGraphQLClient client,
4339
GraphQLRequest request, Func<TResponse> defineResponseType, CancellationToken cancellationToken = default)

src/GraphQL.Client/GraphQLHttpClient.cs

+50-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable
1717
private readonly CancellationTokenSource _cancellationTokenSource = new();
1818

1919
private readonly bool _disposeHttpClient = false;
20-
2120
/// <summary>
2221
/// the json serializer
2322
/// </summary>
@@ -33,6 +32,12 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable
3332
/// </summary>
3433
public GraphQLHttpClientOptions Options { get; }
3534

35+
/// <summary>
36+
/// This flag is set to <see langword="true"/> when an error has occurred on an APQ and <see cref="GraphQLHttpClientOptions.DisableAPQ"/>
37+
/// has returned <see langword="true"/>. To reset this, the instance of <see cref="GraphQLHttpClient"/> has to be disposed and a new one must be created.
38+
/// </summary>
39+
public bool APQDisabledForSession { get; private set; }
40+
3641
/// <inheritdoc />
3742
public IObservable<Exception> WebSocketReceiveErrors => GraphQlHttpWebSocket.ReceiveErrors;
3843

@@ -84,12 +89,49 @@ public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serial
8489

8590
#region IGraphQLClient
8691

92+
private const int APQ_SUPPORTED_VERSION = 1;
93+
8794
/// <inheritdoc />
8895
public async Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default)
8996
{
90-
return Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme()
91-
? await GraphQlHttpWebSocket.SendRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false)
92-
: await SendHttpRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false);
97+
cancellationToken.ThrowIfCancellationRequested();
98+
99+
string? savedQuery = null;
100+
bool useAPQ = false;
101+
102+
if (request.Query != null && !APQDisabledForSession && Options.EnableAutomaticPersistedQueries(request))
103+
{
104+
// https://www.apollographql.com/docs/react/api/link/persisted-queries/
105+
useAPQ = true;
106+
request.GeneratePersistedQueryExtension();
107+
savedQuery = request.Query;
108+
request.Query = null;
109+
}
110+
111+
var response = await SendQueryInternalAsync<TResponse>(request, cancellationToken);
112+
113+
if (useAPQ)
114+
{
115+
if (response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotFound", StringComparison.CurrentCultureIgnoreCase)) == true)
116+
{
117+
// GraphQL server supports APQ!
118+
119+
// Alas, for the first time we did not guess and in vain removed Query, so we return Query and
120+
// send request again. This is one-time "cache miss", not so scary.
121+
request.Query = savedQuery;
122+
return await SendQueryInternalAsync<TResponse>(request, cancellationToken);
123+
}
124+
else
125+
{
126+
// GraphQL server either supports APQ of some other version, or does not support it at all.
127+
// Send a request for the second time. This is better than returning an error. Let the client work with APQ disabled.
128+
APQDisabledForSession = Options.DisableAPQ(response);
129+
request.Query = savedQuery;
130+
return await SendQueryInternalAsync<TResponse>(request, cancellationToken);
131+
}
132+
}
133+
134+
return response;
93135
}
94136

95137
/// <inheritdoc />
@@ -123,6 +165,10 @@ public IObservable<GraphQLResponse<TResponse>> CreateSubscriptionStream<TRespons
123165
public Task SendPongAsync(object? payload) => GraphQlHttpWebSocket.SendPongAsync(payload);
124166

125167
#region Private Methods
168+
private async Task<GraphQLResponse<TResponse>> SendQueryInternalAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default) =>
169+
Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme()
170+
? await GraphQlHttpWebSocket.SendRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false)
171+
: await SendHttpRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false);
126172

127173
private async Task<GraphQLHttpResponse<TResponse>> SendHttpRequestAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default)
128174
{

src/GraphQL.Client/GraphQLHttpClientOptions.cs

+18-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class GraphQLHttpClientOptions
2525
public Uri? WebSocketEndPoint { get; set; }
2626

2727
/// <summary>
28-
/// The GraphQL websocket protocol to be used. Defaults to the older "graphql-ws" protocol to not break old code.
28+
/// The GraphQL websocket protocol to be used. Defaults to the older "graphql-ws" protocol to not break old code.
2929
/// </summary>
3030
public string? WebSocketProtocol { get; set; } = WebSocketProtocols.AUTO_NEGOTIATE;
3131

@@ -99,4 +99,21 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r)
9999
/// </summary>
100100
public ProductInfoHeaderValue? DefaultUserAgentRequestHeader { get; set; }
101101
= new ProductInfoHeaderValue(typeof(GraphQLHttpClient).Assembly.GetName().Name, typeof(GraphQLHttpClient).Assembly.GetName().Version.ToString());
102+
103+
/// <summary>
104+
/// Delegate permitting use of <see href="https://www.apollographql.com/docs/react/api/link/persisted-queries/">Automatic Persisted Queries (APQ)</see>.
105+
/// By default, returns <see langword="false" /> for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely
106+
/// after an unsuccessful attempt to send an APQ request and then send only regular requests.
107+
/// </summary>
108+
public Func<GraphQLRequest, bool> EnableAutomaticPersistedQueries { get; set; } = _ => false;
109+
110+
/// <summary>
111+
/// A delegate which takes an <see cref="IGraphQLResponse"/> and returns a boolean to disable any future persisted queries for that session.
112+
/// This defaults to disabling on PersistedQueryNotSupported or a 400 or 500 HTTP error.
113+
/// </summary>
114+
public Func<IGraphQLResponse, bool> DisableAPQ { get; set; } = response =>
115+
{
116+
return response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported", StringComparison.CurrentCultureIgnoreCase)) == true
117+
|| response is IGraphQLHttpResponse httpResponse && (int)httpResponse.StatusCode >= 400 && (int)httpResponse.StatusCode < 600;
118+
};
102119
}

src/GraphQL.Client/GraphQLHttpRequest.cs

-3
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,10 @@ public GraphQLHttpRequest([StringSyntax("GraphQL")] string query, object? variab
1919
: base(query, variables, operationName, extensions)
2020
{
2121
}
22-
23-
#if NET6_0_OR_GREATER
2422
public GraphQLHttpRequest(GraphQLQuery query, object? variables = null, string? operationName = null, Dictionary<string, object?>? extensions = null)
2523
: base(query, variables, operationName, extensions)
2624
{
2725
}
28-
#endif
2926

3027
public GraphQLHttpRequest(GraphQLRequest other)
3128
: base(other)

src/GraphQL.Client/GraphQLHttpResponse.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
namespace GraphQL.Client.Http;
55

6-
public class GraphQLHttpResponse<T> : GraphQLResponse<T>
6+
public class GraphQLHttpResponse<T> : GraphQLResponse<T>, IGraphQLHttpResponse
77
{
88
public GraphQLHttpResponse(GraphQLResponse<T> response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode)
99
{
@@ -19,6 +19,13 @@ public GraphQLHttpResponse(GraphQLResponse<T> response, HttpResponseHeaders resp
1919
public HttpStatusCode StatusCode { get; set; }
2020
}
2121

22+
public interface IGraphQLHttpResponse : IGraphQLResponse
23+
{
24+
HttpResponseHeaders ResponseHeaders { get; set; }
25+
26+
HttpStatusCode StatusCode { get; set; }
27+
}
28+
2229
public static class GraphQLResponseExtensions
2330
{
2431
public static GraphQLHttpResponse<T> ToGraphQLHttpResponse<T>(this GraphQLResponse<T> response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode) => new(response, responseHeaders, statusCode);

src/GraphQL.Primitives/GraphQL.Primitives.csproj

+3
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@
66
<TargetFrameworks>netstandard2.0;net6.0;net7.0;net8.0</TargetFrameworks>
77
</PropertyGroup>
88

9+
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
10+
<PackageReference Include="System.Buffers" Version="4.5.1" />
11+
</ItemGroup>
912
</Project>
+26-7
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
1-
#if NET6_0_OR_GREATER
21
using System.Diagnostics.CodeAnalysis;
3-
42
namespace GraphQL;
53

64
/// <summary>
7-
/// Value record for a GraphQL query string
5+
/// Value object representing a GraphQL query string and storing the corresponding APQ hash. <br />
6+
/// Use this to hold query strings you want to use more than once.
87
/// </summary>
9-
/// <param name="Text">the actual query string</param>
10-
public readonly record struct GraphQLQuery([StringSyntax("GraphQL")] string Text)
8+
public class GraphQLQuery : IEquatable<GraphQLQuery>
119
{
10+
/// <summary>
11+
/// The actual query string
12+
/// </summary>
13+
public string Text { get; }
14+
15+
/// <summary>
16+
/// The SHA256 hash used for the automatic persisted queries feature (APQ)
17+
/// </summary>
18+
public string Sha256Hash { get; }
19+
20+
public GraphQLQuery([StringSyntax("GraphQL")] string text)
21+
{
22+
Text = text;
23+
Sha256Hash = Hash.Compute(Text);
24+
}
25+
1226
public static implicit operator string(GraphQLQuery query)
1327
=> query.Text;
14-
};
15-
#endif
28+
29+
public bool Equals(GraphQLQuery other) => Sha256Hash == other.Sha256Hash;
30+
31+
public override bool Equals(object? obj) => obj is GraphQLQuery other && Equals(other);
32+
33+
public override int GetHashCode() => Sha256Hash.GetHashCode();
34+
}

src/GraphQL.Primitives/GraphQLRequest.cs

+27-6
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,28 @@ public class GraphQLRequest : Dictionary<string, object>, IEquatable<GraphQLRequ
1111
public const string QUERY_KEY = "query";
1212
public const string VARIABLES_KEY = "variables";
1313
public const string EXTENSIONS_KEY = "extensions";
14+
public const string EXTENSIONS_PERSISTED_QUERY_KEY = "persistedQuery";
15+
public const int APQ_SUPPORTED_VERSION = 1;
16+
17+
private string? _sha265Hash;
1418

1519
/// <summary>
16-
/// The Query
20+
/// The query string
1721
/// </summary>
1822
[StringSyntax("GraphQL")]
19-
public string Query
23+
public string? Query
2024
{
2125
get => TryGetValue(QUERY_KEY, out object value) ? (string)value : null;
22-
set => this[QUERY_KEY] = value;
26+
set
27+
{
28+
this[QUERY_KEY] = value;
29+
// if the query string gets overwritten, reset the hash value
30+
_sha265Hash = null;
31+
}
2332
}
2433

2534
/// <summary>
26-
/// The name of the Operation
35+
/// The operation to execute
2736
/// </summary>
2837
public string? OperationName
2938
{
@@ -59,16 +68,28 @@ public GraphQLRequest([StringSyntax("GraphQL")] string query, object? variables
5968
Extensions = extensions;
6069
}
6170

62-
#if NET6_0_OR_GREATER
6371
public GraphQLRequest(GraphQLQuery query, object? variables = null, string? operationName = null,
6472
Dictionary<string, object?>? extensions = null)
6573
: this(query.Text, variables, operationName, extensions)
6674
{
75+
_sha265Hash = query.Sha256Hash;
6776
}
68-
#endif
6977

7078
public GraphQLRequest(GraphQLRequest other) : base(other) { }
7179

80+
public void GeneratePersistedQueryExtension()
81+
{
82+
if (Query is null)
83+
throw new InvalidOperationException($"{nameof(Query)} is null");
84+
85+
Extensions ??= new();
86+
Extensions[EXTENSIONS_PERSISTED_QUERY_KEY] = new Dictionary<string, object>
87+
{
88+
["version"] = APQ_SUPPORTED_VERSION,
89+
["sha256Hash"] = _sha265Hash ??= Hash.Compute(Query),
90+
};
91+
}
92+
7293
/// <summary>
7394
/// Returns a value that indicates whether this instance is equal to a specified object
7495
/// </summary>

0 commit comments

Comments
 (0)