From b9464345764750954c1636d1f5d6486f145d4224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20M=C3=A4der?= Date: Mon, 16 Dec 2024 15:08:27 +0100 Subject: [PATCH] Added Azure Blob Storage as storage backend for Persisted Operations (#7777) --- src/Directory.Packages.props | 7 +- .../HotChocolate.PersistedOperations.sln | 30 +++++ .../AzureBlobOperationDocumentStorage.cs | 118 +++++++++++++++++ ...rationsRequestExecutorBuilderExtensions.cs | 37 ++++++ ...edOperationsServiceCollectionExtensions.cs | 53 ++++++++ ...ersistedOperations.AzureBlobStorage.csproj | 33 +++++ .../Properties/Resources.Designer.cs | 63 +++++++++ .../Properties/Resources.resx | 101 +++++++++++++++ ...lobStorageOperationDocumentStorageTests.cs | 120 ++++++++++++++++++ ...edOperations.AzureBlobStorage.Tests.csproj | 30 +++++ .../IntegrationTests.cs | 95 ++++++++++++++ .../ModuleInitializer.cs | 13 ++ .../RequestExecutorBuilderTests.cs | 43 +++++++ .../ServiceCollectionExtensionsTests.cs | 61 +++++++++ ...s.Read_OperationDocument_From_Storage.snap | 3 + ...ts.Write_OperationDocument_To_Storage.snap | 3 + ...rationTests.ExecutePersistedOperation.snap | 8 ++ ...ts.ExecutePersistedOperation_NotFound.snap | 11 ++ ...rageOperationDocumentStorage_Services.snap | 6 + .../automatic-persisted-operations.md | 4 +- .../v15/performance/persisted-operations.md | 59 ++++++++- 21 files changed, 888 insertions(+), 10 deletions(-) create mode 100644 src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs create mode 100644 src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs create mode 100644 src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs create mode 100644 src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/HotChocolate.PersistedOperations.AzureBlobStorage.csproj create mode 100644 src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.Designer.cs create mode 100644 src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.resx create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/HotChocolate.PersistedOperations.AzureBlobStorage.Tests.csproj create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ModuleInitializer.cs create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/RequestExecutorBuilderTests.cs create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ServiceCollectionExtensionsTests.cs create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Read_OperationDocument_From_Storage.snap create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Write_OperationDocument_To_Storage.snap create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation.snap create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_NotFound.snap create mode 100644 src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/ServiceCollectionExtensionsTests.AddAzureBlobStorageOperationDocumentStorage_Services.snap diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8018a15419c..0e3634c253b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -2,7 +2,6 @@ true - @@ -11,6 +10,7 @@ + @@ -53,6 +53,7 @@ + @@ -63,7 +64,6 @@ - @@ -88,7 +88,6 @@ - @@ -113,13 +112,11 @@ - - diff --git a/src/HotChocolate/PersistedOperations/HotChocolate.PersistedOperations.sln b/src/HotChocolate/PersistedOperations/HotChocolate.PersistedOperations.sln index c1d6ff32b0e..831f3000d2e 100644 --- a/src/HotChocolate/PersistedOperations/HotChocolate.PersistedOperations.sln +++ b/src/HotChocolate/PersistedOperations/HotChocolate.PersistedOperations.sln @@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.PersistedOpera EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.PersistedOperations.InMemory.Tests", "test\PersistedOperations.InMemory.Tests\HotChocolate.PersistedOperations.InMemory.Tests.csproj", "{406929B9-06ED-428D-B478-A21B41B9BDC0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.PersistedOperations.AzureBlobStorage.Tests", "test\PersistedOperations.AzureBlobStorage.Tests\HotChocolate.PersistedOperations.AzureBlobStorage.Tests.csproj", "{705B7DF2-F2B6-4561-BDCC-365C92BD3E52}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.PersistedOperations.AzureBlobStorage", "src\PersistedOperations.AzureBlobStorage\HotChocolate.PersistedOperations.AzureBlobStorage.csproj", "{4F022EBA-58D1-419B-9667-7291316BD401}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -235,6 +239,30 @@ Global {406929B9-06ED-428D-B478-A21B41B9BDC0}.Release|x64.Build.0 = Release|Any CPU {406929B9-06ED-428D-B478-A21B41B9BDC0}.Release|x86.ActiveCfg = Release|Any CPU {406929B9-06ED-428D-B478-A21B41B9BDC0}.Release|x86.Build.0 = Release|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Debug|x64.ActiveCfg = Debug|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Debug|x64.Build.0 = Debug|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Debug|x86.ActiveCfg = Debug|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Debug|x86.Build.0 = Debug|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Release|Any CPU.Build.0 = Release|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Release|x64.ActiveCfg = Release|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Release|x64.Build.0 = Release|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Release|x86.ActiveCfg = Release|Any CPU + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52}.Release|x86.Build.0 = Release|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Debug|x64.ActiveCfg = Debug|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Debug|x64.Build.0 = Debug|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Debug|x86.ActiveCfg = Debug|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Debug|x86.Build.0 = Debug|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Release|Any CPU.Build.0 = Release|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Release|x64.ActiveCfg = Release|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Release|x64.Build.0 = Release|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Release|x86.ActiveCfg = Release|Any CPU + {4F022EBA-58D1-419B-9667-7291316BD401}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -255,6 +283,8 @@ Global {8BCB768F-4588-41A2-9C7F-7AE7F3FAC404} = {0FE82812-5154-4AF6-9053-487CBD1C5E74} {FDCD436C-3276-4F21-AC8F-E3DCA4EB41EB} = {9313BB55-188C-4067-9514-9317E83847BC} {406929B9-06ED-428D-B478-A21B41B9BDC0} = {A302B0E8-3C01-458D-8C2D-D59FF528287A} + {705B7DF2-F2B6-4561-BDCC-365C92BD3E52} = {A302B0E8-3C01-458D-8C2D-D59FF528287A} + {4F022EBA-58D1-419B-9667-7291316BD401} = {9313BB55-188C-4067-9514-9317E83847BC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FAA72987-7EEF-40D2-B232-34E3D16C9489} diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs new file mode 100644 index 00000000000..e94ae95557c --- /dev/null +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs @@ -0,0 +1,118 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using HotChocolate.Execution; +using HotChocolate.Language; + +namespace HotChocolate.PersistedOperations.AzureBlobStorage; + +/// +/// An implementation of that uses Redis as a storage. +/// +public class AzureBlobOperationDocumentStorage : IOperationDocumentStorage +{ + private static readonly BlobOpenWriteOptions _defaultBlobOpenWriteOptions = new() + { + HttpHeaders = new BlobHttpHeaders + { + ContentType = "application/graphql", + ContentDisposition = "inline", + CacheControl = "public, max-age=604800, immutable" + } + }; + + private readonly BlobContainerClient _blobContainerClient; + private readonly string _blobNamePrefix; + private readonly string _blobNameSuffix; + + /// + /// Initializes a new instance of the class. + /// + /// The blob container client instance. + /// This prefix string is prepended before the hash of the document. + /// This suffix is appended after the hash of the document. + public AzureBlobOperationDocumentStorage( + BlobContainerClient containerClient, + string blobNamePrefix, + string blobNameSuffix) + { + ArgumentNullException.ThrowIfNull(containerClient); + ArgumentNullException.ThrowIfNull(blobNamePrefix); + ArgumentNullException.ThrowIfNull(blobNameSuffix); + + _blobContainerClient = containerClient; + _blobNamePrefix = blobNamePrefix; + _blobNameSuffix = blobNameSuffix; + } + + /// + public ValueTask TryReadAsync( + OperationDocumentId documentId, + CancellationToken cancellationToken = default) + { + if (OperationDocumentId.IsNullOrEmpty(documentId)) + { + throw new ArgumentNullException(nameof(documentId)); + } + + return TryReadInternalAsync(documentId, cancellationToken); + } + + private async ValueTask TryReadInternalAsync( + OperationDocumentId documentId, + CancellationToken cancellationToken) + { + var blobClient = _blobContainerClient.GetBlobClient(BlobName(documentId)); + + try + { + await using var blobStream = await blobClient + .OpenReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + await using var memoryStream = new MemoryStream(); + await blobStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + return memoryStream.Length == 0 + ? null + : new OperationDocument(Utf8GraphQLParser.Parse(memoryStream.ToArray())); + } + catch (RequestFailedException e) + { + if (e.Status == 404) + { + return null; + } + + throw; + } + } + + /// + public ValueTask SaveAsync( + OperationDocumentId documentId, + IOperationDocument document, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(document); + if (OperationDocumentId.IsNullOrEmpty(documentId)) + { + throw new ArgumentNullException(nameof(documentId)); + } + + return SaveInternalAsync(documentId, document, cancellationToken); + } + + private async ValueTask SaveInternalAsync( + OperationDocumentId documentId, + IOperationDocument document, + CancellationToken cancellationToken) + { + var blobClient = _blobContainerClient.GetBlobClient(BlobName(documentId)); + await using var outStream = await blobClient + .OpenWriteAsync(true, _defaultBlobOpenWriteOptions, cancellationToken).ConfigureAwait(false); + + await document.WriteToAsync(outStream, cancellationToken).ConfigureAwait(false); + await outStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private string BlobName(OperationDocumentId documentId) => $"{_blobNamePrefix}{documentId.Value}{_blobNameSuffix}"; +} diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs new file mode 100644 index 00000000000..04968342032 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs @@ -0,0 +1,37 @@ +using Azure.Storage.Blobs; +using HotChocolate.Execution.Configuration; +using HotChocolate; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides utility methods to setup dependency injection. +/// +public static class HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions +{ + /// + /// Adds an Azure Blob Storage based operation document storage to the service collection. + /// + /// + /// The service collection to which the services are added. + /// + /// + /// A factory that resolves the Azure Blob Container Client that + /// shall be used for persistence. + /// + /// This prefix string is prepended before the hash of the document. + /// This suffix is appended after the hash of the document. + public static IRequestExecutorBuilder AddAzureBlobStorageOperationDocumentStorage( + this IRequestExecutorBuilder builder, + Func containerClientFactory, + string blobNamePrefix = "", + string blobNameSuffix = ".graphql") + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(containerClientFactory); + + return builder.ConfigureSchemaServices( + s => s.AddAzureBlobStorageOperationDocumentStorage( + sp => containerClientFactory(sp.GetCombinedServices()), blobNamePrefix, blobNameSuffix)); + } +} diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs new file mode 100644 index 00000000000..6b8cfc029a2 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using Azure.Storage.Blobs; +using HotChocolate.Execution; +using HotChocolate.PersistedOperations.AzureBlobStorage; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate; + +/// +/// Provides utility methods to setup dependency injection. +/// +public static class HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions +{ + /// + /// Adds an Azure Blob Storage based operation document storage to the service collection. + /// + /// + /// The service collection to which the services are added. + /// + /// + /// A factory that resolves the Azure Blob Container Client that + /// shall be used for persistence. + /// + /// This prefix string is prepended before the hash of the document. + /// This suffix is appended after the hash of the document. + public static IServiceCollection AddAzureBlobStorageOperationDocumentStorage( + this IServiceCollection services, + Func containerClientFactory, + string blobNamePrefix = "", + string blobNameSuffix = ".graphql" + ) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(containerClientFactory); + + return services + .RemoveService() + .AddSingleton( + sp => new AzureBlobOperationDocumentStorage(containerClientFactory(sp), blobNamePrefix, blobNameSuffix)); + } + + private static IServiceCollection RemoveService( + this IServiceCollection services) + { + var serviceDescriptor = services.FirstOrDefault(t => t.ServiceType == typeof(TService)); + + if (serviceDescriptor != null) + { + services.Remove(serviceDescriptor); + } + + return services; + } +} diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/HotChocolate.PersistedOperations.AzureBlobStorage.csproj b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/HotChocolate.PersistedOperations.AzureBlobStorage.csproj new file mode 100644 index 00000000000..072c72b42fd --- /dev/null +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/HotChocolate.PersistedOperations.AzureBlobStorage.csproj @@ -0,0 +1,33 @@ + + + + HotChocolate.PersistedOperations.AzureBlobStorage + An implementation of Hot Chocolate persisted operations using an Azure (R) Storage Account. + true + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.Designer.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.Designer.cs new file mode 100644 index 00000000000..4cecb452b18 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace HotChocolate.PersistedOperations.FileSystem.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.PersistedOperations.FileSystem.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.resx b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.resx new file mode 100644 index 00000000000..31bd38f32c8 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Properties/Resources.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs new file mode 100644 index 00000000000..15015f99de9 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs @@ -0,0 +1,120 @@ +using Azure.Storage.Blobs; +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.Language.Utilities; +using Squadron; + +namespace HotChocolate.PersistedOperations.AzureBlobStorage; + +public class AzureBlobStorageOperationDocumentStorageTests : IClassFixture +{ + private readonly BlobContainerClient _client; + private const string Prefix = "hc_"; + private const string Suffix = ".graphql"; + + public AzureBlobStorageOperationDocumentStorageTests(AzureStorageBlobResource blobStorageResource) + { + _client = blobStorageResource.CreateBlobServiceClient().GetBlobContainerClient("test"); + _client.CreateIfNotExists(); + } + + [Fact] + public async Task Write_OperationDocument_To_Storage() + { + // arrange + var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var document = new OperationDocumentSourceText("{ foo }"); + + // act + await storage.SaveAsync(documentId, document); + + // assert + var actual = await ReadBlob(documentId.Value); + actual.MatchSnapshot(); + + await DeleteBlob(documentId.Value); + } + + [Fact] + public async Task Write_OperationDocument_documentId_Invalid() + { + // arrange + var documentId = new OperationDocumentId(); + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var document = new OperationDocumentSourceText("{ foo }"); + + // act + async Task Action() => await storage.SaveAsync(documentId, document); + + // assert + await Assert.ThrowsAsync(Action); + } + + [Fact] + public async Task Write_OperationDocument_OperationDocument_Is_Null() + { + // arrange + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); + + // act + async Task Action() => await storage.SaveAsync(documentId, null!); + + // assert + await Assert.ThrowsAsync(Action); + } + + [Fact] + public async Task Read_OperationDocument_From_Storage() + { + // arrange + var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var buffer = "{ foo }"u8.ToArray(); + await WriteBlob(documentId.Value, buffer); + + // act + var document = await storage.TryReadAsync(documentId); + + // assert + Assert.NotNull(document); + Assert.IsType(document).Document.Print().MatchSnapshot(); + + await DeleteBlob(documentId.Value); + } + + [Fact] + public async Task Read_OperationDocument_documentId_Invalid() + { + // arrange + var documentId = new OperationDocumentId(); + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + + // act + async Task Action() => await storage.TryReadAsync(documentId); + + // assert + await Assert.ThrowsAsync(Action); + } + + private async Task ReadBlob(string key) + { + await using var mem = new MemoryStream(); + await using var blob = await _client.GetBlobClient(BlobName(key)).OpenReadAsync(); + await blob.CopyToAsync(mem); + var value = Utf8GraphQLParser.Parse(mem.ToArray()).Print(); + return value; + } + + private async Task WriteBlob(string key, byte[] buffer) + { + await using var @out = await _client.GetBlobClient(BlobName(key)).OpenWriteAsync(true); + await @out.WriteAsync(buffer); + await @out.FlushAsync(); + } + + private async Task DeleteBlob(string key) => await _client.DeleteBlobAsync(BlobName(key)); + + private static string BlobName(string key) => $"{Prefix}{key}{Suffix}"; +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/HotChocolate.PersistedOperations.AzureBlobStorage.Tests.csproj b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/HotChocolate.PersistedOperations.AzureBlobStorage.Tests.csproj new file mode 100644 index 00000000000..afdd8224c1b --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/HotChocolate.PersistedOperations.AzureBlobStorage.Tests.csproj @@ -0,0 +1,30 @@ + + + + HotChocolate.PersistedOperations.AzureBlobStorage.Tests + HotChocolate.PersistedOperations.AzureBlobStorage + + + + + + + + + + + + + + Always + + + Always + + + + + + + + diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs new file mode 100644 index 00000000000..8199361e1f2 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs @@ -0,0 +1,95 @@ +using Azure.Storage.Blobs; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Squadron; + +namespace HotChocolate.PersistedOperations.AzureBlobStorage; + +public class IntegrationTests : IClassFixture +{ + private const string Prefix = "hc_"; + private const string Suffix = ".graphql"; + + private readonly BlobContainerClient _client; + + public IntegrationTests(AzureStorageBlobResource blobStorageResource) + { + _client = blobStorageResource.CreateBlobServiceClient().GetBlobContainerClient("test"); + _client.CreateIfNotExists(); + } + + [Fact] + public async Task ExecutePersistedOperation() + { + // arrange + var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + + await storage.SaveAsync( + documentId, + new OperationDocumentSourceText("{ __typename }")); + + var executor = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(c => c.Name("Query").Field("a").Resolve("b")) + .AddAzureBlobStorageOperationDocumentStorage(_ => _client, Prefix, Suffix) + .UseRequest(n => async c => + { + await n(c); + + if (c is { IsPersistedDocument: true, Result: IOperationResult r }) + { + c.Result = OperationResultBuilder + .FromResult(r) + .SetExtension("persistedDocument", true) + .Build(); + } + }) + .UsePersistedOperationPipeline() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync(OperationRequest.FromId(documentId)); + + // assert + result.MatchSnapshot(); + } + + [Fact] + public async Task ExecutePersistedOperation_NotFound() + { + // arrange + var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); + var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + await storage.SaveAsync(documentId, new OperationDocumentSourceText("{ __typename }")); + + var executor = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(c => c.Name("Query").Field("a").Resolve("b")) + .AddAzureBlobStorageOperationDocumentStorage(_ => _client, Prefix, Suffix) + .UseRequest(n => async c => + { + await n(c); + + if (c.IsPersistedDocument && c.Result is IOperationResult r) + { + c.Result = OperationResultBuilder + .FromResult(r) + .SetExtension("persistedDocument", true) + .Build(); + } + }) + .UsePersistedOperationPipeline() + .BuildRequestExecutorAsync(); + + // act + var result = + await executor.ExecuteAsync(OperationRequest.FromId("does_not_exist")); + + // assert + result.MatchSnapshot(); + } +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ModuleInitializer.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ModuleInitializer.cs new file mode 100644 index 00000000000..16c799aa06d --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ModuleInitializer.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +namespace HotChocolate.PersistedOperations.AzureBlobStorage; + +internal static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + CookieCrumbleXunit.Initialize(); + CookieCrumbleHotChocolate.Initialize(); + } +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/RequestExecutorBuilderTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/RequestExecutorBuilderTests.cs new file mode 100644 index 00000000000..d220e13e151 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/RequestExecutorBuilderTests.cs @@ -0,0 +1,43 @@ +using Azure.Storage.Blobs; +using Microsoft.Extensions.DependencyInjection; +using Squadron; + +namespace HotChocolate.PersistedOperations.AzureBlobStorage; + +public class RequestExecutorBuilderTests : IClassFixture +{ + private readonly BlobContainerClient _client; + + public RequestExecutorBuilderTests(AzureStorageBlobResource blobStorageResource) + { + _client = blobStorageResource.CreateBlobServiceClient().GetBlobContainerClient("test"); + _client.CreateIfNotExists(); + } + + [Fact] + public void AddAzureBlobStorageOperationDocumentStorage_Services_Is_Null() + { + // arrange + // act + void Action() => + HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions + .AddAzureBlobStorageOperationDocumentStorage(null!, _ => _client); + + // assert + Assert.Throws(Action); + } + + [Fact] + public void AddAzureBlobStorageOperationDocumentStorage_Factory_Is_Null() + { + // arrange + var builder = new ServiceCollection().AddGraphQL(); + + // act + void Action() => + builder.AddAzureBlobStorageOperationDocumentStorage(default!); + + // assert + Assert.Throws(Action); + } +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ServiceCollectionExtensionsTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000000..3cb9726efbc --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,61 @@ +using Azure.Storage.Blobs; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Utilities; +using Squadron; + +namespace HotChocolate.PersistedOperations.AzureBlobStorage; + +public class ServiceCollectionExtensionsTests : IClassFixture +{ + private readonly BlobContainerClient _client; + + public ServiceCollectionExtensionsTests(AzureStorageBlobResource blobStorageResource) + { + _client = blobStorageResource.CreateBlobServiceClient().GetBlobContainerClient("test"); + _client.CreateIfNotExists(); + } + + [Fact] + public void AddAzureBlobStorageOperationDocumentStorage_Services_Is_Null() + { + // arrange + // act + void Action() + => HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions + .AddAzureBlobStorageOperationDocumentStorage(null!, _ => _client); + + // assert + Assert.Throws(Action); + } + + [Fact] + public void AddAzureBlobStorageOperationDocumentStorage_Factory_Is_Null() + { + // arrange + var services = new ServiceCollection(); + + // act + void Action() + => services.AddAzureBlobStorageOperationDocumentStorage(null!); + + // assert + Assert.Throws(Action); + } + + [Fact] + public void AddAzureBlobStorageOperationDocumentStorage_Services() + { + // arrange + var services = new ServiceCollection(); + + // act + services.AddAzureBlobStorageOperationDocumentStorage(_ => _client); + + // assert + services.ToDictionary( + k => k.ServiceType.GetTypeName(), + v => v.ImplementationType?.GetTypeName()) + .OrderBy(t => t.Key) + .MatchSnapshot(); + } +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Read_OperationDocument_From_Storage.snap b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Read_OperationDocument_From_Storage.snap new file mode 100644 index 00000000000..62df75952cd --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Read_OperationDocument_From_Storage.snap @@ -0,0 +1,3 @@ +{ + foo +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Write_OperationDocument_To_Storage.snap b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Write_OperationDocument_To_Storage.snap new file mode 100644 index 00000000000..62df75952cd --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/AzureBlobStorageOperationDocumentStorageTests.Write_OperationDocument_To_Storage.snap @@ -0,0 +1,3 @@ +{ + foo +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation.snap b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation.snap new file mode 100644 index 00000000000..20b47cc26bc --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation.snap @@ -0,0 +1,8 @@ +{ + "data": { + "__typename": "Query" + }, + "extensions": { + "persistedDocument": true + } +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_NotFound.snap b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_NotFound.snap new file mode 100644 index 00000000000..bfebe739706 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_NotFound.snap @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "The specified persisted operation key is invalid.", + "extensions": { + "code": "HC0020", + "requestedKey": "does_not_exist" + } + } + ] +} diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/ServiceCollectionExtensionsTests.AddAzureBlobStorageOperationDocumentStorage_Services.snap b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/ServiceCollectionExtensionsTests.AddAzureBlobStorageOperationDocumentStorage_Services.snap new file mode 100644 index 00000000000..d08a8dba709 --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/__snapshots__/ServiceCollectionExtensionsTests.AddAzureBlobStorageOperationDocumentStorage_Services.snap @@ -0,0 +1,6 @@ +[ + { + "Key": "HotChocolate.Execution.IOperationDocumentStorage", + "Value": null + } +] diff --git a/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md b/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md index 7e8c3cea222..cd5679fbff5 100644 --- a/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md +++ b/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md @@ -213,9 +213,9 @@ curl -g 'http://localhost:5000/graphql/?query={__typename}&extensions={"persiste } ``` -## Step 4: Use Redis as an operation document storage +## Step 4: Use a persisted operation document storage -If you run multiple Hot Chocolate server instances and want to preserve stored operation documents after a server restart, you can opt to use a file-system-based operation document storage or opt to use a Redis cache. Hot Chocolate server supports both. +If you run multiple Hot Chocolate server instances and want to preserve stored operation documents after a server restart, you can opt to use a persisted operation document storage. Hot Chocolate supports a file-system-based operation document storage, Azure Blob Storage or a Redis cache. See [the persisted operations manual](/docs/hotchocolate/v15/performance/persisted-operations) to learn more about each option. The following example uses a Redis cache. 1. Setup a Redis docker container. diff --git a/website/src/docs/hotchocolate/v15/performance/persisted-operations.md b/website/src/docs/hotchocolate/v15/performance/persisted-operations.md index 9635c5c9c72..8756e73211b 100644 --- a/website/src/docs/hotchocolate/v15/performance/persisted-operations.md +++ b/website/src/docs/hotchocolate/v15/performance/persisted-operations.md @@ -86,7 +86,7 @@ This file is expected to contain the operation document that the hash was genera > Warning: Do not forget to ensure that the server has access to the directory. -### Redis +## Redis To load persisted operation documents from Redis, we have to add the following package. @@ -108,7 +108,60 @@ public void ConfigureServices(IServiceCollection services) Keys in the specified Redis database are expected to be operation IDs (hashes) and contain the actual operation document as the value. -## Hashing algorithms +## Azure Blob Storage + +To load persisted operation documents from Azure Blob Storage, we have to add the following package. + + + +After this we need to specify where the persisted operation documents are located. Using `AddAzureBlobStorageOperationDocumentStorage()` we can point to a specific Azure Blob Storage Container. It must contain the operation documents. The blob's name is the hash of the query, its content the corresponding GraphQL query. + +> Important: The Azure Blob Storage Container must already exist when Hot Chocolate uses it for the first time. + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddGraphQLServer() + .AddQueryType() + .UsePersistedOperationPipeline() + .AddAzureBlobStorageOperationDocumentStorage(services => + services.GetService().GetBlobContainerClient("hotchocolate")); +} +``` + +Unlike with Redis, a Blob Storage client has no easy way to set the expiration of files in Azure Blob Storage. However, you can define [a Lifecycle Management Policy](https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview?tabs=azure-portal). The following sample policy will instruct Azure to remove all files from the `hotchocolate` container when they have not been accessed for 10 days. + +```json +{ + "rules": [ + { + "enabled": true, + "name": "remove-after-10d", + "type": "Lifecycle", + "definition": { + "actions": { + "baseBlob": { + "delete": { + "daysAfterLastAccessTimeGreaterThan": 10 + } + } + }, + "filters": { + "blobTypes": [ + "blockBlob" + ], + "prefixMatch": [ + "hotchocolate/" + ] + } + } + } + ] +} +``` + +# Hashing algorithms Per default Hot Chocolate uses the MD5 hashing algorithm, but we can override this default by specifying a `DocumentHashProvider`. @@ -138,7 +191,7 @@ AddSha256DocumentHashProvider(HashFormat.Base64) > Note: [Relay](https://relay.dev) uses the MD5 hashing algorithm - no additional Hot Chocolate configuration is required. -## Blocking regular operations +# Blocking regular operations If you want to disallow any dynamic operations, you can enable `OnlyAllowPersistedDocuments`: