Skip to content

Commit

Permalink
Add APQ support (#555)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
4 people authored May 21, 2024
1 parent dbd9c20 commit 6236c9b
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 32 deletions.
1 change: 1 addition & 0 deletions GraphQL.Client.sln.DotSettings
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
<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">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=APQ/@EntryIndexedValue">APQ</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=QL/@EntryIndexedValue">QL</s:String></wpf:ResourceDictionary>
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<char>`. 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("""
Expand All @@ -191,6 +199,19 @@ var graphQLResponse = await graphQLClient.SendQueryAsync<ResponseType>(
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<char>`. 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)
Expand Down
4 changes: 0 additions & 4 deletions src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@ public static Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(this IG
cancellationToken: cancellationToken);
}

#if NET6_0_OR_GREATER
public static Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(this IGraphQLClient client,
GraphQLQuery query, object? variables = null,
string? operationName = null, Func<TResponse>? defineResponseType = null,
CancellationToken cancellationToken = default)
=> SendQueryAsync(client, query.Text, variables, operationName, defineResponseType,
cancellationToken);
#endif

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

#if NET6_0_OR_GREATER
public static Task<GraphQLResponse<TResponse>> SendMutationAsync<TResponse>(this IGraphQLClient client,
GraphQLQuery query, object? variables = null, string? operationName = null, Func<TResponse>? defineResponseType = null,
CancellationToken cancellationToken = default)
=> SendMutationAsync(client, query.Text, variables, operationName, defineResponseType,
cancellationToken);
#endif

public static Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(this IGraphQLClient client,
GraphQLRequest request, Func<TResponse> defineResponseType, CancellationToken cancellationToken = default)
Expand Down
54 changes: 50 additions & 4 deletions src/GraphQL.Client/GraphQLHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable
private readonly CancellationTokenSource _cancellationTokenSource = new();

private readonly bool _disposeHttpClient = false;

/// <summary>
/// the json serializer
/// </summary>
Expand All @@ -33,6 +32,12 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable
/// </summary>
public GraphQLHttpClientOptions Options { get; }

/// <summary>
/// This flag is set to <see langword="true"/> when an error has occurred on an APQ and <see cref="GraphQLHttpClientOptions.DisableAPQ"/>
/// 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.
/// </summary>
public bool APQDisabledForSession { get; private set; }

/// <inheritdoc />
public IObservable<Exception> WebSocketReceiveErrors => GraphQlHttpWebSocket.ReceiveErrors;

Expand Down Expand Up @@ -84,12 +89,49 @@ public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serial

#region IGraphQLClient

private const int APQ_SUPPORTED_VERSION = 1;

/// <inheritdoc />
public async Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default)
{
return Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme()
? await GraphQlHttpWebSocket.SendRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false)
: await SendHttpRequestAsync<TResponse>(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<TResponse>(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<TResponse>(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<TResponse>(request, cancellationToken);
}
}

return response;
}

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

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

private async Task<GraphQLHttpResponse<TResponse>> SendHttpRequestAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default)
{
Expand Down
19 changes: 18 additions & 1 deletion src/GraphQL.Client/GraphQLHttpClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class GraphQLHttpClientOptions
public Uri? WebSocketEndPoint { get; set; }

/// <summary>
/// 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.
/// </summary>
public string? WebSocketProtocol { get; set; } = WebSocketProtocols.AUTO_NEGOTIATE;

Expand Down Expand Up @@ -99,4 +99,21 @@ public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r)
/// </summary>
public ProductInfoHeaderValue? DefaultUserAgentRequestHeader { get; set; }
= new ProductInfoHeaderValue(typeof(GraphQLHttpClient).Assembly.GetName().Name, typeof(GraphQLHttpClient).Assembly.GetName().Version.ToString());

/// <summary>
/// Delegate permitting use of <see href="https://www.apollographql.com/docs/react/api/link/persisted-queries/">Automatic Persisted Queries (APQ)</see>.
/// By default, returns <see langword="false" /> 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.
/// </summary>
public Func<GraphQLRequest, bool> EnableAutomaticPersistedQueries { get; set; } = _ => false;

/// <summary>
/// A delegate which takes an <see cref="IGraphQLResponse"/> 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.
/// </summary>
public Func<IGraphQLResponse, bool> 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;
};
}
3 changes: 0 additions & 3 deletions src/GraphQL.Client/GraphQLHttpRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>? extensions = null)
: base(query, variables, operationName, extensions)
{
}
#endif

public GraphQLHttpRequest(GraphQLRequest other)
: base(other)
Expand Down
9 changes: 8 additions & 1 deletion src/GraphQL.Client/GraphQLHttpResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace GraphQL.Client.Http;

public class GraphQLHttpResponse<T> : GraphQLResponse<T>
public class GraphQLHttpResponse<T> : GraphQLResponse<T>, IGraphQLHttpResponse
{
public GraphQLHttpResponse(GraphQLResponse<T> response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode)
{
Expand All @@ -19,6 +19,13 @@ public GraphQLHttpResponse(GraphQLResponse<T> 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<T> ToGraphQLHttpResponse<T>(this GraphQLResponse<T> response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode) => new(response, responseHeaders, statusCode);
Expand Down
3 changes: 3 additions & 0 deletions src/GraphQL.Primitives/GraphQL.Primitives.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
<TargetFrameworks>netstandard2.0;net6.0;net7.0;net8.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Buffers" Version="4.5.1" />
</ItemGroup>
</Project>
33 changes: 26 additions & 7 deletions src/GraphQL.Primitives/GraphQLQuery.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
#if NET6_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;

namespace GraphQL;

/// <summary>
/// Value record for a GraphQL query string
/// Value object representing a GraphQL query string and storing the corresponding APQ hash. <br />
/// Use this to hold query strings you want to use more than once.
/// </summary>
/// <param name="Text">the actual query string</param>
public readonly record struct GraphQLQuery([StringSyntax("GraphQL")] string Text)
public class GraphQLQuery : IEquatable<GraphQLQuery>
{
/// <summary>
/// The actual query string
/// </summary>
public string Text { get; }

/// <summary>
/// The SHA256 hash used for the automatic persisted queries feature (APQ)
/// </summary>
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();
}
33 changes: 27 additions & 6 deletions src/GraphQL.Primitives/GraphQLRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,28 @@ public class GraphQLRequest : Dictionary<string, object>, IEquatable<GraphQLRequ
public const string QUERY_KEY = "query";
public const string VARIABLES_KEY = "variables";
public const string EXTENSIONS_KEY = "extensions";
public const string EXTENSIONS_PERSISTED_QUERY_KEY = "persistedQuery";
public const int APQ_SUPPORTED_VERSION = 1;

private string? _sha265Hash;

/// <summary>
/// The Query
/// The query string
/// </summary>
[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;
}
}

/// <summary>
/// The name of the Operation
/// The operation to execute
/// </summary>
public string? OperationName
{
Expand Down Expand Up @@ -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<string, object?>? 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<string, object>
{
["version"] = APQ_SUPPORTED_VERSION,
["sha256Hash"] = _sha265Hash ??= Hash.Compute(Query),
};
}

/// <summary>
/// Returns a value that indicates whether this instance is equal to a specified object
/// </summary>
Expand Down
Loading

0 comments on commit 6236c9b

Please sign in to comment.