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));
}