diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/IOperationDocumentHashProvider.cs b/src/HotChocolate/Core/src/Abstractions/Execution/IOperationDocumentHashProvider.cs
new file mode 100644
index 00000000000..e5988773497
--- /dev/null
+++ b/src/HotChocolate/Core/src/Abstractions/Execution/IOperationDocumentHashProvider.cs
@@ -0,0 +1,12 @@
+namespace HotChocolate.Execution;
+
+///
+/// Provides the hash of an operation document.
+///
+public interface IOperationDocumentHashProvider
+{
+ ///
+ /// Gets the hash of the operation document.
+ ///
+ OperationDocumentHash Hash { get; }
+}
diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/IOperationDocumentNodeProvider.cs b/src/HotChocolate/Core/src/Abstractions/Execution/IOperationDocumentNodeProvider.cs
new file mode 100644
index 00000000000..5fea64273a2
--- /dev/null
+++ b/src/HotChocolate/Core/src/Abstractions/Execution/IOperationDocumentNodeProvider.cs
@@ -0,0 +1,14 @@
+using HotChocolate.Language;
+
+namespace HotChocolate.Execution;
+
+///
+/// Provides the document syntax node of an operation document.
+///
+public interface IOperationDocumentNodeProvider
+{
+ ///
+ /// Gets the document syntax node of the operation document.
+ ///
+ DocumentNode Document { get; }
+}
diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/OperationDocumentHash.cs b/src/HotChocolate/Core/src/Abstractions/Execution/OperationDocumentHash.cs
new file mode 100644
index 00000000000..45819a4bd4e
--- /dev/null
+++ b/src/HotChocolate/Core/src/Abstractions/Execution/OperationDocumentHash.cs
@@ -0,0 +1,46 @@
+using HotChocolate.Language;
+
+namespace HotChocolate.Execution;
+
+///
+/// Represents the hash of an operation document.
+///
+public readonly struct OperationDocumentHash
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ ///
+ /// The hash of the operation document.
+ ///
+ ///
+ /// The algorithm used to compute the hash.
+ ///
+ ///
+ /// The format of the hash.
+ ///
+ ///
+ /// Thrown when or is null.
+ ///
+ public OperationDocumentHash(string hash, string algorithm, HashFormat format)
+ {
+ Hash = hash ?? throw new ArgumentNullException(nameof(hash));
+ AlgorithmName = algorithm ?? throw new ArgumentNullException(nameof(algorithm));
+ Format = format;
+ }
+
+ ///
+ /// Gets the hash of the operation document.
+ ///
+ public string Hash { get; }
+
+ ///
+ /// Gets the algorithm used to compute the hash.
+ ///
+ public string AlgorithmName { get; }
+
+ ///
+ /// Gets the format of the hash.
+ ///
+ public HashFormat Format { get; }
+}
diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/DocumentCacheMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/DocumentCacheMiddleware.cs
index ba00ae1c331..6f0bdba954c 100644
--- a/src/HotChocolate/Core/src/Execution/Pipeline/DocumentCacheMiddleware.cs
+++ b/src/HotChocolate/Core/src/Execution/Pipeline/DocumentCacheMiddleware.cs
@@ -38,6 +38,7 @@ public async ValueTask InvokeAsync(IRequestContext context)
_documentCache.TryGetDocument(request.DocumentId.Value.Value, out var document))
{
context.DocumentId = request.DocumentId;
+ context.DocumentHash = document.Hash;
context.Document = document.Body;
context.ValidationResult = DocumentValidatorResult.Ok;
context.IsCachedDocument = true;
@@ -49,6 +50,7 @@ public async ValueTask InvokeAsync(IRequestContext context)
_documentCache.TryGetDocument(request.DocumentHash, out document))
{
context.DocumentId = request.DocumentHash;
+ context.DocumentHash = document.Hash;
context.Document = document.Body;
context.ValidationResult = DocumentValidatorResult.Ok;
context.IsCachedDocument = true;
@@ -81,7 +83,18 @@ public async ValueTask InvokeAsync(IRequestContext context)
{
_documentCache.TryAddDocument(
context.DocumentId.Value.Value,
- new CachedDocument(context.Document, context.IsPersistedDocument));
+ new CachedDocument(context.Document, context.DocumentHash, context.IsPersistedDocument));
+
+ // The hash and the documentId can differ if the id is not a hash or
+ // if the hash algorithm is different from the one that Hot Chocolate uses internally.
+ // In the case they differ we just add another lookup to the cache.
+ if(context.DocumentHash is not null)
+ {
+ _documentCache.TryAddDocument(
+ context.DocumentHash,
+ new CachedDocument(context.Document, context.DocumentHash, context.IsPersistedDocument));
+ }
+
_diagnosticEvents.AddedDocumentToCache(context);
}
}
diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/ReadPersistedOperationMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/ReadPersistedOperationMiddleware.cs
index 03f55bf7769..0b075823089 100644
--- a/src/HotChocolate/Core/src/Execution/Pipeline/ReadPersistedOperationMiddleware.cs
+++ b/src/HotChocolate/Core/src/Execution/Pipeline/ReadPersistedOperationMiddleware.cs
@@ -11,12 +11,14 @@ internal sealed class ReadPersistedOperationMiddleware
private readonly RequestDelegate _next;
private readonly IExecutionDiagnosticEvents _diagnosticEvents;
private readonly IOperationDocumentStorage _operationDocumentStorage;
+ private readonly IDocumentHashProvider _documentHashAlgorithm;
private readonly PersistedOperationOptions _options;
private ReadPersistedOperationMiddleware(
RequestDelegate next,
[SchemaService] IExecutionDiagnosticEvents diagnosticEvents,
[SchemaService] IOperationDocumentStorage operationDocumentStorage,
+ IDocumentHashProvider documentHashAlgorithm,
PersistedOperationOptions options)
{
_next = next ??
@@ -25,6 +27,8 @@ private ReadPersistedOperationMiddleware(
throw new ArgumentNullException(nameof(diagnosticEvents));
_operationDocumentStorage = operationDocumentStorage ??
throw new ArgumentNullException(nameof(operationDocumentStorage));
+ _documentHashAlgorithm = documentHashAlgorithm ??
+ throw new ArgumentNullException(nameof(documentHashAlgorithm));
_options = options;
}
@@ -53,45 +57,58 @@ await _operationDocumentStorage.TryReadAsync(
documentId.Value, context.RequestAborted)
.ConfigureAwait(false);
- if (operationDocument is OperationDocument parsedDoc)
+ if (operationDocument is not null)
{
context.DocumentId = documentId;
- context.Document = parsedDoc.Document;
+ context.Document = GetOrParseDocument(operationDocument);
+ context.DocumentHash = GetDocumentHash(operationDocument);
context.ValidationResult = DocumentValidatorResult.Ok;
context.IsCachedDocument = true;
context.IsPersistedDocument = true;
- if (_options.SkipPersistedDocumentValidation)
- {
- context.ValidationResult = DocumentValidatorResult.Ok;
- }
- _diagnosticEvents.RetrievedDocumentFromStorage(context);
- }
- if (operationDocument is OperationDocumentSourceText sourceTextDoc)
- {
- context.DocumentId = documentId;
- context.Document = Utf8GraphQLParser.Parse(sourceTextDoc.AsSpan());
- context.ValidationResult = DocumentValidatorResult.Ok;
- context.IsCachedDocument = true;
- context.IsPersistedDocument = true;
if (_options.SkipPersistedDocumentValidation)
{
context.ValidationResult = DocumentValidatorResult.Ok;
}
+
_diagnosticEvents.RetrievedDocumentFromStorage(context);
}
}
}
+ private static DocumentNode GetOrParseDocument(IOperationDocument document)
+ {
+ if (document is IOperationDocumentNodeProvider nodeProvider)
+ {
+ return nodeProvider.Document;
+ }
+
+ return Utf8GraphQLParser.Parse(document.AsSpan());
+ }
+
+ private string? GetDocumentHash(IOperationDocument document)
+ {
+ if (document is IOperationDocumentHashProvider hashProvider
+ && _documentHashAlgorithm.Name.Equals(hashProvider.Hash.AlgorithmName)
+ && _documentHashAlgorithm.Format.Equals(hashProvider.Hash.Format))
+ {
+ return hashProvider.Hash.Hash;
+ }
+
+ return null;
+ }
+
public static RequestCoreMiddleware Create()
=> (core, next) =>
{
var diagnosticEvents = core.SchemaServices.GetRequiredService();
var persistedOperationStore = core.SchemaServices.GetRequiredService();
+ var documentHashAlgorithm = core.Services.GetRequiredService();
var middleware = new ReadPersistedOperationMiddleware(
next,
diagnosticEvents,
persistedOperationStore,
+ documentHashAlgorithm,
core.Options.PersistedOperations);
return context => middleware.InvokeAsync(context);
};
diff --git a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/DocumentCacheMiddlewareTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/DocumentCacheMiddlewareTests.cs
index 7fc847ed2f8..049e28cb799 100644
--- a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/DocumentCacheMiddlewareTests.cs
+++ b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/DocumentCacheMiddlewareTests.cs
@@ -25,7 +25,7 @@ public async Task RetrieveItemFromCache_DocumentFoundOnCache()
.Build();
var document = Utf8GraphQLParser.Parse("{ a }");
- cache.TryAddDocument("a", new CachedDocument(document, false));
+ cache.TryAddDocument("a", new CachedDocument(document, null, false));
var requestContext = new Mock();
var schema = new Mock();
@@ -63,7 +63,7 @@ public async Task RetrieveItemFromCacheByHash_DocumentFoundOnCache()
.Build();
var document = Utf8GraphQLParser.Parse("{ a }");
- cache.TryAddDocument("a", new CachedDocument(document, false));
+ cache.TryAddDocument("a", new CachedDocument(document, null, false));
var requestContext = new Mock();
var schema = new Mock();
@@ -101,7 +101,7 @@ public async Task RetrieveItemFromCache_DocumentNotFoundOnCache()
.Build();
var document = Utf8GraphQLParser.Parse("{ a }");
- cache.TryAddDocument("b", new CachedDocument(document, false));
+ cache.TryAddDocument("b", new CachedDocument(document, null, false));
var requestContext = new Mock();
var schema = new Mock();
diff --git a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/ReadPersistedOperationMiddleware.cs b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/ReadPersistedOperationMiddleware.cs
new file mode 100644
index 00000000000..69762a3de55
--- /dev/null
+++ b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/ReadPersistedOperationMiddleware.cs
@@ -0,0 +1,209 @@
+using System.Text;
+using HotChocolate.Execution.Instrumentation;
+using HotChocolate.Execution.Options;
+using HotChocolate.Language;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+namespace HotChocolate.Execution.Pipeline;
+
+public class ReadPersistedOperationMiddlewareTests
+{
+ [Fact]
+ public async Task SetDocumentHash_On_Context_When_Algorithm_Matches()
+ {
+ // arrange
+ var hashAlgorithm = new Sha256DocumentHashProvider();
+
+ var documents = new Dictionary
+ {
+ {
+ new OperationDocumentId("a"),
+ new FakeDocumentWithHash(
+ "{ a }",
+ new OperationDocumentHash(
+ "abc",
+ hashAlgorithm.Name,
+ hashAlgorithm.Format))
+ }
+ };
+
+ var documentStore = new FakeDocumentStore(documents);
+
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton(documentStore);
+ services.AddSingleton(hashAlgorithm);
+
+ var options = new RequestExecutorOptions
+ {
+ PersistedOperations = new PersistedOperationOptions
+ {
+ SkipPersistedDocumentValidation = true
+ }
+ };
+
+ var factoryContext = new RequestCoreMiddlewareContext(
+ "Default",
+ services.BuildServiceProvider(),
+ services.BuildServiceProvider(),
+ options);
+
+ var schema = CreateSchema();
+ var errorHandler = new Mock();
+ var requestContext = new RequestContext(
+ schema,
+ 1,
+ errorHandler.Object,
+ new NoopExecutionDiagnosticEvents());
+
+ requestContext.Initialize(
+ OperationRequestBuilder.New()
+ .SetDocumentId("a")
+ .Build(),
+ services.BuildServiceProvider());
+
+ var middleware = ReadPersistedOperationMiddleware.Create();
+ var requestDelegate = middleware(factoryContext, async _ => await Task.CompletedTask);
+
+ // act
+ await requestDelegate(requestContext);
+
+ // assert
+ Assert.NotNull(requestContext.Document);
+ Assert.Equal("abc", requestContext.DocumentHash);
+ }
+
+ [Fact]
+ public async Task Do_Not_SetDocumentHash_On_Context_When_Algorithm_Does_Not_Match()
+ {
+ // arrange
+ var hashAlgorithm = new Sha256DocumentHashProvider();
+
+ var documents = new Dictionary
+ {
+ {
+ new OperationDocumentId("a"),
+ new FakeDocumentWithHash(
+ "{ a }",
+ new OperationDocumentHash(
+ "abc",
+ "abc",
+ hashAlgorithm.Format))
+ }
+ };
+
+ var documentStore = new FakeDocumentStore(documents);
+
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton(documentStore);
+ services.AddSingleton(hashAlgorithm);
+
+ var options = new RequestExecutorOptions
+ {
+ PersistedOperations = new PersistedOperationOptions
+ {
+ SkipPersistedDocumentValidation = true
+ }
+ };
+
+ var factoryContext = new RequestCoreMiddlewareContext(
+ "Default",
+ services.BuildServiceProvider(),
+ services.BuildServiceProvider(),
+ options);
+
+ var schema = CreateSchema();
+ var errorHandler = new Mock();
+ var requestContext = new RequestContext(
+ schema,
+ 1,
+ errorHandler.Object,
+ new NoopExecutionDiagnosticEvents());
+
+ requestContext.Initialize(
+ OperationRequestBuilder.New()
+ .SetDocumentId("a")
+ .Build(),
+ services.BuildServiceProvider());
+
+ var middleware = ReadPersistedOperationMiddleware.Create();
+ var requestDelegate = middleware(factoryContext, async _ => await Task.CompletedTask);
+
+ // act
+ await requestDelegate(requestContext);
+
+ // assert
+ Assert.NotNull(requestContext.Document);
+ Assert.Null(requestContext.DocumentHash);
+ }
+
+ private static ISchema CreateSchema()
+ => SchemaBuilder.New()
+ .AddDocumentFromString("type Query { a: String }")
+ .Use(_ => _)
+ .Create();
+
+ private sealed class FakeDocumentStore(
+ Dictionary documents)
+ : IOperationDocumentStorage
+ {
+ public ValueTask SaveAsync(
+ OperationDocumentId documentId,
+ IOperationDocument document,
+ CancellationToken cancellationToken = default)
+ {
+ if (documents.ContainsKey(documentId))
+ {
+ documents[documentId] = document;
+ }
+ else
+ {
+ documents.Add(documentId, document);
+ }
+
+ return default;
+ }
+
+ public async ValueTask TryReadAsync(
+ OperationDocumentId documentId,
+ CancellationToken cancellationToken = default)
+ {
+ if (documents.TryGetValue(documentId, out var document))
+ {
+ return await new ValueTask(document);
+ }
+
+ return null;
+ }
+ }
+
+ private sealed class FakeDocumentWithHash
+ : IOperationDocument
+ , IOperationDocumentHashProvider
+ {
+ public FakeDocumentWithHash(string document, OperationDocumentHash hash)
+ {
+ Document = document;
+ Hash = hash;
+ }
+
+ public string Document { get; }
+
+ public OperationDocumentHash Hash { get; }
+
+ public ReadOnlySpan AsSpan()
+ => Encoding.UTF8.GetBytes(Document).AsSpan();
+
+ public byte[] ToArray()
+ => Encoding.UTF8.GetBytes(Document);
+
+ public Task WriteToAsync(
+ Stream output,
+ CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+
+ public override string ToString() => Document;
+ }
+}
diff --git a/src/HotChocolate/Language/src/Language.Web/CachedDocument.cs b/src/HotChocolate/Language/src/Language.Web/CachedDocument.cs
index 7124f1aa8b3..b0130b87982 100644
--- a/src/HotChocolate/Language/src/Language.Web/CachedDocument.cs
+++ b/src/HotChocolate/Language/src/Language.Web/CachedDocument.cs
@@ -3,10 +3,9 @@ namespace HotChocolate.Language;
///
/// Represents a cached document.
///
-///
-///
public sealed class CachedDocument(
DocumentNode body,
+ string? hash,
bool isPersisted)
{
///
@@ -14,6 +13,11 @@ public sealed class CachedDocument(
///
public DocumentNode Body { get; } = body;
+ ///
+ /// Gets the hash of the document.
+ ///
+ public string? Hash { get; } = hash;
+
///
/// Defines if the document is a persisted document.
///
diff --git a/src/HotChocolate/Language/test/Language.Tests/Parser/GraphQLRequestParserTests.cs b/src/HotChocolate/Language/test/Language.Tests/Parser/GraphQLRequestParserTests.cs
index 2f26d075d7b..34f74ce45df 100644
--- a/src/HotChocolate/Language/test/Language.Tests/Parser/GraphQLRequestParserTests.cs
+++ b/src/HotChocolate/Language/test/Language.Tests/Parser/GraphQLRequestParserTests.cs
@@ -213,7 +213,7 @@ public void Parse_Kitchen_Sink_Query_With_Cache()
var first = requestParser.Parse();
- cache.TryAddDocument(first[0].QueryId!, new CachedDocument(first[0].Query!, false));
+ cache.TryAddDocument(first[0].QueryId!, new CachedDocument(first[0].Query!, null, false));
// act
requestParser = new Utf8GraphQLRequestParser(