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..d4902ac5506585 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.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("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.ZLibCompressionOptions compressionOptions) { } + public GZipCompressedContent(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 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.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("wasi")] + public sealed partial class ZstandardCompressedContent : System.Net.Http.HttpContent + { + public ZstandardCompressedContent(System.Net.Http.HttpContent content, System.IO.Compression.ZstandardCompressionOptions compressionOptions) { } + public ZstandardCompressedContent(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; } + } } 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 f973fa8499005b..ee798fe89bdb51 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -26,6 +26,10 @@ + + + + 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..b05528887cb1ab --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/BrotliCompressedContent.cs @@ -0,0 +1,89 @@ +// 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 CompressedContentCore _core; + 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); + + _compressionLevel = compressionLevel; + _core = new CompressedContentCore(content, CreateCompressionStream); + 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; + _core = new CompressedContentCore(content, CreateCompressionStream); + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + _core.SerializeToStream(stream, context, cancellationToken); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + _core.SerializeToStreamAsync(stream, context, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + _core.SerializeToStreamAsync(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) + { + _core.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..a579d67046f169 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/CompressedContentCore.cs @@ -0,0 +1,64 @@ +// 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.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + /// + /// Shared implementation for the per-algorithm compressed content types. Holds the inner content and + /// drives serialization through a caller-provided factory that wraps the output stream in a compression stream. + /// + internal sealed class CompressedContentCore + { + private readonly HttpContent _originalContent; + private readonly Func _createCompressionStream; + + public CompressedContentCore(HttpContent originalContent, Func createCompressionStream) + { + _originalContent = originalContent; + _createCompressionStream = createCompressionStream; + } + + 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). + // The common case is that the inner content has no Content-Encoding, so take the + // allocation-free fast path and only materialize the collection when we need to stack. + if (target.Headers.Contains(KnownHeaders.ContentEncoding.Descriptor)) + { + target.Headers.ContentEncoding.Add(encoding); + } + else + { + target.Headers.TryAddWithoutValidation(KnownHeaders.ContentEncoding.Descriptor, encoding); + } + + // Remove Content-Length since we don't know the compressed size upfront. + target.Headers.ContentLength = null; + } + + public void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + using Stream compressionStream = _createCompressionStream(stream); + _originalContent.CopyTo(compressionStream, context, cancellationToken); + } + + public async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + Stream compressionStream = _createCompressionStream(stream); + await using (compressionStream.ConfigureAwait(false)) + { + await _originalContent.CopyToAsync(compressionStream, context, cancellationToken).ConfigureAwait(false); + } + } + + public void Dispose() => _originalContent.Dispose(); + } +} 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..093bd7ff946914 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/GZipCompressedContent.cs @@ -0,0 +1,86 @@ +// 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 CompressedContentCore _core; + 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); + + _compressionLevel = compressionLevel; + _core = new CompressedContentCore(content, CreateCompressionStream); + 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; + _core = new CompressedContentCore(content, CreateCompressionStream); + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + _core.SerializeToStream(stream, context, cancellationToken); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + _core.SerializeToStreamAsync(stream, context, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + _core.SerializeToStreamAsync(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) + { + _core.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..ae7b115bea4574 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs @@ -0,0 +1,89 @@ +// 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 CompressedContentCore _core; + 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); + + _compressionLevel = compressionLevel; + _core = new CompressedContentCore(content, CreateCompressionStream); + 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; + _core = new CompressedContentCore(content, CreateCompressionStream); + CompressedContentCore.InitializeHeaders(this, content, Encoding); + } + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + _core.SerializeToStream(stream, context, cancellationToken); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + _core.SerializeToStreamAsync(stream, context, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + _core.SerializeToStreamAsync(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) + { + _core.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.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.cs new file mode 100644 index 00000000000000..2cc0b2032013b7 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.cs @@ -0,0 +1,260 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Xunit; + +namespace System.Net.Http.Functional.Tests +{ + public class CompressedContentTest + { + [Fact] + public void 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)); + 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 Ctor_NullOptions_ThrowsArgumentNullException() + { + var inner = new ByteArrayContent(Array.Empty()); + AssertExtensions.Throws("compressionOptions", () => new GZipCompressedContent(inner, null)); + AssertExtensions.Throws("compressionOptions", () => new BrotliCompressedContent(inner, null)); + AssertExtensions.Throws("compressionOptions", () => new ZstandardCompressedContent(inner, null)); + } + + [Theory] + [InlineData("gzip")] + [InlineData("br")] + [InlineData("zstd")] + public void Ctor_SetsContentEncodingHeader(string encoding) + { + HttpContent content = CreateContent(encoding, new ByteArrayContent(Array.Empty())); + + Assert.Equal(new[] { encoding }, content.Headers.ContentEncoding); + } + + [Fact] + public void 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 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 Ctor_NestedContent_StacksContentEncoding() + { + var inner = new GZipCompressedContent(new ByteArrayContent(Array.Empty())); + var outer = new BrotliCompressedContent(inner); + + Assert.Equal(new[] { "gzip", "br" }, outer.Headers.ContentEncoding); + } + + [Fact] + public void Dispose_DisposesInnerContent() + { + var inner = new MockContent(); + var content = new GZipCompressedContent(inner); + + content.Dispose(); + + Assert.Equal(1, inner.DisposeCount); + } + + [Theory] + [MemberData(nameof(RoundTripData))] + public async Task 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 = CreateContent(encoding, new ByteArrayContent(original)); + + byte[] compressed = await SerializeAsync(content, async); + byte[] decompressed = Decompress(compressed, encoding); + + Assert.Equal(original, decompressed); + } + + [Fact] + public async Task 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, Decompress(await SerializeAsync(gzip, async: true), "gzip")); + + if (!OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi()) + { + var brotli = new BrotliCompressedContent(new ByteArrayContent(original), new BrotliCompressionOptions { Quality = 5 }); + Assert.Equal(original, Decompress(await SerializeAsync(brotli, async: true), "br")); + + var zstd = new ZstandardCompressedContent(new ByteArrayContent(original), new ZstandardCompressionOptions { Quality = 5 }); + Assert.Equal(original, Decompress(await SerializeAsync(zstd, async: true), "zstd")); + } + } + + [Theory] + [MemberData(nameof(Encodings))] + public async Task SerializeToStream_WithCompressionLevel_RoundTrips(string encoding) + { + byte[] original = Encoding.UTF8.GetBytes(new string('a', 4096)); + HttpContent content = encoding switch + { + "gzip" => new GZipCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize), + "br" => new BrotliCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize), + "zstd" => new ZstandardCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize), + _ => throw new ArgumentOutOfRangeException(nameof(encoding)) + }; + + Assert.Equal(original, Decompress(await SerializeAsync(content, async: true), encoding)); + } + + [Theory] + [MemberData(nameof(Encodings))] + public async Task 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 = CreateContent(encoding, new ByteArrayContent(original)); + + byte[] first = await SerializeAsync(content, async: true); + byte[] second = await SerializeAsync(content, async: false); + + Assert.Equal(original, Decompress(first, encoding)); + Assert.Equal(original, Decompress(second, encoding)); + } + + [Fact] + public void 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)); + } + + public static IEnumerable RoundTripData() + { + foreach (bool async in new[] { true, false }) + { + yield return new object[] { "gzip", async }; + + // BrotliStream and ZstandardStream are not supported on browser/wasi. + if (!OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi()) + { + yield return new object[] { "br", async }; + yield return new object[] { "zstd", async }; + } + } + } + + public static IEnumerable Encodings() + { + yield return new object[] { "gzip" }; + + // BrotliStream and ZstandardStream are not supported on browser/wasi. + if (!OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi()) + { + yield return new object[] { "br" }; + yield return new object[] { "zstd" }; + } + } + + private static HttpContent CreateContent(string encoding, HttpContent inner) => encoding switch + { + "gzip" => new GZipCompressedContent(inner), + "br" => new BrotliCompressedContent(inner), + "zstd" => new ZstandardCompressedContent(inner), + _ => throw new ArgumentOutOfRangeException(nameof(encoding)) + }; + + 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[] Decompress(byte[] compressed, string encoding) + { + using var source = new MemoryStream(compressed); + using Stream decompressor = encoding switch + { + "gzip" => new GZipStream(source, CompressionMode.Decompress), + "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(); + } + + 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 b699cc0dafd7fd..4a4c7e893ea7fa 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,7 @@ Link="Common\System\Threading\Tasks\TaskTimeoutExtensions.cs" /> +