From 767b3efa04fc2bc32f0ae7fc8849b88b90c6beb2 Mon Sep 17 00:00:00 2001 From: Kristian Hellang Date: Tue, 10 Jul 2018 21:44:01 +0200 Subject: [PATCH] Add Brotli compression provider (#342) * Added failing test for Brotli compression * Added Brotli compression provider * Add Brotli to existing test cases * Add failing test for accept encoding order * Use compression provider order when selecting provider * Some test cleanup * PR feedback * Added benchmarks for GetCompressionProvider * Added Brotli configuration order test * PR feedback * Switch Brotli and Gzip priority --- BasicMiddleware.sln | 9 + .../AssemblyInfo.cs | 4 + ...Core.ResponseCompression.Benchmarks.csproj | 19 ++ .../ResponseCompressionProviderBenchmark.cs | 57 +++++ build/dependencies.props | 3 + .../BrotliCompressionProvider.cs | 51 ++++ .../BrotliCompressionProviderOptions.cs | 22 ++ .../GzipCompressionProvider.cs | 2 +- ...soft.AspNetCore.ResponseCompression.csproj | 2 +- .../ResponseCompressionOptions.cs | 3 +- .../ResponseCompressionProvider.cs | 122 ++++++++-- .../ResponseCompressionMiddlewareTest.cs | 222 +++++++++++++----- 12 files changed, 428 insertions(+), 88 deletions(-) create mode 100644 benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/AssemblyInfo.cs create mode 100644 benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj create mode 100644 benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/ResponseCompressionProviderBenchmark.cs create mode 100644 src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProvider.cs create mode 100644 src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProviderOptions.cs diff --git a/BasicMiddleware.sln b/BasicMiddleware.sln index 8810201f5a..4bfc2ad2c5 100644 --- a/BasicMiddleware.sln +++ b/BasicMiddleware.sln @@ -76,6 +76,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HostFi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HostFiltering", "src\Microsoft.AspNetCore.HostFiltering\Microsoft.AspNetCore.HostFiltering.csproj", "{762F7276-C916-4111-A6C0-41668ABB3823}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{C6DA6317-30FC-42FE-891C-64E75D88FF12}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.ResponseCompression.Benchmarks", "benchmarks\Microsoft.AspNetCore.ResponseCompression.Benchmarks\Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj", "{5AF10E85-5076-40B9-84CF-9830B585ABE5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -154,6 +158,10 @@ Global {762F7276-C916-4111-A6C0-41668ABB3823}.Debug|Any CPU.Build.0 = Debug|Any CPU {762F7276-C916-4111-A6C0-41668ABB3823}.Release|Any CPU.ActiveCfg = Release|Any CPU {762F7276-C916-4111-A6C0-41668ABB3823}.Release|Any CPU.Build.0 = Release|Any CPU + {5AF10E85-5076-40B9-84CF-9830B585ABE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AF10E85-5076-40B9-84CF-9830B585ABE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AF10E85-5076-40B9-84CF-9830B585ABE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AF10E85-5076-40B9-84CF-9830B585ABE5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -178,6 +186,7 @@ Global {5CEA6F31-A829-4A02-8CD5-EC3DDD4CC1EA} = {59A9B64C-E9BE-409E-89A2-58D72E2918F5} {4BC947ED-13B8-4BE6-82A4-96A48D86980B} = {8437B0F3-3894-4828-A945-A9187F37631D} {762F7276-C916-4111-A6C0-41668ABB3823} = {A5076D28-FA7E-4606-9410-FEDD0D603527} + {5AF10E85-5076-40B9-84CF-9830B585ABE5} = {C6DA6317-30FC-42FE-891C-64E75D88FF12} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4518E9CE-3680-4E05-9259-B64EA7807158} diff --git a/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/AssemblyInfo.cs b/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/AssemblyInfo.cs new file mode 100644 index 0000000000..409fcf814a --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj b/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj new file mode 100644 index 0000000000..5dbd63c35a --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj @@ -0,0 +1,19 @@ + + + + Exe + netcoreapp2.1 + + + + + + + + + + + + + + diff --git a/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/ResponseCompressionProviderBenchmark.cs b/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/ResponseCompressionProviderBenchmark.cs new file mode 100644 index 0000000000..4e4b78fcb7 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.ResponseCompression.Benchmarks/ResponseCompressionProviderBenchmark.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.ResponseCompression.Benchmarks +{ + public class ResponseCompressionProviderBenchmark + { + [GlobalSetup] + public void GlobalSetup() + { + var services = new ServiceCollection() + .AddOptions() + .AddResponseCompression() + .BuildServiceProvider(); + + var options = new ResponseCompressionOptions(); + + Provider = new ResponseCompressionProvider(services, Options.Create(options)); + } + + [ParamsSource(nameof(EncodingStrings))] + public string AcceptEncoding { get; set; } + + public static IEnumerable EncodingStrings() + { + return new[] + { + "gzip;q=0.8, compress;q=0.6, br;q=0.4", + "gzip, compress, br", + "br, compress, gzip", + "gzip, compress", + "identity", + "*" + }; + } + + public ResponseCompressionProvider Provider { get; set; } + + [Benchmark] + public ICompressionProvider GetCompressionProvider() + { + var context = new DefaultHttpContext(); + + context.Request.Headers[HeaderNames.AcceptEncoding] = AcceptEncoding; + + return Provider.GetCompressionProvider(context); + } + } +} \ No newline at end of file diff --git a/build/dependencies.props b/build/dependencies.props index 3ab03afc13..7147597eac 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -3,7 +3,9 @@ $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + 0.10.14 2.2.0-preview1-17099 + 2.2.0-preview1-34640 2.2.0-preview1-34640 2.2.0-preview1-34640 2.2.0-preview1-34640 @@ -15,6 +17,7 @@ 2.2.0-preview1-34640 2.2.0-preview1-34640 2.2.0-preview1-34640 + 2.2.0-preview1-34640 2.2.0-preview1-34640 2.2.0-preview1-34640 2.2.0-preview1-34640 diff --git a/src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProvider.cs b/src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProvider.cs new file mode 100644 index 0000000000..20a88dd30a --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProvider.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.IO.Compression; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.ResponseCompression +{ + /// + /// Brotli compression provider. + /// + public class BrotliCompressionProvider : ICompressionProvider + { + /// + /// Creates a new instance of with options. + /// + /// + public BrotliCompressionProvider(IOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + Options = options.Value; + } + + private BrotliCompressionProviderOptions Options { get; } + + /// + public string EncodingName => "br"; + + /// + public bool SupportsFlush => true; + + /// + public Stream CreateStream(Stream outputStream) + { +#if NETCOREAPP2_1 + return new BrotliStream(outputStream, Options.Level, leaveOpen: true); +#elif NET461 || NETSTANDARD2_0 + // Brotli is only supported in .NET Core 2.1+ + throw new PlatformNotSupportedException(); +#else +#error Target frameworks need to be updated. +#endif + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProviderOptions.cs b/src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProviderOptions.cs new file mode 100644 index 0000000000..029f22b854 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCompression/BrotliCompressionProviderOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO.Compression; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.ResponseCompression +{ + /// + /// Options for the + /// + public class BrotliCompressionProviderOptions : IOptions + { + /// + /// What level of compression to use for the stream. The default is . + /// + public CompressionLevel Level { get; set; } = CompressionLevel.Fastest; + + /// + BrotliCompressionProviderOptions IOptions.Value => this; + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCompression/GzipCompressionProvider.cs b/src/Microsoft.AspNetCore.ResponseCompression/GzipCompressionProvider.cs index e825b37976..474f255111 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/GzipCompressionProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCompression/GzipCompressionProvider.cs @@ -39,7 +39,7 @@ namespace Microsoft.AspNetCore.ResponseCompression { #if NET461 return false; -#elif NETSTANDARD2_0 +#elif NETSTANDARD2_0 || NETCOREAPP2_1 return true; #else #error target frameworks need to be updated diff --git a/src/Microsoft.AspNetCore.ResponseCompression/Microsoft.AspNetCore.ResponseCompression.csproj b/src/Microsoft.AspNetCore.ResponseCompression/Microsoft.AspNetCore.ResponseCompression.csproj index e652961322..d39f7db89a 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/Microsoft.AspNetCore.ResponseCompression.csproj +++ b/src/Microsoft.AspNetCore.ResponseCompression/Microsoft.AspNetCore.ResponseCompression.csproj @@ -2,7 +2,7 @@ ASP.NET Core middleware for HTTP Response compression. - net461;netstandard2.0 + net461;netstandard2.0;netcoreapp2.1 true aspnetcore diff --git a/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionOptions.cs b/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionOptions.cs index c6b33c9737..6ec4fb62b4 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionOptions.cs +++ b/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionOptions.cs @@ -22,7 +22,8 @@ namespace Microsoft.AspNetCore.ResponseCompression public bool EnableForHttps { get; set; } = false; /// - /// The ICompressionProviders to use for responses. + /// The types to use for responses. + /// Providers are prioritized based on the order they are added. /// public CompressionProviderCollection Providers { get; } = new CompressionProviderCollection(); } diff --git a/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionProvider.cs b/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionProvider.cs index e5f577f00d..3ed2dd058a 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionProvider.cs @@ -38,7 +38,17 @@ namespace Microsoft.AspNetCore.ResponseCompression if (_providers.Length == 0) { // Use the factory so it can resolve IOptions from DI. - _providers = new ICompressionProvider[] { new CompressionProviderFactory(typeof(GzipCompressionProvider)) }; + _providers = new ICompressionProvider[] + { +#if NETCOREAPP2_1 + new CompressionProviderFactory(typeof(BrotliCompressionProvider)), +#elif NET461 || NETSTANDARD2_0 + // Brotli is only supported in .NET Core 2.1+ +#else +#error Target frameworks need to be updated. +#endif + new CompressionProviderFactory(typeof(GzipCompressionProvider)), + }; } for (var i = 0; i < _providers.Length; i++) { @@ -62,42 +72,76 @@ namespace Microsoft.AspNetCore.ResponseCompression /// public virtual ICompressionProvider GetCompressionProvider(HttpContext context) { - IList unsorted; - // e.g. Accept-Encoding: gzip, deflate, sdch var accept = context.Request.Headers[HeaderNames.AcceptEncoding]; - if (!StringValues.IsNullOrEmpty(accept) - && StringWithQualityHeaderValue.TryParseList(accept, out unsorted) - && unsorted != null && unsorted.Count > 0) - { - // TODO PERF: clients don't usually include quality values so this sort will not have any effect. Fast-path? - var sorted = unsorted - .Where(s => s.Quality.GetValueOrDefault(1) > 0) - .OrderByDescending(s => s.Quality.GetValueOrDefault(1)); - foreach (var encoding in sorted) + if (StringValues.IsNullOrEmpty(accept)) + { + return null; + } + + if (StringWithQualityHeaderValue.TryParseList(accept, out var encodings)) + { + if (encodings.Count == 0) { - // There will rarely be more than three providers, and there's only one by default - foreach (var provider in _providers) + return null; + } + + var candidates = new HashSet(); + + foreach (var encoding in encodings) + { + var encodingName = encoding.Value; + var quality = encoding.Quality.GetValueOrDefault(1); + + if (quality < double.Epsilon) { - if (StringSegment.Equals(provider.EncodingName, encoding.Value, StringComparison.OrdinalIgnoreCase)) + continue; + } + + for (int i = 0; i < _providers.Length; i++) + { + var provider = _providers[i]; + + if (StringSegment.Equals(provider.EncodingName, encodingName, StringComparison.OrdinalIgnoreCase)) { - return provider; + candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider)); } } // Uncommon but valid options - if (StringSegment.Equals("*", encoding.Value, StringComparison.Ordinal)) + if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal)) { - // Any - return _providers[0]; + for (int i = 0; i < _providers.Length; i++) + { + var provider = _providers[i]; + + // Any provider is a candidate. + candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider)); + } + + break; } - if (StringSegment.Equals("identity", encoding.Value, StringComparison.OrdinalIgnoreCase)) + + if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase)) { - // No compression - return null; + // We add 'identity' to the list of "candidates" with a very low priority and no provider. + // This will allow it to be ordered based on its quality (and priority) later in the method. + candidates.Add(new ProviderCandidate(encodingName.Value, quality, priority: int.MaxValue, provider: null)); } } + + if (candidates.Count <= 1) + { + return candidates.ElementAtOrDefault(0).Provider; + } + + var accepted = candidates + .OrderByDescending(x => x.Quality) + .ThenBy(x => x.Priority) + .First(); + + return accepted.Provider; } return null; @@ -139,5 +183,39 @@ namespace Microsoft.AspNetCore.ResponseCompression } return !string.IsNullOrEmpty(context.Request.Headers[HeaderNames.AcceptEncoding]); } + + private readonly struct ProviderCandidate : IEquatable + { + public ProviderCandidate(string encodingName, double quality, int priority, ICompressionProvider provider) + { + EncodingName = encodingName; + Quality = quality; + Priority = priority; + Provider = provider; + } + + public string EncodingName { get; } + + public double Quality { get; } + + public int Priority { get; } + + public ICompressionProvider Provider { get; } + + public bool Equals(ProviderCandidate other) + { + return string.Equals(EncodingName, other.EncodingName, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is ProviderCandidate candidate && Equals(candidate); + } + + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(EncodingName); + } + } } } diff --git a/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs b/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs index 52eb965cf0..5a2a17a53c 100644 --- a/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs +++ b/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -23,6 +24,26 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests { private const string TextPlain = "text/plain"; + public static IEnumerable SupportedEncodings => + TestData.Select(x => new object[] { x.EncodingName }); + + public static IEnumerable SupportedEncodingsWithBodyLength => + TestData.Select(x => new object[] { x.EncodingName, x.ExpectedBodyLength }); + + private static IEnumerable TestData + { + get + { + yield return new EncodingTestData("gzip", expectedBodyLength: 24); +#if NETCOREAPP2_2 + yield return new EncodingTestData("br", expectedBodyLength: 20); +#elif NET461 +#else +#error Target frameworks need to be updated. +#endif + } + } + [Fact] public void Options_HttpsDisabledByDefault() { @@ -42,15 +63,66 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [Fact] public async Task Request_AcceptGzipDeflate_CompressedGzip() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "gzip", "deflate" }, responseType: TextPlain); + var response = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "gzip", "deflate" }, responseType: TextPlain); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); } + [Fact] + public async Task Request_AcceptBrotli_CompressedBrotli() + { + var response = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "br" }, responseType: TextPlain); + +#if NET461 + CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); +#elif NETCOREAPP2_2 + CheckResponseCompressed(response, expectedBodyLength: 20, expectedEncoding: "br"); +#else +#error Target frameworks need to be updated. +#endif + } + + [Theory] + [InlineData("gzip", "br")] + [InlineData("br", "gzip")] + public async Task Request_AcceptMixed_CompressedBrotli(string encoding1, string encoding2) + { + var response = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain); + +#if NET461 + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); +#elif NETCOREAPP2_2 + CheckResponseCompressed(response, expectedBodyLength: 20, expectedEncoding: "br"); +#else +#error Target frameworks need to be updated. +#endif + } + +#if NETCOREAPP2_2 + [Theory] + [InlineData("gzip", "br")] + [InlineData("br", "gzip")] + public async Task Request_AcceptMixed_ConfiguredOrder_CompressedGzip(string encoding1, string encoding2) + { + void Configure(ResponseCompressionOptions options) + { + options.Providers.Add(); + options.Providers.Add(); + } + + var response = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain, configure: Configure); + + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + } +#elif NET461 +#else +#error Target frameworks need to be updated. +#endif + [Fact] public async Task Request_AcceptUnknown_NotCompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "unknown" }, responseType: TextPlain); + var response = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "unknown" }, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); } @@ -86,7 +158,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); } [Fact] @@ -117,7 +189,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 123); + CheckResponseCompressed(response, expectedBodyLength: 123, expectedEncoding: "gzip"); } [Theory] @@ -191,32 +263,38 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [Fact] public async Task Request_AcceptStar_Compressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "*" }, responseType: TextPlain); + var response = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "*" }, responseType: TextPlain); - CheckResponseCompressed(response, expectedBodyLength: 24); +#if NET461 + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); +#elif NETCOREAPP2_2 + CheckResponseCompressed(response, expectedBodyLength: 20, expectedEncoding: "br"); +#else +#error Target frameworks need to be updated. +#endif } [Fact] public async Task Request_AcceptIdentity_NotCompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "identity" }, responseType: TextPlain); + var response = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "identity" }, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); } [Theory] - [InlineData(new string[] { "identity;q=0.5", "gzip;q=1" }, 24)] - [InlineData(new string[] { "identity;q=0", "gzip;q=0.8" }, 24)] - [InlineData(new string[] { "identity;q=0.5", "gzip" }, 24)] + [InlineData(new[] { "identity;q=0.5", "gzip;q=1" }, 24)] + [InlineData(new[] { "identity;q=0", "gzip;q=0.8" }, 24)] + [InlineData(new[] { "identity;q=0.5", "gzip" }, 24)] public async Task Request_AcceptWithHigherCompressionQuality_Compressed(string[] acceptEncodings, int expectedBodyLength) { var response = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); - CheckResponseCompressed(response, expectedBodyLength: expectedBodyLength); + CheckResponseCompressed(response, expectedBodyLength: expectedBodyLength, expectedEncoding: "gzip"); } [Theory] - [InlineData(new string[] { "gzip;q=0.5", "identity;q=0.8" }, 100)] + [InlineData(new[] { "gzip;q=0.5", "identity;q=0.8" }, 100)] public async Task Request_AcceptWithhigherIdentityQuality_NotCompressed(string[] acceptEncodings, int expectedBodyLength) { var response = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); @@ -227,7 +305,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [Fact] public async Task Response_UnknownMimeType_NotCompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "gzip" }, responseType: "text/custom"); + var response = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "gzip" }, responseType: "text/custom"); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); } @@ -235,7 +313,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [Fact] public async Task Response_WithContentRange_NotCompressed() { - var response = await InvokeMiddleware(50, requestAcceptEncodings: new string[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => + var response = await InvokeMiddleware(50, requestAcceptEncodings: new[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => { r.Headers[HeaderNames.ContentRange] = "1-2/*"; }); @@ -248,7 +326,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests { var otherContentEncoding = "something"; - var response = await InvokeMiddleware(50, requestAcceptEncodings: new string[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => + var response = await InvokeMiddleware(50, requestAcceptEncodings: new[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => { r.Headers[HeaderNames.ContentEncoding] = otherContentEncoding; }); @@ -282,8 +360,11 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests }); }); - var server = new TestServer(builder); - server.BaseAddress = new Uri("https://localhost/"); + var server = new TestServer(builder) + { + BaseAddress = new Uri("https://localhost/") + }; + var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); @@ -294,8 +375,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); } - [Fact] - public async Task FlushHeaders_SendsHeaders_Compresses() + [Theory] + [MemberData(nameof(SupportedEncodingsWithBodyLength))] + public async Task FlushHeaders_SendsHeaders_Compresses(string encoding, int expectedBodyLength) { var responseReceived = new ManualResetEvent(false); @@ -321,18 +403,19 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); responseReceived.Set(); await response.Content.LoadIntoBufferAsync(); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength, encoding); } - [Fact] - public async Task FlushAsyncHeaders_SendsHeaders_Compresses() + [Theory] + [MemberData(nameof(SupportedEncodingsWithBodyLength))] + public async Task FlushAsyncHeaders_SendsHeaders_Compresses(string encoding, int expectedBodyLength) { var responseReceived = new ManualResetEvent(false); @@ -358,18 +441,19 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); responseReceived.Set(); await response.Content.LoadIntoBufferAsync(); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength, encoding); } - [Fact] - public async Task FlushBody_CompressesAndFlushes() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task FlushBody_CompressesAndFlushes(string encoding) { var responseReceived = new ManualResetEvent(false); @@ -397,13 +481,12 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); @@ -415,8 +498,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.True(read > 0); } - [Fact] - public async Task FlushAsyncBody_CompressesAndFlushes() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task FlushAsyncBody_CompressesAndFlushes(string encoding) { var responseReceived = new ManualResetEvent(false); @@ -443,13 +527,12 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); @@ -461,8 +544,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.True(read > 0); } - [Fact] - public async Task TrickleWriteAndFlush_FlushesEachWrite() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task TrickleWriteAndFlush_FlushesEachWrite(string encoding) { var responseReceived = new[] { @@ -501,7 +585,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); @@ -509,9 +593,8 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.NotNull(response.Content.Headers.GetValues(HeaderNames.ContentMD5)); Assert.Empty(response.Content.Headers.ContentEncoding); #elif NETCOREAPP2_2 // Flush supported, compression enabled - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); #else #error Target frameworks need to be updated. #endif @@ -527,8 +610,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } - [Fact] - public async Task TrickleWriteAndFlushAsync_FlushesEachWrite() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task TrickleWriteAndFlushAsync_FlushesEachWrite(string encoding) { var responseReceived = new[] { @@ -566,7 +650,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); @@ -574,9 +658,8 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.NotNull(response.Content.Headers.GetValues(HeaderNames.ContentMD5)); Assert.Empty(response.Content.Headers.ContentEncoding); #elif NETCOREAPP2_2 // Flush supported, compression enabled - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); #else #error Target framework needs to be updated #endif @@ -705,7 +788,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 34); + CheckResponseCompressed(response, expectedBodyLength: 34, expectedEncoding: "gzip"); Assert.False(fakeSendFile.Invoked); } @@ -749,17 +832,22 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 40); + CheckResponseCompressed(response, expectedBodyLength: 40, expectedEncoding: "gzip"); Assert.False(fakeSendFile.Invoked); } - private Task InvokeMiddleware(int uncompressedBodyLength, string[] requestAcceptEncodings, string responseType, Action addResponseAction = null) + private Task InvokeMiddleware( + int uncompressedBodyLength, + string[] requestAcceptEncodings, + string responseType, + Action addResponseAction = null, + Action configure = null) { var builder = new WebHostBuilder() .ConfigureServices(services => { - services.AddResponseCompression(); + services.AddResponseCompression(configure ?? (_ => { })); }) .Configure(app => { @@ -769,10 +857,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = responseType; Assert.Null(context.Features.Get()); - if (addResponseAction != null) - { - addResponseAction(context.Response); - } + addResponseAction?.Invoke(context.Response); return context.Response.WriteAsync(new string('a', uncompressedBodyLength)); }); }); @@ -789,10 +874,8 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests return client.SendAsync(request); } - private void CheckResponseCompressed(HttpResponseMessage response, int expectedBodyLength) + private void CheckResponseCompressed(HttpResponseMessage response, int expectedBodyLength, string expectedEncoding) { - IEnumerable contentMD5 = null; - var containsVaryAcceptEncoding = false; foreach (var value in response.Headers.GetValues(HeaderNames.Vary)) { @@ -803,8 +886,8 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } Assert.True(containsVaryAcceptEncoding); - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, expectedEncoding); Assert.Equal(expectedBodyLength, response.Content.Headers.ContentLength); } @@ -858,5 +941,18 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } } + + private readonly struct EncodingTestData + { + public EncodingTestData(string encodingName, int expectedBodyLength) + { + EncodingName = encodingName; + ExpectedBodyLength = expectedBodyLength; + } + + public string EncodingName { get; } + + public int ExpectedBodyLength { get; } + } } }