diff --git a/Directory.Packages.props b/Directory.Packages.props index 8868280..5c09f2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,6 @@ true - @@ -15,8 +14,8 @@ - - + + diff --git a/Gress.Tests/Gress.Tests.csproj b/Gress.Tests/Gress.Tests.csproj index 5290706..dab483a 100644 --- a/Gress.Tests/Gress.Tests.csproj +++ b/Gress.Tests/Gress.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/Gress.Tests/IntegrationSpecs.cs b/Gress.Tests/IntegrationSpecs.cs new file mode 100644 index 0000000..65fca69 --- /dev/null +++ b/Gress.Tests/IntegrationSpecs.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Gress.Integration; +using PowerKit; +using PowerKit.Extensions; +using Xunit; + +namespace Gress.Tests; + +public class IntegrationSpecs +{ + [Fact] + public async Task I_can_copy_a_stream_to_another_stream_with_progress() + { + // Arrange + using var buffer = SpanPool.Shared.Rent( + // Longer buffer to ensure multiple progress reports + 81920 * 2 + + 1000 + ); + + Random.Shared.NextBytes(buffer.Span); + var data = buffer.Span.ToArray(); + + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + var progress = new ProgressCollector(); + + // Act + await source.CopyToAsync(destination, progress); + + // Assert + destination.ToArray().Should().Equal(data); + progress.GetValues().Should().NotBeEmpty(); + progress.GetValues().Should().BeInAscendingOrder(); + } + + [Fact] + public async Task I_can_download_a_web_resource_as_a_byte_array_with_progress() + { + // Arrange + using var http = new HttpClient(); + var progress = new ProgressCollector(); + + // Act + var result = await http.GetByteArrayAsync( + // Need something that reports content length + "https://github.com/Tyrrrz/CliWrap/releases/download/3.10.1/CliWrap.3.10.1.nupkg", + progress + ); + + // Assert + result.Should().NotBeNullOrEmpty(); + progress.GetValues().Should().NotBeEmpty(); + } + + [Fact] + public async Task I_can_download_a_web_resource_as_a_string_with_progress() + { + // Arrange + using var http = new HttpClient(); + var progress = new ProgressCollector(); + + // Act + var result = await http.GetStringAsync( + // Need something that reports content length + "https://github.com/Tyrrrz/CliWrap/releases/download/3.10.1/CliWrap.3.10.1.nupkg", + progress + ); + + // Assert + result.Should().NotBeNullOrEmpty(); + progress.GetValues().Should().NotBeEmpty(); + } + + [Fact] + public async Task I_can_download_a_web_resource_to_a_file_with_progress() + { + // Arrange + using var http = new HttpClient(); + using var tempFile = TempFile.Create(); + var progress = new ProgressCollector(); + + // Act + await http.DownloadAsync( + // Need something that reports content length + "https://github.com/Tyrrrz/CliWrap/releases/download/3.10.1/CliWrap.3.10.1.nupkg", + tempFile.Path, + progress + ); + + // Assert + new FileInfo(tempFile.Path) + .Length.Should() + .BeGreaterThan(0); + + progress.GetValues().Should().NotBeEmpty(); + } +} diff --git a/Gress/Integration/HttpClientExtensions.cs b/Gress/Integration/HttpClientExtensions.cs new file mode 100644 index 0000000..67d94bd --- /dev/null +++ b/Gress/Integration/HttpClientExtensions.cs @@ -0,0 +1,120 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using PowerKit.Extensions; + +namespace Gress.Integration; + +/// +/// Provides progress-aware extensions for . +/// +public static class HttpClientExtensions +{ + /// + extension(HttpClient client) + { + /// + /// Sends a GET request and returns the response body as a byte array. + /// + public async Task GetByteArrayAsync( + Uri requestUri, + IProgress? progress, + CancellationToken cancellationToken = default + ) + { + using var response = await client + .GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + return await response + .Content.ReadAsByteArrayAsync(progress, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Sends a GET request and returns the response body as a byte array. + /// + public async Task GetByteArrayAsync( + string requestUri, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await client + .GetByteArrayAsync( + new Uri(requestUri, UriKind.RelativeOrAbsolute), + progress, + cancellationToken + ) + .ConfigureAwait(false); + + /// + /// Sends a GET request and returns the response body as text. + /// + public async Task GetStringAsync( + Uri requestUri, + IProgress? progress, + CancellationToken cancellationToken = default + ) + { + using var response = await client + .GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + return await response + .Content.ReadAsStringAsync(progress, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Sends a GET request and returns the response body as text. + /// + public async Task GetStringAsync( + string requestUri, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await client + .GetStringAsync( + new Uri(requestUri, UriKind.RelativeOrAbsolute), + progress, + cancellationToken + ) + .ConfigureAwait(false); + + /// + /// Sends a GET request and saves the response body to a file. + /// + public async Task DownloadAsync( + Uri requestUri, + string filePath, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await client + .DownloadAsync(requestUri, filePath, progress?.ToDoubleBased(), cancellationToken) + .ConfigureAwait(false); + + /// + /// Sends a GET request and saves the response body to a file. + /// + public async Task DownloadAsync( + string requestUri, + string filePath, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await client + .DownloadAsync( + new Uri(requestUri, UriKind.RelativeOrAbsolute), + filePath, + progress, + cancellationToken + ) + .ConfigureAwait(false); + } +} diff --git a/Gress/Integration/HttpContentExtensions.cs b/Gress/Integration/HttpContentExtensions.cs new file mode 100644 index 0000000..b7dcd95 --- /dev/null +++ b/Gress/Integration/HttpContentExtensions.cs @@ -0,0 +1,92 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using PowerKit.Extensions; + +namespace Gress.Integration; + +/// +/// Provides progress-aware extensions for . +/// +public static class HttpContentExtensions +{ + /// + extension(HttpContent content) + { + /// + /// Serializes the HTTP content and writes it to the specified stream. + /// + public async Task CopyToAsync( + Stream destination, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await content + .CopyToStreamAsync(destination, progress?.ToDoubleBased(), cancellationToken) + .ConfigureAwait(false); + + private async Task ReadAsMemoryStreamAsync( + IProgress? progress, + CancellationToken cancellationToken = default + ) + { + var destination = new MemoryStream(); + + await content + .CopyToAsync(destination, progress, cancellationToken) + .ConfigureAwait(false); + + destination.Position = 0; + + return destination; + } + + /// + /// Reads the HTTP content and returns it as a byte array. + /// + public async Task ReadAsByteArrayAsync( + IProgress? progress, + CancellationToken cancellationToken = default + ) + { + using var buffer = await content + .ReadAsMemoryStreamAsync(progress, cancellationToken) + .ConfigureAwait(false); + + return buffer.ToArray(); + } + + /// + /// Reads the HTTP content and returns it as a string. + /// + public async Task ReadAsStringAsync( + IProgress? progress, + CancellationToken cancellationToken = default + ) + { + var encoding = + content + .Headers.ContentType?.CharSet?.Pipe(c => + Encoding + .GetEncodings() + .FirstOrDefault(e => + string.Equals(e.Name, c, StringComparison.OrdinalIgnoreCase) + ) + ) + ?.GetEncoding() + ?? Encoding.UTF8; + + using var buffer = await content + .ReadAsMemoryStreamAsync(progress, cancellationToken) + .ConfigureAwait(false); + + using var reader = new StreamReader(buffer, encoding, true); + + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Gress/Integration/StreamExtensions.cs b/Gress/Integration/StreamExtensions.cs new file mode 100644 index 0000000..fc1875c --- /dev/null +++ b/Gress/Integration/StreamExtensions.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using PowerKit.Extensions; + +namespace Gress.Integration; + +/// +/// Provides progress-aware extensions for . +/// +public static class StreamExtensions +{ + /// + extension(Stream source) + { + /// + /// Asynchronously copies bytes from the source stream to the destination stream. + /// + public async Task CopyToAsync( + Stream destination, + long sourceLength, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await source + .CopyToAsync( + destination, + sourceLength, + progress?.ToDoubleBased(), + cancellationToken + ) + .ConfigureAwait(false); + + /// + /// Asynchronously copies bytes from the source stream to the destination stream. + /// + public async Task CopyToAsync( + Stream destination, + IProgress? progress, + CancellationToken cancellationToken = default + ) => + await source + .CopyToAsync( + destination, + source.CanSeek ? source.Length : -1, + progress, + cancellationToken + ) + .ConfigureAwait(false); + } +} diff --git a/Readme.md b/Readme.md index b80fdb8..2a1b8b4 100644 --- a/Readme.md +++ b/Readme.md @@ -538,3 +538,27 @@ subProgress3.Report(Percentage.FromFraction(0.5)); > [!NOTE] > You can wrap an instance of `ICompletableProgress` in a disposable container by calling `ToDisposable()`. > This allows you to place the handler in a `using (...)` block, which ensures that the completion is always reported at the end. + +### Integration extensions + +**Gress** also provides extensions for several built-in .NET types that integrate `IProgress` into their existing APIs. +For example, you can use the below `Stream.CopyToAsync(...)` overload to copy data between two streams while tracking the operation's progress: + +```csharp +using Gress; +using Gress.Integration; + +await using var source = File.OpenRead("input.bin"); +await using var destination = File.Create("output.bin"); + +await source.CopyToAsync( + destination, + new Progress(p => Console.WriteLine($"Copied: {p}")) +); + +// Console output: +// Copied: 37,5% +// Copied: 62,5% +// Copied: 87,5% +// Copied: 100,0% +```