Browse Source

Support zstd Content-Encoding (#65479)

* Add Zstandard (zstd) content encoding support to response compression middleware
* Add Zstandard (zstd) content encoding support to request decompression middleware
Emmanuel André 1 week ago
parent
commit
2cf27e22c5

+ 1 - 0
src/Middleware/RequestDecompression/src/RequestDecompressionOptions.cs

@@ -13,6 +13,7 @@ public sealed class RequestDecompressionOptions
     /// </summary>
     public IDictionary<string, IDecompressionProvider> DecompressionProviders { get; } = new Dictionary<string, IDecompressionProvider>(StringComparer.OrdinalIgnoreCase)
     {
+        ["zstd"] = new ZstandardDecompressionProvider(),
         ["br"] = new BrotliDecompressionProvider(),
         ["deflate"] = new DeflateDecompressionProvider(),
         ["gzip"] = new GZipDecompressionProvider()

+ 18 - 0
src/Middleware/RequestDecompression/src/ZstandardDecompressionProvider.cs

@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO.Compression;
+
+namespace Microsoft.AspNetCore.RequestDecompression;
+
+/// <summary>
+/// Zstandard decompression provider.
+/// </summary>
+internal sealed class ZstandardDecompressionProvider : IDecompressionProvider
+{
+    /// <inheritdoc />
+    public Stream GetDecompressionStream(Stream stream)
+    {
+        return new ZstandardStream(stream, CompressionMode.Decompress, leaveOpen: true);
+    }
+}

+ 2 - 0
src/Middleware/RequestDecompression/test/DefaultRequestDecompressionProviderTests.cs

@@ -14,6 +14,8 @@ namespace Microsoft.AspNetCore.RequestDecompression.Tests;
 public class DefaultRequestDecompressionProviderTests
 {
     [Theory]
+    [InlineData("zstd", typeof(ZstandardStream))]
+    [InlineData("ZSTD", typeof(ZstandardStream))]
     [InlineData("br", typeof(BrotliStream))]
     [InlineData("BR", typeof(BrotliStream))]
     [InlineData("deflate", typeof(ZLibStream))]

+ 24 - 0
src/Middleware/RequestDecompression/test/RequestDecompressionMiddlewareTests.cs

@@ -75,6 +75,14 @@ public class RequestDecompressionMiddlewareTests
         return await GetCompressedContent(compressorDelegate, uncompressedBytes);
     }
 
+    private static async Task<byte[]> GetZstdCompressedContent(byte[] uncompressedBytes)
+    {
+        static Stream compressorDelegate(Stream compressedContent) =>
+            new ZstandardStream(compressedContent, CompressionMode.Compress);
+
+        return await GetCompressedContent(compressorDelegate, uncompressedBytes);
+    }
+
     [Fact]
     public async Task Request_ContentEncodingBrotli_Decompressed()
     {
@@ -139,6 +147,22 @@ public class RequestDecompressionMiddlewareTests
         Assert.Equal(uncompressedBytes, decompressedBytes);
     }
 
+    [Fact]
+    public async Task Request_ContentEncodingZstd_Decompressed()
+    {
+        // Arrange
+        var contentEncoding = "zstd";
+        var uncompressedBytes = GetUncompressedContent();
+        var compressedBytes = await GetZstdCompressedContent(uncompressedBytes);
+
+        // Act
+        var (logMessages, decompressedBytes) = await InvokeMiddleware(compressedBytes, new[] { contentEncoding });
+
+        // Assert
+        AssertDecompressedWithLog(logMessages, contentEncoding.ToLowerInvariant());
+        Assert.Equal(uncompressedBytes, decompressedBytes);
+    }
+
     [Fact]
     public async Task Request_NoContentEncoding_NotDecompressed()
     {

+ 4 - 1
src/Middleware/RequestDecompression/test/RequestDecompressionOptionsTests.cs

@@ -9,7 +9,7 @@ public class RequestDecompressionOptionsTests
     public void Options_InitializedWithDefaultProviders()
     {
         // Arrange
-        var defaultProviderCount = 3;
+        var defaultProviderCount = 4;
 
         // Act
         var options = new RequestDecompressionOptions();
@@ -18,6 +18,9 @@ public class RequestDecompressionOptionsTests
         var providers = options.DecompressionProviders;
         Assert.Equal(defaultProviderCount, providers.Count);
 
+        var zstdProvider = Assert.Contains("zstd", providers);
+        Assert.IsType<ZstandardDecompressionProvider>(zstdProvider);
+
         var brotliProvider = Assert.Contains("br", providers);
         Assert.IsType<BrotliDecompressionProvider>(brotliProvider);
 

+ 5 - 0
src/Middleware/ResponseCompression/sample/Startup.cs

@@ -12,8 +12,13 @@ public class Startup
     public void ConfigureServices(IServiceCollection services)
     {
         services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
+        services.Configure<ZstandardCompressionProviderOptions>(options =>
+        {
+            options.CompressionOptions = new ZstandardCompressionOptions { Quality = 1 };
+        });
         services.AddResponseCompression(options =>
         {
+            options.Providers.Add<ZstandardCompressionProvider>();
             options.Providers.Add<GzipCompressionProvider>();
             options.Providers.Add<CustomCompressionProvider>();
             // .Append(TItem) is only available on Core.

+ 9 - 0
src/Middleware/ResponseCompression/src/PublicAPI.Unshipped.txt

@@ -1 +1,10 @@
 #nullable enable
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProvider
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProvider.CreateStream(System.IO.Stream! outputStream) -> System.IO.Stream!
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProvider.EncodingName.get -> string!
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProvider.SupportsFlush.get -> bool
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProvider.ZstandardCompressionProvider(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProviderOptions!>! options) -> void
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProviderOptions
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProviderOptions.CompressionOptions.get -> System.IO.Compression.ZstandardCompressionOptions!
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProviderOptions.CompressionOptions.set -> void
+Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProviderOptions.ZstandardCompressionProviderOptions() -> void

+ 4 - 3
src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs

@@ -37,11 +37,12 @@ public class ResponseCompressionProvider : IResponseCompressionProvider
         _providers = responseCompressionOptions.Providers.ToArray();
         if (_providers.Length == 0)
         {
-            // Use the factory so it can resolve IOptions<GzipCompressionProviderOptions> from DI.
+            // Use the factory so the default compression providers and their options can be resolved from DI.
             _providers = new ICompressionProvider[]
             {
-                    new CompressionProviderFactory(typeof(BrotliCompressionProvider)),
-                    new CompressionProviderFactory(typeof(GzipCompressionProvider)),
+                new CompressionProviderFactory(typeof(ZstandardCompressionProvider)),
+                new CompressionProviderFactory(typeof(BrotliCompressionProvider)),
+                new CompressionProviderFactory(typeof(GzipCompressionProvider)),
             };
         }
         for (var i = 0; i < _providers.Length; i++)

+ 38 - 0
src/Middleware/ResponseCompression/src/ZstandardCompressionProvider.cs

@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO.Compression;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.ResponseCompression;
+
+/// <summary>
+/// Zstandard compression provider.
+/// </summary>
+public class ZstandardCompressionProvider : ICompressionProvider
+{
+    /// <summary>
+    /// Creates a new instance of <see cref="ZstandardCompressionProvider"/> with options.
+    /// </summary>
+    /// <param name="options">The options for this instance.</param>
+    public ZstandardCompressionProvider(IOptions<ZstandardCompressionProviderOptions> options)
+    {
+        ArgumentNullException.ThrowIfNull(options);
+
+        Options = options.Value;
+    }
+
+    private ZstandardCompressionProviderOptions Options { get; }
+
+    /// <inheritdoc />
+    public string EncodingName { get; } = "zstd";
+
+    /// <inheritdoc />
+    public bool SupportsFlush { get; } = true;
+
+    /// <inheritdoc />
+    public Stream CreateStream(Stream outputStream)
+    {
+        return new ZstandardStream(outputStream, Options.CompressionOptions, leaveOpen: true);
+    }
+}

+ 21 - 0
src/Middleware/ResponseCompression/src/ZstandardCompressionProviderOptions.cs

@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO.Compression;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.ResponseCompression;
+
+/// <summary>
+/// Options for the <see cref="ZstandardCompressionProvider"/>
+/// </summary>
+public class ZstandardCompressionProviderOptions : IOptions<ZstandardCompressionProviderOptions>
+{
+    /// <summary>
+    /// The compression options to use for the stream.
+    /// </summary>
+    public ZstandardCompressionOptions CompressionOptions { get; set; } = new();
+
+    /// <inheritdoc />
+    ZstandardCompressionProviderOptions IOptions<ZstandardCompressionProviderOptions>.Value => this;
+}

+ 42 - 29
src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs

@@ -22,20 +22,15 @@ public class ResponseCompressionMiddlewareTest
 {
     private const string TextPlain = "text/plain";
 
-    public static IEnumerable<object[]> SupportedEncodings =>
-        TestData.Select(x => new object[] { x.EncodingName });
-
-    public static IEnumerable<object[]> SupportedEncodingsWithBodyLength =>
-        TestData.Select(x => new object[] { x.EncodingName, x.ExpectedBodyLength });
+    private static readonly string[] _supportedEncodings =
+        [
+            "gzip",
+            "br",
+            "zstd"
+        ];
 
-    private static IEnumerable<EncodingTestData> TestData
-    {
-        get
-        {
-            yield return new EncodingTestData("gzip", expectedBodyLength: 29);
-            yield return new EncodingTestData("br", expectedBodyLength: 21);
-        }
-    }
+    public static IEnumerable<object[]> SupportedEncodings =>
+        _supportedEncodings.Select(encoding => new[] { encoding });
 
     [Fact]
     public void Options_HttpsDisabledByDefault()
@@ -72,10 +67,34 @@ public class ResponseCompressionMiddlewareTest
         AssertCompressedWithLog(logMessages, "br");
     }
 
+    [Fact]
+    public async Task Request_AcceptZstd_CompressedZstd()
+    {
+        var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "zstd" }, responseType: TextPlain);
+
+        await CheckResponseCompressed(response, "zstd");
+        AssertCompressedWithLog(logMessages, "zstd");
+    }
+
+    [Theory]
+    [InlineData("zstd", "gzip")]
+    [InlineData("gzip", "zstd")]
+    [InlineData("zstd", "br")]
+    [InlineData("br", "zstd")]
+    [InlineData("zstd", "gzip", "br")]
+    [InlineData("br", "gzip", "zstd")]
+    public async Task Request_AcceptMixed_CompressedZstd(params string[] encodings)
+    {
+        var (response, logMessages) = await InvokeMiddleware(100, encodings, responseType: TextPlain);
+
+        await CheckResponseCompressed(response, "zstd");
+        AssertCompressedWithLog(logMessages, "zstd");
+    }
+
     [Theory]
     [InlineData("gzip", "br")]
     [InlineData("br", "gzip")]
-    public async Task Request_AcceptMixed_CompressedBrotli(string encoding1, string encoding2)
+    public async Task Request_AcceptMixed_NoBestMatch_CompressedBrotli(string encoding1, string encoding2)
     {
         var (response, logMessages) = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain);
 
@@ -345,8 +364,8 @@ public class ResponseCompressionMiddlewareTest
     {
         var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "*" }, responseType: TextPlain);
 
-        await CheckResponseCompressed(response, "br");
-        AssertCompressedWithLog(logMessages, "br");
+        await CheckResponseCompressed(response, "zstd");
+        AssertCompressedWithLog(logMessages, "zstd");
     }
 
     [Fact]
@@ -1353,6 +1372,13 @@ public class ResponseCompressionMiddlewareTest
             using var reader = new StreamReader(brotliStream);
             decompressedContent = await reader.ReadToEndAsync();
         }
+        else if (expectedEncoding == "zstd")
+        {
+            using var compressedStream = new MemoryStream(compressedBytes);
+            using var zstdStream = new ZstandardStream(compressedStream, CompressionMode.Decompress);
+            using var reader = new StreamReader(zstdStream);
+            decompressedContent = await reader.ReadToEndAsync();
+        }
         else
         {
             throw new ArgumentException($"Unsupported encoding: {expectedEncoding}");
@@ -1437,19 +1463,6 @@ public class ResponseCompressionMiddlewareTest
         public Task StartAsync(CancellationToken token = default) => InnerFeature.StartAsync(token);
     }
 
-    private readonly struct EncodingTestData
-    {
-        public EncodingTestData(string encodingName, int expectedBodyLength)
-        {
-            EncodingName = encodingName;
-            ExpectedBodyLength = expectedBodyLength;
-        }
-
-        public string EncodingName { get; }
-
-        public int ExpectedBodyLength { get; }
-    }
-
     private class NoSyncWrapperStream : Stream
     {
         private readonly Stream _body;