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(