diff --git a/src/libraries/System.Net.Http/ref/System.Net.Http.cs b/src/libraries/System.Net.Http/ref/System.Net.Http.cs index 2eb4200dc536d3..2d636ea25820f0 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -6,6 +6,18 @@ namespace System.Net.Http { + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("wasi")] + public sealed partial class BrotliCompressedContent : System.Net.Http.HttpContent + { + public BrotliCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.BrotliCompressionOptions compressionOptions) { } + public BrotliCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.CompressionLevel compressionLevel = System.IO.Compression.CompressionLevel.Optimal) { } + protected override void Dispose(bool disposing) { } + protected override void SerializeToStream(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { } + protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context) { throw null; } + protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { throw null; } + protected internal override bool TryComputeLength(out long length) { throw null; } + } public partial class ByteArrayContent : System.Net.Http.HttpContent { public ByteArrayContent(byte[] content) { } @@ -42,6 +54,16 @@ public FormUrlEncodedContent( >> nameValueCollection) : base (default(byte[])) { } protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { throw null; } } + public sealed partial class GZipCompressedContent : System.Net.Http.HttpContent + { + public GZipCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.CompressionLevel compressionLevel = System.IO.Compression.CompressionLevel.Optimal) { } + public GZipCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.ZLibCompressionOptions compressionOptions) { } + protected override void Dispose(bool disposing) { } + protected override void SerializeToStream(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { } + protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context) { throw null; } + protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { throw null; } + protected internal override bool TryComputeLength(out long length) { throw null; } + } public delegate System.Text.Encoding? HeaderEncodingSelector(string headerName, TContext context); public partial class HttpClient : System.Net.Http.HttpMessageInvoker { @@ -501,6 +523,18 @@ public StringContent(string content, System.Text.Encoding? encoding, System.Net. public StringContent(string content, System.Text.Encoding? encoding, string? mediaType) : base (default(byte[])) { } protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { throw null; } } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("wasi")] + public sealed partial class ZstandardCompressedContent : System.Net.Http.HttpContent + { + public ZstandardCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.CompressionLevel compressionLevel = System.IO.Compression.CompressionLevel.Optimal) { } + public ZstandardCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.ZstandardCompressionOptions compressionOptions) { } + protected override void Dispose(bool disposing) { } + protected override void SerializeToStream(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { } + protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context) { throw null; } + protected override System.Threading.Tasks.Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context, System.Threading.CancellationToken cancellationToken) { throw null; } + protected internal override bool TryComputeLength(out long length) { throw null; } + } } namespace System.Net.Http.Headers { diff --git a/src/libraries/System.Net.Http/ref/System.Net.Http.csproj b/src/libraries/System.Net.Http/ref/System.Net.Http.csproj index 5c12a22894d477..b4ff186f10ece5 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/libraries/System.Net.Http/src/System.Net.Http.csproj b/src/libraries/System.Net.Http/src/System.Net.Http.csproj index 18c9177a49753b..b09f329d938ab3 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -23,9 +23,11 @@ + + @@ -34,6 +36,7 @@ + @@ -75,6 +78,7 @@ + diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/BrotliCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/BrotliCompressedContent.cs new file mode 100644 index 00000000000000..0ac4ea4df98899 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/BrotliCompressedContent.cs @@ -0,0 +1,90 @@ +// 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; +using System.IO.Compression; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + /// + /// Provides HTTP content that compresses the inner content using the Brotli content coding. + /// + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("wasi")] + public sealed class BrotliCompressedContent : HttpContent + { + private const string Encoding = "br"; + + private readonly HttpContent _content; + private readonly BrotliCompressionOptions? _compressionOptions; + private readonly CompressionLevel _compressionLevel; + + /// + /// Initializes a new instance of the class that compresses the + /// provided content using the Brotli content coding at the specified compression level. + /// + /// The HTTP content to compress. + /// One of the enumeration values that indicates whether to emphasize speed or compression efficiency. + public BrotliCompressedContent(HttpContent content, CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + ArgumentNullException.ThrowIfNull(content); + CompressedContentCore.ValidateCompressionLevel(compressionLevel, nameof(compressionLevel)); + + _compressionLevel = compressionLevel; + _content = content; + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + /// + /// Initializes a new instance of the class that compresses the + /// provided content using the Brotli content coding and the specified compression options. + /// + /// The HTTP content to compress. + /// The options used to fine-tune the compression. + public BrotliCompressedContent(HttpContent content, BrotliCompressionOptions compressionOptions) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(compressionOptions); + + _compressionOptions = compressionOptions; + _content = content; + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + CompressedContentCore.SerializeToStream(_content, CreateCompressionStream(stream), context, cancellationToken); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + CompressedContentCore.SerializeToStreamAsync(_content, CreateCompressionStream(stream), context, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + CompressedContentCore.SerializeToStreamAsync(_content, CreateCompressionStream(stream), context, cancellationToken); + + protected internal override bool TryComputeLength(out long length) + { + // Compressed size is not known without actually compressing. + length = 0; + return false; + } + + internal override bool AllowDuplex => false; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _content.Dispose(); + } + + base.Dispose(disposing); + } + + private BrotliStream CreateCompressionStream(Stream outputStream) => + _compressionOptions is null + ? new BrotliStream(outputStream, _compressionLevel, leaveOpen: true) + : new BrotliStream(outputStream, _compressionOptions, leaveOpen: true); + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/CompressedContentCore.cs b/src/libraries/System.Net.Http/src/System/Net/Http/CompressedContentCore.cs new file mode 100644 index 00000000000000..96b7b13a4ff80a --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/CompressedContentCore.cs @@ -0,0 +1,58 @@ +// 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; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + /// + /// Shared helpers for the per-algorithm compressed content types. Provides header initialization, + /// argument validation, and serialization that copies the inner content through a compression stream. + /// + internal static class CompressedContentCore + { + public static void ValidateCompressionLevel(CompressionLevel compressionLevel, string paramName) + { + // Validate up front so an invalid enum value fails fast at construction time rather than + // deferring the exception to the serialization path when the request is being sent. + if (compressionLevel is not (CompressionLevel.Optimal or CompressionLevel.Fastest or CompressionLevel.NoCompression or CompressionLevel.SmallestSize)) + { + throw new ArgumentOutOfRangeException(paramName); + } + } + + public static void InitializeHeaders(HttpContent target, HttpContent source, string encoding) + { + // Copy headers from the original content. + target.Headers.AddHeaders(source.Headers); + + // Append our encoding to the Content-Encoding header (supports stacking per HTTP spec). + // Always use TryAddWithoutValidation so we append the new encoding without re-parsing or + // validating any existing Content-Encoding values the inner content may have added. + target.Headers.TryAddWithoutValidation(KnownHeaders.ContentEncoding.Descriptor, encoding); + + // Remove Content-Length since we don't know the compressed size upfront. + target.Headers.ContentLength = null; + } + + public static void SerializeToStream(HttpContent originalContent, Stream compressionStream, TransportContext? context, CancellationToken cancellationToken) + { + using (compressionStream) + { + originalContent.CopyTo(compressionStream, context, cancellationToken); + } + } + + public static async Task SerializeToStreamAsync(HttpContent originalContent, Stream compressionStream, TransportContext? context, CancellationToken cancellationToken) + { + await using (compressionStream.ConfigureAwait(false)) + { + await originalContent.CopyToAsync(compressionStream, context, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/GZipCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/GZipCompressedContent.cs new file mode 100644 index 00000000000000..d0764b2470cb86 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/GZipCompressedContent.cs @@ -0,0 +1,87 @@ +// 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; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + /// + /// Provides HTTP content that compresses the inner content using the gzip content coding. + /// + public sealed class GZipCompressedContent : HttpContent + { + private const string Encoding = "gzip"; + + private readonly HttpContent _content; + private readonly ZLibCompressionOptions? _compressionOptions; + private readonly CompressionLevel _compressionLevel; + + /// + /// Initializes a new instance of the class that compresses the + /// provided content using the gzip content coding at the specified compression level. + /// + /// The HTTP content to compress. + /// One of the enumeration values that indicates whether to emphasize speed or compression efficiency. + public GZipCompressedContent(HttpContent content, CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + ArgumentNullException.ThrowIfNull(content); + CompressedContentCore.ValidateCompressionLevel(compressionLevel, nameof(compressionLevel)); + + _compressionLevel = compressionLevel; + _content = content; + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + /// + /// Initializes a new instance of the class that compresses the + /// provided content using the gzip content coding and the specified compression options. + /// + /// The HTTP content to compress. + /// The options used to fine-tune the compression. + public GZipCompressedContent(HttpContent content, ZLibCompressionOptions compressionOptions) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(compressionOptions); + + _compressionOptions = compressionOptions; + _content = content; + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + CompressedContentCore.SerializeToStream(_content, CreateCompressionStream(stream), context, cancellationToken); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + CompressedContentCore.SerializeToStreamAsync(_content, CreateCompressionStream(stream), context, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + CompressedContentCore.SerializeToStreamAsync(_content, CreateCompressionStream(stream), context, cancellationToken); + + protected internal override bool TryComputeLength(out long length) + { + // Compressed size is not known without actually compressing. + length = 0; + return false; + } + + internal override bool AllowDuplex => false; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _content.Dispose(); + } + + base.Dispose(disposing); + } + + private GZipStream CreateCompressionStream(Stream outputStream) => + _compressionOptions is null + ? new GZipStream(outputStream, _compressionLevel, leaveOpen: true) + : new GZipStream(outputStream, _compressionOptions, leaveOpen: true); + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs new file mode 100644 index 00000000000000..20cb0b050db4c5 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs @@ -0,0 +1,90 @@ +// 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; +using System.IO.Compression; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + /// + /// Provides HTTP content that compresses the inner content using the Zstandard content coding. + /// + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("wasi")] + public sealed class ZstandardCompressedContent : HttpContent + { + private const string Encoding = "zstd"; + + private readonly HttpContent _content; + private readonly ZstandardCompressionOptions? _compressionOptions; + private readonly CompressionLevel _compressionLevel; + + /// + /// Initializes a new instance of the class that compresses the + /// provided content using the Zstandard content coding at the specified compression level. + /// + /// The HTTP content to compress. + /// One of the enumeration values that indicates whether to emphasize speed or compression efficiency. + public ZstandardCompressedContent(HttpContent content, CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + ArgumentNullException.ThrowIfNull(content); + CompressedContentCore.ValidateCompressionLevel(compressionLevel, nameof(compressionLevel)); + + _compressionLevel = compressionLevel; + _content = content; + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + /// + /// Initializes a new instance of the class that compresses the + /// provided content using the Zstandard content coding and the specified compression options. + /// + /// The HTTP content to compress. + /// The options used to fine-tune the compression. + public ZstandardCompressedContent(HttpContent content, ZstandardCompressionOptions compressionOptions) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(compressionOptions); + + _compressionOptions = compressionOptions; + _content = content; + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + CompressedContentCore.SerializeToStream(_content, CreateCompressionStream(stream), context, cancellationToken); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + CompressedContentCore.SerializeToStreamAsync(_content, CreateCompressionStream(stream), context, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + CompressedContentCore.SerializeToStreamAsync(_content, CreateCompressionStream(stream), context, cancellationToken); + + protected internal override bool TryComputeLength(out long length) + { + // Compressed size is not known without actually compressing. + length = 0; + return false; + } + + internal override bool AllowDuplex => false; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _content.Dispose(); + } + + base.Dispose(disposing); + } + + private ZstandardStream CreateCompressionStream(Stream outputStream) => + _compressionOptions is null + ? new ZstandardStream(outputStream, _compressionLevel, leaveOpen: true) + : new ZstandardStream(outputStream, _compressionOptions, leaveOpen: true); + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs new file mode 100644 index 00000000000000..c2ac84bfa99f69 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs @@ -0,0 +1,157 @@ +// 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; +using System.IO.Compression; +using System.Text; +using System.Threading.Tasks; + +using Xunit; + +namespace System.Net.Http.Functional.Tests +{ + // Brotli and Zstandard rely on native libraries that are not available on browser/wasi, so both + // types are annotated [UnsupportedOSPlatform("browser"/"wasi")]. This file is excluded from those + // builds; the gzip tests and shared helpers live in CompressedContentTest.cs. + public partial class CompressedContentTest + { + [Fact] + public void BrotliZstd_Ctor_NullContent_ThrowsArgumentNullException() + { + AssertExtensions.Throws("content", () => new BrotliCompressedContent(null)); + AssertExtensions.Throws("content", () => new BrotliCompressedContent(null, new BrotliCompressionOptions())); + AssertExtensions.Throws("content", () => new BrotliCompressedContent(null, CompressionLevel.SmallestSize)); + AssertExtensions.Throws("content", () => new ZstandardCompressedContent(null)); + AssertExtensions.Throws("content", () => new ZstandardCompressedContent(null, new ZstandardCompressionOptions())); + AssertExtensions.Throws("content", () => new ZstandardCompressedContent(null, CompressionLevel.SmallestSize)); + } + + [Fact] + public void BrotliZstd_Ctor_NullOptions_ThrowsArgumentNullException() + { + var inner = new ByteArrayContent(Array.Empty()); + AssertExtensions.Throws("compressionOptions", () => new BrotliCompressedContent(inner, null)); + AssertExtensions.Throws("compressionOptions", () => new ZstandardCompressedContent(inner, null)); + } + + [Theory] + [InlineData((CompressionLevel)(-1))] + [InlineData((CompressionLevel)4)] + [InlineData((CompressionLevel)99)] + public void BrotliZstd_Ctor_InvalidCompressionLevel_ThrowsArgumentOutOfRangeException(CompressionLevel compressionLevel) + { + var inner = new ByteArrayContent(Array.Empty()); + AssertExtensions.Throws("compressionLevel", () => new BrotliCompressedContent(inner, compressionLevel)); + AssertExtensions.Throws("compressionLevel", () => new ZstandardCompressedContent(inner, compressionLevel)); + } + + [Theory] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.NoCompression)] + [InlineData(CompressionLevel.SmallestSize)] + public void BrotliZstd_Ctor_ValidCompressionLevel_DoesNotThrow(CompressionLevel compressionLevel) + { + var inner = new ByteArrayContent(Array.Empty()); + _ = new BrotliCompressedContent(inner, compressionLevel); + _ = new ZstandardCompressedContent(inner, compressionLevel); + } + + [Theory] + [InlineData("br")] + [InlineData("zstd")] + public void BrotliZstd_Ctor_SetsContentEncodingHeader(string encoding) + { + HttpContent content = CreateBrotliOrZstdContent(encoding, new ByteArrayContent(Array.Empty())); + + Assert.Equal(new[] { encoding }, content.Headers.ContentEncoding); + } + + [Fact] + public void BrotliZstd_Ctor_NestedContent_StacksContentEncoding() + { + var inner = new GZipCompressedContent(new ByteArrayContent(Array.Empty())); + var outer = new BrotliCompressedContent(inner); + + Assert.Equal(new[] { "gzip", "br" }, outer.Headers.ContentEncoding); + } + + [Theory] + [InlineData("br", true)] + [InlineData("br", false)] + [InlineData("zstd", true)] + [InlineData("zstd", false)] + public async Task BrotliZstd_SerializeToStream_RoundTrips_MatchesOriginal(string encoding, bool async) + { + byte[] original = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog. " + new string('a', 1024)); + HttpContent content = CreateBrotliOrZstdContent(encoding, new ByteArrayContent(original)); + + byte[] compressed = await SerializeAsync(content, async); + + Assert.Equal(original, DecompressBrotliOrZstd(compressed, encoding)); + } + + [Theory] + [InlineData("br")] + [InlineData("zstd")] + public async Task BrotliZstd_SerializeToStream_WithOptions_RoundTrips(string encoding) + { + byte[] original = Encoding.UTF8.GetBytes(new string('a', 4096)); + HttpContent content = encoding == "br" + ? new BrotliCompressedContent(new ByteArrayContent(original), new BrotliCompressionOptions { Quality = 5 }) + : new ZstandardCompressedContent(new ByteArrayContent(original), new ZstandardCompressionOptions { Quality = 5 }); + + Assert.Equal(original, DecompressBrotliOrZstd(await SerializeAsync(content, async: true), encoding)); + } + + [Theory] + [InlineData("br")] + [InlineData("zstd")] + public async Task BrotliZstd_SerializeToStream_WithCompressionLevel_RoundTrips(string encoding) + { + byte[] original = Encoding.UTF8.GetBytes(new string('a', 4096)); + HttpContent content = encoding == "br" + ? new BrotliCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize) + : new ZstandardCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize); + + Assert.Equal(original, DecompressBrotliOrZstd(await SerializeAsync(content, async: true), encoding)); + } + + [Theory] + [InlineData("br")] + [InlineData("zstd")] + public async Task BrotliZstd_SerializeToStream_RepeatableInnerContent_CanSerializeMultipleTimes(string encoding) + { + byte[] original = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog. " + new string('a', 512)); + HttpContent content = CreateBrotliOrZstdContent(encoding, new ByteArrayContent(original)); + + byte[] first = await SerializeAsync(content, async: true); + byte[] second = await SerializeAsync(content, async: false); + + Assert.Equal(original, DecompressBrotliOrZstd(first, encoding)); + Assert.Equal(original, DecompressBrotliOrZstd(second, encoding)); + } + + private static HttpContent CreateBrotliOrZstdContent(string encoding, HttpContent inner) => encoding switch + { + "br" => new BrotliCompressedContent(inner), + "zstd" => new ZstandardCompressedContent(inner), + _ => throw new ArgumentOutOfRangeException(nameof(encoding)) + }; + + private static byte[] DecompressBrotliOrZstd(byte[] compressed, string encoding) + { + using var source = new MemoryStream(compressed); + using Stream decompressor = encoding switch + { + "br" => new BrotliStream(source, CompressionMode.Decompress), + "zstd" => new ZstandardStream(source, CompressionMode.Decompress), + _ => throw new ArgumentOutOfRangeException(nameof(encoding)) + }; + + using var result = new MemoryStream(); + decompressor.CopyTo(result); + return result.ToArray(); + } + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.cs new file mode 100644 index 00000000000000..b829dd216f940a --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.cs @@ -0,0 +1,215 @@ +// 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; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; + +using Xunit; + +namespace System.Net.Http.Functional.Tests +{ + // GZip is supported on every platform (including browser/wasi), so the gzip tests and the shared + // helpers live in this file, which is compiled for all targets. Brotli and Zstandard are not + // supported on browser/wasi; their tests live in CompressedContentTest.NonBrowser.cs, which is + // excluded from those builds. + public partial class CompressedContentTest + { + [Fact] + public void GZip_Ctor_NullContent_ThrowsArgumentNullException() + { + AssertExtensions.Throws("content", () => new GZipCompressedContent(null)); + AssertExtensions.Throws("content", () => new GZipCompressedContent(null, new ZLibCompressionOptions())); + AssertExtensions.Throws("content", () => new GZipCompressedContent(null, CompressionLevel.SmallestSize)); + } + + [Fact] + public void GZip_Ctor_NullOptions_ThrowsArgumentNullException() + { + var inner = new ByteArrayContent(Array.Empty()); + AssertExtensions.Throws("compressionOptions", () => new GZipCompressedContent(inner, null)); + } + + [Theory] + [InlineData((CompressionLevel)(-1))] + [InlineData((CompressionLevel)4)] + [InlineData((CompressionLevel)99)] + public void GZip_Ctor_InvalidCompressionLevel_ThrowsArgumentOutOfRangeException(CompressionLevel compressionLevel) + { + var inner = new ByteArrayContent(Array.Empty()); + AssertExtensions.Throws("compressionLevel", () => new GZipCompressedContent(inner, compressionLevel)); + } + + [Theory] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.NoCompression)] + [InlineData(CompressionLevel.SmallestSize)] + public void GZip_Ctor_ValidCompressionLevel_DoesNotThrow(CompressionLevel compressionLevel) + { + var inner = new ByteArrayContent(Array.Empty()); + _ = new GZipCompressedContent(inner, compressionLevel); + } + + [Fact] + public void GZip_Ctor_SetsContentEncodingHeader() + { + var content = new GZipCompressedContent(new ByteArrayContent(Array.Empty())); + + Assert.Equal(new[] { "gzip" }, content.Headers.ContentEncoding); + } + + [Fact] + public void GZip_Ctor_RemovesContentLength() + { + var inner = new ByteArrayContent(new byte[10]); + Assert.Equal(10, inner.Headers.ContentLength); + + var content = new GZipCompressedContent(inner); + + Assert.Null(content.Headers.ContentLength); + } + + [Fact] + public void GZip_Ctor_CopiesInnerContentHeaders() + { + var inner = new ByteArrayContent(Array.Empty()); + inner.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + var content = new GZipCompressedContent(inner); + + Assert.Equal("application/json", content.Headers.ContentType.MediaType); + } + + [Fact] + public void GZip_Ctor_NestedContent_StacksContentEncoding() + { + var inner = new GZipCompressedContent(new ByteArrayContent(Array.Empty())); + var outer = new GZipCompressedContent(inner); + + Assert.Equal(new[] { "gzip", "gzip" }, outer.Headers.ContentEncoding); + } + + [Fact] + public void GZip_Dispose_DisposesInnerContent() + { + var inner = new MockContent(); + var content = new GZipCompressedContent(inner); + + content.Dispose(); + + Assert.Equal(1, inner.DisposeCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GZip_SerializeToStream_RoundTrips_MatchesOriginal(bool async) + { + byte[] original = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog. " + new string('a', 1024)); + var content = new GZipCompressedContent(new ByteArrayContent(original)); + + byte[] compressed = await SerializeAsync(content, async); + + Assert.Equal(original, DecompressGZip(compressed)); + } + + [Fact] + public async Task GZip_SerializeToStream_WithOptions_RoundTrips() + { + byte[] original = Encoding.UTF8.GetBytes(new string('a', 4096)); + + var gzip = new GZipCompressedContent(new ByteArrayContent(original), new ZLibCompressionOptions { CompressionLevel = 5 }); + + Assert.Equal(original, DecompressGZip(await SerializeAsync(gzip, async: true))); + } + + [Fact] + public async Task GZip_SerializeToStream_WithCompressionLevel_RoundTrips() + { + byte[] original = Encoding.UTF8.GetBytes(new string('a', 4096)); + + var content = new GZipCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize); + + Assert.Equal(original, DecompressGZip(await SerializeAsync(content, async: true))); + } + + [Fact] + public async Task GZip_SerializeToStream_RepeatableInnerContent_CanSerializeMultipleTimes() + { + byte[] original = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog. " + new string('a', 512)); + var content = new GZipCompressedContent(new ByteArrayContent(original)); + + byte[] first = await SerializeAsync(content, async: true); + byte[] second = await SerializeAsync(content, async: false); + + Assert.Equal(original, DecompressGZip(first)); + Assert.Equal(original, DecompressGZip(second)); + } + + [Fact] + public void GZip_SerializeToStream_NonRepeatableInnerContent_SecondCallThrows() + { + var inner = new StreamContent(new NonSeekableStream(Encoding.UTF8.GetBytes("data"))); + var content = new GZipCompressedContent(inner); + + using var ms = new MemoryStream(); + content.CopyTo(ms, null, default); + + Assert.Throws(() => content.CopyTo(ms, null, default)); + } + + private static async Task SerializeAsync(HttpContent content, bool async) + { + using var ms = new MemoryStream(); + + if (async) + { + await content.CopyToAsync(ms); + } + else + { + content.CopyTo(ms, null, default); + } + + return ms.ToArray(); + } + + private static byte[] DecompressGZip(byte[] compressed) + { + using var source = new MemoryStream(compressed); + using var decompressor = new GZipStream(source, CompressionMode.Decompress); + using var result = new MemoryStream(); + decompressor.CopyTo(result); + return result.ToArray(); + } + + private sealed class NonSeekableStream : MemoryStream + { + public NonSeekableStream(byte[] data) : base(data) { } + + public override bool CanSeek => false; + } + + private sealed class MockContent : HttpContent + { + public int DisposeCount { get; private set; } + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj index 28306cba768a64..836e575358df06 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj @@ -145,6 +145,8 @@ Link="Common\System\Threading\Tasks\TaskTimeoutExtensions.cs" /> + +