From 18fd5e0f77b7302570327645e3de1af34f1bc8d4 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Tue, 2 Dec 2025 17:14:13 +0100 Subject: [PATCH 1/9] feat: add more IServiceCollection extensions --- .../Options/ServiceCollectionExtensions.cs | 59 +++++++++ .../Atc.Rest.Client.Tests.csproj | 1 + .../ServiceCollectionExtensionsTests.cs | 122 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs diff --git a/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs b/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs index 8fdfaa1..80d46fa 100644 --- a/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs +++ b/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs @@ -2,6 +2,65 @@ namespace Atc.Rest.Client.Options; public static class ServiceCollectionExtensions { + /// + /// Registers the core Atc.Rest.Client services (IHttpMessageFactory and IContractSerializer) + /// without HttpClient configuration. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddAtcRestClient( + this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + /// + /// Registers the core Atc.Rest.Client services with a custom contract serializer. + /// + /// The service collection. + /// The custom contract serializer to use. + /// The service collection for chaining. + /// Thrown when is null. + public static IServiceCollection AddAtcRestClient( + this IServiceCollection services, + IContractSerializer contractSerializer) + { + if (contractSerializer is null) + { + throw new ArgumentNullException(nameof(contractSerializer)); + } + + services.TryAddSingleton(contractSerializer); + services.TryAddSingleton(); + return services; + } + + /// + /// Registers the core Atc.Rest.Client services with configuration options. + /// + /// The service collection. + /// The configuration action for AtcRestClientOptions. + /// The service collection for chaining. + /// Thrown when is null. + public static IServiceCollection AddAtcRestClient( + this IServiceCollection services, + Action configure) + { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var options = new AtcRestClientOptions(); + configure(options); + + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + [EditorBrowsable(EditorBrowsableState.Never)] public static IServiceCollection AddAtcRestClient( this IServiceCollection services, diff --git a/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj b/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj index 883b55c..20a8ec3 100644 --- a/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj +++ b/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..b4fe043 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,122 @@ +namespace Atc.Rest.Client.Tests.Options; + +using Atc.Rest.Client.Options; +using Microsoft.Extensions.DependencyInjection; + +public sealed class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddAtcRestClient_RegistersHttpMessageFactory() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAtcRestClient(); + var provider = services.BuildServiceProvider(); + var factory = provider.GetService(); + + // Assert + factory.Should().NotBeNull(); + } + + [Fact] + public void AddAtcRestClient_RegistersDefaultContractSerializer() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAtcRestClient(); + var provider = services.BuildServiceProvider(); + var serializer = provider.GetService(); + + // Assert + serializer.Should().NotBeNull(); + serializer.Should().BeOfType(); + } + + [Fact] + public void AddAtcRestClient_WithCustomSerializer_UsesProvidedSerializer() + { + // Arrange + var services = new ServiceCollection(); + var customSerializer = Substitute.For(); + + // Act + services.AddAtcRestClient(customSerializer); + var provider = services.BuildServiceProvider(); + var resolvedSerializer = provider.GetService(); + + // Assert + resolvedSerializer.Should().BeSameAs(customSerializer); + } + + [Fact] + public void AddAtcRestClient_DoesNotOverwriteExistingRegistrations() + { + // Arrange + var services = new ServiceCollection(); + var existingSerializer = Substitute.For(); + services.AddSingleton(existingSerializer); + + // Act + services.AddAtcRestClient(); + var provider = services.BuildServiceProvider(); + var resolvedSerializer = provider.GetService(); + + // Assert + resolvedSerializer.Should().BeSameAs(existingSerializer); + } + + [Fact] + public void AddAtcRestClient_WithNullSerializer_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var act = () => services.AddAtcRestClient((IContractSerializer)null!); + + // Assert + act.Should().Throw() + .WithParameterName("contractSerializer"); + } + + [Fact] + public void AddAtcRestClient_WithConfigure_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var configureWasCalled = false; + + // Act + services.AddAtcRestClient(options => + { + configureWasCalled = true; + options.Timeout = TimeSpan.FromSeconds(60); + }); + var provider = services.BuildServiceProvider(); + var factory = provider.GetService(); + var serializer = provider.GetService(); + + // Assert + configureWasCalled.Should().BeTrue(); + factory.Should().NotBeNull(); + serializer.Should().NotBeNull(); + } + + [Fact] + public void AddAtcRestClient_WithNullConfigure_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var act = () => services.AddAtcRestClient((Action)null!); + + // Assert + act.Should().Throw() + .WithParameterName("configure"); + } +} \ No newline at end of file From 51d51d85643bce75a049fec304198dec6bfb9543 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Tue, 2 Dec 2025 17:55:31 +0100 Subject: [PATCH 2/9] feat: extend IContractSerializer with DeserializeAsyncEnumerable --- .../Serialization/DefaultJsonContractSerializer.cs | 8 ++++++++ .../Serialization/IContractSerializer.cs | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs b/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs index 2ed18ea..553b09f 100644 --- a/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs +++ b/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs @@ -53,4 +53,12 @@ public string Serialize( utf8Json, returnType, options); + + public IAsyncEnumerable DeserializeAsyncEnumerable( + Stream stream, + CancellationToken cancellationToken = default) + => JsonSerializer.DeserializeAsyncEnumerable( + stream, + options, + cancellationToken); } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Serialization/IContractSerializer.cs b/src/Atc.Rest.Client/Serialization/IContractSerializer.cs index 05fd7c8..5b1f7d8 100644 --- a/src/Atc.Rest.Client/Serialization/IContractSerializer.cs +++ b/src/Atc.Rest.Client/Serialization/IContractSerializer.cs @@ -18,4 +18,15 @@ string Serialize( object? Deserialize( byte[] utf8Json, Type returnType); + + /// + /// Deserializes a stream as an async enumerable sequence of items. + /// + /// The type of items to deserialize. + /// The stream containing JSON array data. + /// The cancellation token. + /// An async enumerable of deserialized items. + IAsyncEnumerable DeserializeAsyncEnumerable( + Stream stream, + CancellationToken cancellationToken = default); } \ No newline at end of file From d613b25e046652e97c37f6dd6ee09f42a6f5c1cb Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Tue, 2 Dec 2025 17:56:35 +0100 Subject: [PATCH 3/9] feat: add BinaryEndpointResponse --- src/Atc.Rest.Client/BinaryEndpointResponse.cs | 68 +++++++++++++++++++ .../BinaryEndpointResponseTests.cs | 65 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/Atc.Rest.Client/BinaryEndpointResponse.cs create mode 100644 test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs diff --git a/src/Atc.Rest.Client/BinaryEndpointResponse.cs b/src/Atc.Rest.Client/BinaryEndpointResponse.cs new file mode 100644 index 0000000..4149b4d --- /dev/null +++ b/src/Atc.Rest.Client/BinaryEndpointResponse.cs @@ -0,0 +1,68 @@ +namespace Atc.Rest.Client; + +/// +/// Represents a binary file response from an endpoint. +/// +[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Binary content requires array for practical usage.")] +public class BinaryEndpointResponse +{ + /// + /// Initializes a new instance of the class. + /// + /// Whether the request was successful. + /// The HTTP status code. + /// The binary content. + /// The content type. + /// The file name from Content-Disposition header. + /// The content length. + public BinaryEndpointResponse( + bool isSuccess, + HttpStatusCode statusCode, + byte[]? content, + string? contentType, + string? fileName, + long? contentLength) + { + IsSuccess = isSuccess; + StatusCode = statusCode; + Content = content; + ContentType = contentType; + FileName = fileName; + ContentLength = contentLength; + } + + /// + /// Gets a value indicating whether the request was successful. + /// + public bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the status code indicates OK (200). + /// + public bool IsOk => StatusCode == HttpStatusCode.OK; + + /// + /// Gets the HTTP status code. + /// + public HttpStatusCode StatusCode { get; } + + /// + /// Gets the binary content. + /// + public byte[]? Content { get; } + + /// + /// Gets the content type. + /// + public string? ContentType { get; } + + /// + /// Gets the file name from the Content-Disposition header. + /// + public string? FileName { get; } + + /// + /// Gets the content length. + /// + public long? ContentLength { get; } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs b/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs new file mode 100644 index 0000000..679d089 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs @@ -0,0 +1,65 @@ +namespace Atc.Rest.Client.Tests; + +public sealed class BinaryEndpointResponseTests +{ + [Fact] + public void Constructor_SetsAllProperties() + { + // Arrange + var content = new byte[] { 1, 2, 3 }; + + // Act + var sut = new BinaryEndpointResponse( + isSuccess: true, + HttpStatusCode.OK, + content, + "application/octet-stream", + "test.bin", + contentLength: 3); + + // Assert + sut.IsSuccess.Should().BeTrue(); + sut.IsOk.Should().BeTrue(); + sut.StatusCode.Should().Be(HttpStatusCode.OK); + sut.Content.Should().BeEquivalentTo(content); + sut.ContentType.Should().Be("application/octet-stream"); + sut.FileName.Should().Be("test.bin"); + sut.ContentLength.Should().Be(3); + } + + [Fact] + public void IsOk_WhenStatusCodeIsNotOK_ReturnsFalse() + { + // Arrange & Act + var sut = new BinaryEndpointResponse( + isSuccess: true, + HttpStatusCode.Created, + content: null, + contentType: null, + fileName: null, + contentLength: null); + + // Assert + sut.IsOk.Should().BeFalse(); + } + + [Fact] + public void Constructor_WithNullValues_SetsPropertiesToNull() + { + // Arrange & Act + var sut = new BinaryEndpointResponse( + isSuccess: false, + HttpStatusCode.NotFound, + content: null, + contentType: null, + fileName: null, + contentLength: null); + + // Assert + sut.IsSuccess.Should().BeFalse(); + sut.Content.Should().BeNull(); + sut.ContentType.Should().BeNull(); + sut.FileName.Should().BeNull(); + sut.ContentLength.Should().BeNull(); + } +} \ No newline at end of file From 192f73bd89a7742287af327d243eeeedf45cef76 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Tue, 2 Dec 2025 17:56:51 +0100 Subject: [PATCH 4/9] feat: add StreamBinaryEndpointResponse --- .../StreamBinaryEndpointResponse.cs | 97 +++++++++++++++++++ .../StreamBinaryEndpointResponseTests.cs | 70 +++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs create mode 100644 test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs diff --git a/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs b/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs new file mode 100644 index 0000000..ce93973 --- /dev/null +++ b/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs @@ -0,0 +1,97 @@ +namespace Atc.Rest.Client; + +/// +/// Represents a streaming binary response from an endpoint. +/// +public class StreamBinaryEndpointResponse : IDisposable +{ + private bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Whether the request was successful. + /// The HTTP status code. + /// The content stream. + /// The content type. + /// The file name from Content-Disposition header. + /// The content length. + public StreamBinaryEndpointResponse( + bool isSuccess, + HttpStatusCode statusCode, + Stream? contentStream, + string? contentType, + string? fileName, + long? contentLength) + { + IsSuccess = isSuccess; + StatusCode = statusCode; + ContentStream = contentStream; + ContentType = contentType; + FileName = fileName; + ContentLength = contentLength; + } + + /// + /// Gets a value indicating whether the request was successful. + /// + public bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the status code indicates OK (200). + /// + public bool IsOk => StatusCode == HttpStatusCode.OK; + + /// + /// Gets the HTTP status code. + /// + public HttpStatusCode StatusCode { get; } + + /// + /// Gets the content stream. + /// + public Stream? ContentStream { get; } + + /// + /// Gets the content type. + /// + public string? ContentType { get; } + + /// + /// Gets the file name from the Content-Disposition header. + /// + public string? FileName { get; } + + /// + /// Gets the content length. + /// + public long? ContentLength { get; } + + /// + /// Disposes the content stream. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes managed resources. + /// + /// Whether to dispose managed resources. + protected virtual void Dispose(bool disposing) + { + if (disposed) + { + return; + } + + if (disposing) + { + ContentStream?.Dispose(); + } + + disposed = true; + } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs b/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs new file mode 100644 index 0000000..17c87dc --- /dev/null +++ b/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs @@ -0,0 +1,70 @@ +namespace Atc.Rest.Client.Tests; + +public sealed class StreamBinaryEndpointResponseTests +{ + [Fact] + public void Constructor_SetsAllProperties() + { + // Arrange + using var stream = new MemoryStream([1, 2, 3]); + + // Act + using var sut = new StreamBinaryEndpointResponse( + isSuccess: true, + HttpStatusCode.OK, + stream, + "application/octet-stream", + "test.bin", + contentLength: 3); + + // Assert + sut.IsSuccess.Should().BeTrue(); + sut.IsOk.Should().BeTrue(); + sut.StatusCode.Should().Be(HttpStatusCode.OK); + sut.ContentStream.Should().BeSameAs(stream); + sut.ContentType.Should().Be("application/octet-stream"); + sut.FileName.Should().Be("test.bin"); + sut.ContentLength.Should().Be(3); + } + + [Fact] + public void Dispose_DisposesContentStream() + { + // Arrange + var stream = new MemoryStream([1, 2, 3]); + var sut = new StreamBinaryEndpointResponse( + isSuccess: true, + HttpStatusCode.OK, + stream, + contentType: null, + fileName: null, + contentLength: null); + + // Act + sut.Dispose(); + + // Assert + var act = () => stream.ReadByte(); + act.Should().Throw(); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes_WithoutThrowing() + { + // Arrange + using var stream = new MemoryStream([1, 2, 3]); + using var sut = new StreamBinaryEndpointResponse( + isSuccess: true, + HttpStatusCode.OK, + stream, + contentType: null, + fileName: null, + contentLength: null); + + // Act + var act = () => sut.Dispose(); + + // Assert - first dispose already happens via using, this is the second + act.Should().NotThrow(); + } +} \ No newline at end of file From 6703ba675b444dad473ba46420281db53bd0e250 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Tue, 2 Dec 2025 17:57:51 +0100 Subject: [PATCH 5/9] feat: extend IMessageRequestBuilder and IMessageResponseBuilder --- src/Atc.Rest.Client/Atc.Rest.Client.csproj | 1 + .../Builder/IMessageRequestBuilder.cs | 38 +++ .../Builder/IMessageResponseBuilder.cs | 28 ++ .../Builder/MessageRequestBuilder.cs | 95 +++++++ .../Builder/MessageResponseBuilder.cs | 72 +++++ src/Atc.Rest.Client/EndpointResponse.cs | 2 +- src/Atc.Rest.Client/GlobalUsings.cs | 1 + src/Atc.Rest.Client/InternalVisibleTo.cs | 2 +- .../MessageRequestBuilderAdditionalTests.cs | 264 ++++++++++++++++++ .../MessageRequestBuilderMultipartTests.cs | 171 ++++++++++++ 10 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs create mode 100644 test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs diff --git a/src/Atc.Rest.Client/Atc.Rest.Client.csproj b/src/Atc.Rest.Client/Atc.Rest.Client.csproj index cb2c1e3..6293732 100644 --- a/src/Atc.Rest.Client/Atc.Rest.Client.csproj +++ b/src/Atc.Rest.Client/Atc.Rest.Client.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs b/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs index ce9f836..229cde3 100644 --- a/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs +++ b/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs @@ -57,4 +57,42 @@ public interface IMessageRequestBuilder /// The to use in the request. /// The created . HttpRequestMessage Build(HttpMethod method); + + /// + /// Sets the HTTP completion option for the request. + /// Use for streaming scenarios. + /// + /// The HTTP completion option. + /// The . + IMessageRequestBuilder WithHttpCompletionOption(HttpCompletionOption completionOption); + + /// + /// Gets the HTTP completion option set for this request. + /// + HttpCompletionOption HttpCompletionOption { get; } + + /// + /// Adds a file to the multipart form data content. + /// + /// The file stream to upload. + /// The form field name. + /// The file name. + /// Optional content type. Defaults to application/octet-stream. + /// The . + IMessageRequestBuilder WithFile(Stream stream, string name, string fileName, string? contentType = null); + + /// + /// Adds multiple files to the multipart form data content. + /// + /// Collection of files with Stream, Name, FileName, and optional ContentType. + /// The . + IMessageRequestBuilder WithFiles(IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files); + + /// + /// Adds a form field to the multipart form data content. + /// + /// The form field name. + /// The form field value. + /// The . + IMessageRequestBuilder WithFormField(string name, string value); } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs b/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs index 5215be3..259c945 100644 --- a/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs +++ b/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs @@ -28,4 +28,32 @@ Task BuildResponseAsync( CancellationToken cancellationToken) where TSuccessContent : class where TErrorContent : class; + + /// + /// Builds a binary response from the HTTP response. + /// + /// The cancellation token. + /// A containing the binary content. + Task BuildBinaryResponseAsync( + CancellationToken cancellationToken); + + /// + /// Builds a streaming binary response from the HTTP response. + /// + /// + /// The caller is responsible for disposing the returned response. + /// + /// The cancellation token. + /// A containing the content stream. + Task BuildStreamBinaryResponseAsync( + CancellationToken cancellationToken); + + /// + /// Builds a streaming response that yields items as they are deserialized from the response stream. + /// + /// The type of items to deserialize. + /// The cancellation token. + /// An async enumerable of deserialized items, or an empty enumerable if the response is null or failed. + IAsyncEnumerable BuildStreamingResponseAsync( + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs b/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs index 3e0012a..a9afd0b 100644 --- a/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs +++ b/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs @@ -7,6 +7,8 @@ internal class MessageRequestBuilder : IMessageRequestBuilder private readonly Dictionary pathMapper; private readonly Dictionary headerMapper; private readonly Dictionary queryMapper; + private readonly Dictionary formFields; + private readonly List<(Stream Stream, string Name, string FileName, string? ContentType)> streamFiles; private string? content; private List? contentFormFiles; @@ -19,9 +21,14 @@ public MessageRequestBuilder( pathMapper = new Dictionary(StringComparer.Ordinal); headerMapper = new Dictionary(StringComparer.Ordinal); queryMapper = new Dictionary(StringComparer.Ordinal); + formFields = new Dictionary(StringComparer.Ordinal); + streamFiles = []; WithHeaderParameter("accept", "application/json"); } + /// + public HttpCompletionOption HttpCompletionOption { get; private set; } = HttpCompletionOption.ResponseContentRead; + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "OK - ByteArrayContent can't be disposed.")] public HttpRequestMessage Build( HttpMethod method) @@ -40,6 +47,28 @@ public HttpRequestMessage Build( message.Content = new StringContent(content); message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); } + else if (streamFiles.Count > 0 || formFields.Count > 0) + { + var formDataContent = new MultipartFormDataContent(); + + foreach (var formField in formFields) + { + formDataContent.Add(new StringContent(formField.Value), formField.Key); + } + + foreach (var file in streamFiles) + { + var streamContent = new StreamContent(file.Stream); + if (file.ContentType is not null) + { + streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(file.ContentType); + } + + formDataContent.Add(streamContent, file.Name, file.FileName); + } + + message.Content = formDataContent; + } else if (contentFormFiles is not null) { var formDataContent = new MultipartFormDataContent(); @@ -208,4 +237,70 @@ private static string BuildQueryKeyEqualValue( => pair.Key.StartsWith("#", StringComparison.Ordinal) ? $"{pair.Key.Replace("#", string.Empty)}={pair.Value}" : $"{pair.Key}={Uri.EscapeDataString(pair.Value)}"; + + public IMessageRequestBuilder WithFile( + Stream stream, + string name, + string fileName, + string? contentType = null) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException($"'{nameof(fileName)}' cannot be null or whitespace", nameof(fileName)); + } + + streamFiles.Add((stream, name, fileName, contentType)); + return this; + } + + public IMessageRequestBuilder WithFiles( + IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files) + { + if (files is null) + { + throw new ArgumentNullException(nameof(files)); + } + + foreach (var file in files) + { + WithFile(file.Stream, file.Name, file.FileName, file.ContentType); + } + + return this; + } + + public IMessageRequestBuilder WithFormField( + string name, + string value) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace", nameof(name)); + } + + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + formFields[name] = value; + return this; + } + + public IMessageRequestBuilder WithHttpCompletionOption( + HttpCompletionOption completionOption) + { + HttpCompletionOption = completionOption; + return this; + } } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs b/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs index 116ad3c..eb364ed 100644 --- a/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs +++ b/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs @@ -109,6 +109,78 @@ public Task> r => new EndpointResponse(r), cancellationToken); + public async Task BuildBinaryResponseAsync( + CancellationToken cancellationToken) + { + if (response is null) + { + return new BinaryEndpointResponse( + isSuccess: false, + HttpStatusCode.InternalServerError, + content: null, + contentType: null, + fileName: null, + contentLength: null); + } + + var content = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType; + var fileName = response.Content.Headers.ContentDisposition?.FileName?.Trim('"'); + var contentLength = response.Content.Headers.ContentLength; + + return new BinaryEndpointResponse( + response.IsSuccessStatusCode, + response.StatusCode, + content, + contentType, + fileName, + contentLength); + } + + public async Task BuildStreamBinaryResponseAsync( + CancellationToken cancellationToken) + { + if (response is null) + { + return new StreamBinaryEndpointResponse( + isSuccess: false, + HttpStatusCode.InternalServerError, + contentStream: null, + contentType: null, + fileName: null, + contentLength: null); + } + + var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType; + var fileName = response.Content.Headers.ContentDisposition?.FileName?.Trim('"'); + var contentLength = response.Content.Headers.ContentLength; + + return new StreamBinaryEndpointResponse( + response.IsSuccessStatusCode, + response.StatusCode, + contentStream, + contentType, + fileName, + contentLength); + } + + public async IAsyncEnumerable BuildStreamingResponseAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (response is null || !response.IsSuccessStatusCode) + { + yield break; + } + + var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + await foreach (var item in serializer.DeserializeAsyncEnumerable(stream, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + private static bool UseReadAsStringFromContentDependingOnContentType( MediaTypeHeaderValue? headersContentType) => headersContentType?.MediaType is null || diff --git a/src/Atc.Rest.Client/EndpointResponse.cs b/src/Atc.Rest.Client/EndpointResponse.cs index b0f15d2..1446105 100644 --- a/src/Atc.Rest.Client/EndpointResponse.cs +++ b/src/Atc.Rest.Client/EndpointResponse.cs @@ -4,7 +4,7 @@ public class EndpointResponse : IEndpointResponse { public EndpointResponse(EndpointResponse response) : this( - response?.IsSuccess ?? throw new System.ArgumentNullException(nameof(response)), + response?.IsSuccess ?? throw new ArgumentNullException(nameof(response)), response.StatusCode, response.Content, response.ContentObject, diff --git a/src/Atc.Rest.Client/GlobalUsings.cs b/src/Atc.Rest.Client/GlobalUsings.cs index 1c64a8e..3f53d15 100644 --- a/src/Atc.Rest.Client/GlobalUsings.cs +++ b/src/Atc.Rest.Client/GlobalUsings.cs @@ -4,6 +4,7 @@ global using System.Net; global using System.Net.Http.Headers; global using System.Reflection; +global using System.Runtime.CompilerServices; global using System.Runtime.Serialization; global using System.Text; global using System.Text.Json; diff --git a/src/Atc.Rest.Client/InternalVisibleTo.cs b/src/Atc.Rest.Client/InternalVisibleTo.cs index c284a31..15ed7f3 100644 --- a/src/Atc.Rest.Client/InternalVisibleTo.cs +++ b/src/Atc.Rest.Client/InternalVisibleTo.cs @@ -1 +1 @@ -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Atc.Rest.Client.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("Atc.Rest.Client.Tests")] \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs new file mode 100644 index 0000000..99aa8dc --- /dev/null +++ b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs @@ -0,0 +1,264 @@ +namespace Atc.Rest.Client.Tests.Builder; + +public sealed class MessageRequestBuilderAdditionalTests +{ + private readonly IContractSerializer serializer = Substitute.For(); + + private MessageRequestBuilder CreateSut(string template = "/api") => new(template, serializer); + + [Fact] + public void Build_WithNoContent_ReturnsMessageWithNullContent() + { + // Arrange + var sut = CreateSut(); + + // Act + var message = sut.Build(HttpMethod.Get); + + // Assert + message.Content.Should().BeNull(); + } + + [Fact] + public void Build_SetsRequestUri() + { + // Arrange + var sut = CreateSut("/api/users"); + + // Act + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri.Should().NotBeNull(); + message.RequestUri!.ToString().Should().Be("/api/users"); + } + + [Fact] + public void WithHeaderParameter_AddsCustomHeader() + { + // Arrange + var sut = CreateSut(); + + // Act + sut.WithHeaderParameter("X-Custom-Header", "custom-value"); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.Headers.GetValues("X-Custom-Header").Should().Contain("custom-value"); + } + + [Fact] + public void WithHeaderParameter_CanOverwriteAcceptHeader() + { + // Arrange + var sut = CreateSut(); + + // Act + sut.WithHeaderParameter("accept", "text/plain"); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.Headers.Accept.Should().ContainSingle() + .Which.MediaType.Should().Be("text/plain"); + } + + [Fact] + public void WithHeaderParameter_WithNullValue_DoesNotAddHeader() + { + // Arrange + var sut = CreateSut(); + + // Act + sut.WithHeaderParameter("X-Optional", null); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.Headers.Contains("X-Optional").Should().BeFalse(); + } + + [Fact] + public void WithQueryParameter_WithNullValue_DoesNotAddParameter() + { + // Arrange + var sut = CreateSut("/api"); + + // Act + sut.WithQueryParameter("optional", null); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Be("/api"); + } + + [Fact] + public void WithQueryParameter_ThrowsOnNullOrWhitespaceName() + { + // Arrange + var sut = CreateSut(); + + // Act & Assert + sut.Invoking(x => x.WithQueryParameter(null!, "value")) + .Should().Throw(); + + sut.Invoking(x => x.WithQueryParameter("", "value")) + .Should().Throw(); + + sut.Invoking(x => x.WithQueryParameter(" ", "value")) + .Should().Throw(); + } + + [Fact] + public void WithPathParameter_UrlEncodesSpecialCharacters() + { + // Arrange + var sut = CreateSut("/api/users/{id}"); + + // Act + sut.WithPathParameter("id", "user/with/slashes"); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Be("/api/users/user%2Fwith%2Fslashes"); + } + + [Fact] + public void WithQueryParameter_UrlEncodesSpecialCharacters() + { + // Arrange + var sut = CreateSut("/api"); + + // Act + sut.WithQueryParameter("search", "hello world&special=chars"); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Contain("search=hello%20world%26special%3Dchars"); + } + + [Fact] + public void CombinedParameters_BuildsCorrectUri() + { + // Arrange + var sut = CreateSut("/api/{resource}/{id}"); + + // Act + sut.WithPathParameter("resource", "users"); + sut.WithPathParameter("id", "123"); + sut.WithQueryParameter("include", "details"); + sut.WithQueryParameter("format", "json"); + sut.WithHeaderParameter("Authorization", "Bearer token"); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Be("/api/users/123?include=details&format=json"); + message.Headers.GetValues("Authorization").Should().Contain("Bearer token"); + } + + [Fact] + public void FluentApi_ReturnsSameInstance() + { + // Arrange + var sut = CreateSut("/api/{id}"); + + // Act & Assert + sut.WithPathParameter("id", "1").Should().BeSameAs(sut); + sut.WithQueryParameter("q", "test").Should().BeSameAs(sut); + sut.WithHeaderParameter("X-Test", "value").Should().BeSameAs(sut); + sut.WithHttpCompletionOption(HttpCompletionOption.ResponseHeadersRead).Should().BeSameAs(sut); + } + + [Fact] + public void WithBody_SerializesObjectUsingSerializer() + { + // Arrange + var sut = CreateSut(); + var body = new { Name = "Test", Value = 42 }; + serializer.Serialize(body).Returns("{\"name\":\"Test\",\"value\":42}"); + + // Act + sut.WithBody(body); + _ = sut.Build(HttpMethod.Post); + + // Assert + serializer.Received(1).Serialize(body); + } + + [Fact] + public void WithQueryParameter_WithBooleanValue_ConvertsToString() + { + // Arrange + var sut = CreateSut("/api"); + + // Act + sut.WithQueryParameter("active", true); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Be("/api?active=True"); + } + + [Fact] + public void Build_CanBeCalledMultipleTimes() + { + // Arrange + var sut = CreateSut("/api"); + sut.WithQueryParameter("page", 1); + + // Act + var message1 = sut.Build(HttpMethod.Get); + var message2 = sut.Build(HttpMethod.Post); + + // Assert + message1.Method.Should().Be(HttpMethod.Get); + message2.Method.Should().Be(HttpMethod.Post); + message1.RequestUri.Should().Be(message2.RequestUri); + } + + [Fact] + public void WithPathParameter_MultipleReplacements_WorksCorrectly() + { + // Arrange + var sut = CreateSut("/api/{version}/users/{userId}/orders/{orderId}"); + + // Act + sut.WithPathParameter("version", "v1"); + sut.WithPathParameter("userId", "user123"); + sut.WithPathParameter("orderId", "order456"); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Be("/api/v1/users/user123/orders/order456"); + } + + [Fact] + public void WithQueryParameter_EmptyArray_DoesNotAddParameter() + { + // Arrange + var sut = CreateSut("/api"); + + // Act + sut.WithQueryParameter("ids", Array.Empty()); + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri!.ToString().Should().Be("/api?ids="); + } + + [Theory] + [InlineData("/api")] + [InlineData("api")] + [InlineData("https://example.com/api")] + [InlineData("/api/v1/users")] + public void Build_HandlesVariousUriFormats(string template) + { + // Arrange + var sut = CreateSut(template); + + // Act + var message = sut.Build(HttpMethod.Get); + + // Assert + message.RequestUri.Should().NotBeNull(); + message.RequestUri!.ToString().Should().Be(template); + } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs new file mode 100644 index 0000000..78cc5bb --- /dev/null +++ b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs @@ -0,0 +1,171 @@ +namespace Atc.Rest.Client.Tests.Builder; + +public sealed class MessageRequestBuilderMultipartTests +{ + private readonly IContractSerializer serializer = Substitute.For(); + + private MessageRequestBuilder CreateSut(string template = "/test") => new(template, serializer); + + [Fact] + public void WithFile_AddsFileToRequest() + { + // Arrange + var sut = CreateSut(); + using var stream = new MemoryStream([1, 2, 3]); + + // Act + var result = sut.WithFile(stream, "file", "test.txt", "text/plain"); + + // Assert + result.Should().BeSameAs(sut); + var message = sut.Build(HttpMethod.Post); + message.Content.Should().BeOfType(); + } + + [Fact] + public void WithFile_WithNullStream_ThrowsArgumentNullException() + { + // Arrange + var sut = CreateSut(); + + // Act + var act = () => sut.WithFile(null!, "file", "test.txt"); + + // Assert + act.Should().Throw() + .WithParameterName("stream"); + } + + [Fact] + public void WithFile_WithNullName_ThrowsArgumentException() + { + // Arrange + var sut = CreateSut(); + using var stream = new MemoryStream(); + + // Act + var act = () => sut.WithFile(stream, null!, "test.txt"); + + // Assert + act.Should().Throw() + .WithParameterName("name"); + } + + [Fact] + public void WithFile_WithNullFileName_ThrowsArgumentException() + { + // Arrange + var sut = CreateSut(); + using var stream = new MemoryStream(); + + // Act + var act = () => sut.WithFile(stream, "file", null!); + + // Assert + act.Should().Throw() + .WithParameterName("fileName"); + } + + [Fact] + public void WithFiles_AddsMultipleFilesToRequest() + { + // Arrange + var sut = CreateSut(); + using var stream1 = new MemoryStream([1, 2, 3]); + using var stream2 = new MemoryStream([4, 5, 6]); + var files = new List<(Stream, string, string, string?)> + { + (stream1, "file1", "test1.txt", "text/plain"), + (stream2, "file2", "test2.txt", null), + }; + + // Act + var result = sut.WithFiles(files); + + // Assert + result.Should().BeSameAs(sut); + var message = sut.Build(HttpMethod.Post); + message.Content.Should().BeOfType(); + } + + [Fact] + public void WithFiles_WithNullFiles_ThrowsArgumentNullException() + { + // Arrange + var sut = CreateSut(); + + // Act + var act = () => sut.WithFiles(null!); + + // Assert + act.Should().Throw() + .WithParameterName("files"); + } + + [Fact] + public void WithFormField_AddsFormFieldToRequest() + { + // Arrange + var sut = CreateSut(); + + // Act + var result = sut.WithFormField("key", "value"); + + // Assert + result.Should().BeSameAs(sut); + var message = sut.Build(HttpMethod.Post); + message.Content.Should().BeOfType(); + } + + [Fact] + public void WithFormField_WithNullName_ThrowsArgumentException() + { + // Arrange + var sut = CreateSut(); + + // Act + var act = () => sut.WithFormField(null!, "value"); + + // Assert + act.Should().Throw() + .WithParameterName("name"); + } + + [Fact] + public void WithFormField_WithNullValue_ThrowsArgumentNullException() + { + // Arrange + var sut = CreateSut(); + + // Act + var act = () => sut.WithFormField("key", null!); + + // Assert + act.Should().Throw() + .WithParameterName("value"); + } + + [Fact] + public void WithHttpCompletionOption_SetsOption() + { + // Arrange + var sut = CreateSut(); + + // Act + var result = sut.WithHttpCompletionOption(HttpCompletionOption.ResponseHeadersRead); + + // Assert + result.Should().BeSameAs(sut); + sut.HttpCompletionOption.Should().Be(HttpCompletionOption.ResponseHeadersRead); + } + + [Fact] + public void HttpCompletionOption_DefaultsToResponseContentRead() + { + // Arrange & Act + var sut = CreateSut(); + + // Assert + sut.HttpCompletionOption.Should().Be(HttpCompletionOption.ResponseContentRead); + } +} \ No newline at end of file From 1d095ef03a85e7fa21ab97ee4021739f3558dfdf Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Tue, 2 Dec 2025 18:03:18 +0100 Subject: [PATCH 6/9] chore: upgrade to .NET 10, C# 14, and xUnit v3 --- .github/workflows/post-integration.yml | 10 +- .github/workflows/pre-integration.yml | 22 +- .github/workflows/release.yml | 10 +- Directory.Build.props | 20 +- README.md | 192 +++++++++++++++++- global.json | 5 +- .../Atc.Rest.Client.Tests.csproj | 16 +- test/Directory.Build.props | 18 +- 8 files changed, 237 insertions(+), 56 deletions(-) diff --git a/.github/workflows/post-integration.yml b/.github/workflows/post-integration.yml index 3232292..41f6757 100644 --- a/.github/workflows/post-integration.yml +++ b/.github/workflows/post-integration.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.PAT_WORKFLOWS }} @@ -30,10 +30,10 @@ jobs: with: setAllVars: true - - name: โš™๏ธ Setup dotnet 9.0.x - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -59,4 +59,4 @@ jobs: run: dotnet pack -c Release --no-restore -o ${GITHUB_WORKSPACE}/packages -p:RepositoryBranch=$BRANCH_NAME - name: ๐Ÿ“ฆ Push packages to GitHub Package Registry - run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Rest.Client.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.GITHUB_TOKEN }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate \ No newline at end of file + run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Rest.Client.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.GITHUB_TOKEN }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate diff --git a/.github/workflows/pre-integration.yml b/.github/workflows/pre-integration.yml index c312531..6e87131 100644 --- a/.github/workflows/pre-integration.yml +++ b/.github/workflows/pre-integration.yml @@ -8,21 +8,21 @@ on: - reopened jobs: - dotnet5-build: + dotnet-build: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: โš™๏ธ Setup dotnet 9.0.x - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -36,17 +36,17 @@ jobs: dotnet-test: runs-on: ubuntu-latest needs: - - dotnet5-build + - dotnet-build steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: โš™๏ธ Setup dotnet 9.0.x - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿ” Restore packages run: dotnet restore @@ -55,4 +55,4 @@ jobs: run: dotnet build -c Release --no-restore /p:UseSourceLink=true - name: ๐Ÿงช Run unit tests - run: dotnet test -c Release --no-build \ No newline at end of file + run: dotnet test -c Release --no-build -- --filter-query "/[category!=integration]" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b65a8d5..7531794 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.PAT_WORKFLOWS }} @@ -27,10 +27,10 @@ jobs: with: setAllVars: true - - name: โš™๏ธ Setup dotnet 9.0.x - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -53,4 +53,4 @@ jobs: run: dotnet pack -c Release --no-restore -o ${GITHUB_WORKSPACE}/packages -p:RepositoryBranch=$BRANCH_NAME /p:PublicRelease=true - name: ๐Ÿ“ฆ Push packages to NuGet - run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/Atc.Rest.Client.${{ env.NBGV_NuGetPackageVersion }}.nupkg -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate --no-symbols true \ No newline at end of file + run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/Atc.Rest.Client.${{ env.NBGV_NuGetPackageVersion }}.nupkg -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate --no-symbols true diff --git a/Directory.Build.props b/Directory.Build.props index ba4917a..9d55000 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,22 +16,20 @@ enable - 13.0 + 14.0 enable - net9.0 + net10.0 true - 1573,1591,1712,CA1014,NU5104 + 1573,1591,1712,CA1014,NETSDK1198 full true - - AllEnabledByDefault - true - latest - true + + latest-All + false @@ -43,10 +41,10 @@ - + - - + + \ No newline at end of file diff --git a/README.md b/README.md index 71d7684..f696dce 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,17 @@ A lightweight and flexible REST client library for .NET, providing a clean abstr - [Getting Started](#getting-started) - [Installation](#installation) - [Service Registration](#service-registration) - - [Approach 1: Direct Configuration (Recommended for Simple Cases)](#approach-1-direct-configuration-recommended-for-simple-cases) - - [Approach 2: Custom Options Type](#approach-2-custom-options-type) + - [Approach 1: Simple Registration (No HttpClient Configuration)](#approach-1-simple-registration-no-httpclient-configuration) + - [Approach 2: Direct Configuration](#approach-2-direct-configuration) + - [Approach 3: Custom Options Type](#approach-3-custom-options-type) - [Creating an Endpoint](#creating-an-endpoint) - [Usage Examples](#usage-examples) - [Simple GET Request](#simple-get-request) - [POST Request with Body](#post-request-with-body) - [Using Path and Query Parameters](#using-path-and-query-parameters) + - [File Upload (Multipart Form Data)](#file-upload-multipart-form-data) + - [File Download (Binary Response)](#file-download-binary-response) + - [Streaming Responses (IAsyncEnumerable)](#streaming-responses-iasyncenumerable) - [Handling Responses](#handling-responses) - [Success and Error Response Handling](#success-and-error-response-handling) - [Custom Response Processing](#custom-response-processing) @@ -33,6 +37,8 @@ A lightweight and flexible REST client library for .NET, providing a clean abstr - [`IMessageRequestBuilder`](#imessagerequestbuilder) - [`IMessageResponseBuilder`](#imessageresponsebuilder) - [`EndpointResponse`](#endpointresponse) + - [`BinaryEndpointResponse`](#binaryendpointresponse) + - [`StreamBinaryEndpointResponse`](#streambinaryendpointresponse) - [How to Contribute](#how-to-contribute) ## Features @@ -45,6 +51,10 @@ A lightweight and flexible REST client library for .NET, providing a clean abstr - **Query & Header Parameters**: Easy addition of query strings and headers - **Custom Serialization**: Pluggable contract serialization (defaults to JSON) - **Response Processing**: Built-in support for success/error response handling +- **Multipart Form Data**: File upload support with Stream-based API +- **Binary Responses**: Handle file downloads with byte[] or Stream responses +- **Streaming Support**: IAsyncEnumerable streaming for large datasets +- **HTTP Completion Options**: Control response buffering for streaming scenarios ## Getting Started @@ -58,9 +68,23 @@ dotnet add package Atc.Rest.Client ### Service Registration -There are two ways to register an HTTP client with dependency injection: +There are multiple ways to register services with dependency injection: -#### Approach 1: Direct Configuration (Recommended for Simple Cases) +#### Approach 1: Simple Registration (No HttpClient Configuration) + +Use this approach when you configure HttpClient separately or use source-generated endpoints: + +```csharp +using Atc.Rest.Client.Options; + +// Registers IHttpMessageFactory and IContractSerializer only +services.AddAtcRestClient(); + +// Or with a custom serializer +services.AddAtcRestClient(myCustomSerializer); +``` + +#### Approach 2: Direct Configuration Use this approach when you have straightforward configuration needs: @@ -73,7 +97,7 @@ services.AddAtcRestClient( timeout: TimeSpan.FromSeconds(30)); ``` -#### Approach 2: Custom Options Type +#### Approach 3: Custom Options Type Use this approach when you need to register the options as a singleton for later retrieval: @@ -204,6 +228,101 @@ using var request = requestBuilder.Build(HttpMethod.Get); // Results in: GET /api/users/123/posts?pageSize=10&page=1&orderBy=createdDate ``` +### File Upload (Multipart Form Data) + +Upload files using the Stream-based multipart form data API: + +```csharp +// Single file upload +await using var fileStream = File.OpenRead("document.pdf"); + +var requestBuilder = messageFactory.FromTemplate("/api/files/upload"); +requestBuilder.WithFile(fileStream, "file", "document.pdf", "application/pdf"); +requestBuilder.WithFormField("description", "My document"); + +using var request = requestBuilder.Build(HttpMethod.Post); +using var response = await client.SendAsync(request, cancellationToken); +``` + +Upload multiple files: + +```csharp +await using var file1 = File.OpenRead("image1.png"); +await using var file2 = File.OpenRead("image2.png"); + +var files = new List<(Stream, string, string, string?)> +{ + (file1, "images", "image1.png", "image/png"), + (file2, "images", "image2.png", "image/png") +}; + +var requestBuilder = messageFactory.FromTemplate("/api/files/upload-multiple"); +requestBuilder.WithFiles(files); + +using var request = requestBuilder.Build(HttpMethod.Post); +``` + +### File Download (Binary Response) + +Download files as byte arrays or streams: + +```csharp +var requestBuilder = messageFactory.FromTemplate("/api/files/{fileId}"); +requestBuilder.WithPathParameter("fileId", "123"); + +using var request = requestBuilder.Build(HttpMethod.Get); +using var response = await client.SendAsync(request, cancellationToken); + +var responseBuilder = messageFactory.FromResponse(response); + +// Option 1: Get as byte array +var binaryResponse = await responseBuilder.BuildBinaryResponseAsync(cancellationToken); +if (binaryResponse.IsSuccess) +{ + var content = binaryResponse.Content; + var fileName = binaryResponse.FileName; + var contentType = binaryResponse.ContentType; + // Save or process the file... +} + +// Option 2: Get as stream (for large files) +var streamResponse = await responseBuilder.BuildStreamBinaryResponseAsync(cancellationToken); +if (streamResponse.IsSuccess) +{ + await using var contentStream = streamResponse.ContentStream; + await using var fileStream = File.Create(streamResponse.FileName ?? "download.bin"); + await contentStream!.CopyToAsync(fileStream, cancellationToken); +} +``` + +### Streaming Responses (IAsyncEnumerable) + +Stream large datasets efficiently using IAsyncEnumerable: + +```csharp +var requestBuilder = messageFactory.FromTemplate("/api/data/stream"); + +// Set HttpCompletionOption for streaming (don't buffer the entire response) +requestBuilder.WithHttpCompletionOption(HttpCompletionOption.ResponseHeadersRead); + +using var request = requestBuilder.Build(HttpMethod.Get); +using var response = await client.SendAsync( + request, + requestBuilder.HttpCompletionOption, // Use the configured option + cancellationToken); + +var responseBuilder = messageFactory.FromResponse(response); + +// Stream items as they arrive +await foreach (var item in responseBuilder.BuildStreamingResponseAsync(cancellationToken)) +{ + if (item is not null) + { + Console.WriteLine($"Received: {item.Name}"); + } +} +``` + ### Handling Responses #### Success and Error Response Handling @@ -275,7 +394,20 @@ services.AddAtcRestClient("Payments-API", new Uri("https://payments.api.com"), T #### `AddAtcRestClient` Extension Methods ```csharp -// Non-generic overload for simple scenarios +// Simple registration (no HttpClient configuration) +IServiceCollection AddAtcRestClient(this IServiceCollection services) + +// With custom serializer +IServiceCollection AddAtcRestClient( + this IServiceCollection services, + IContractSerializer contractSerializer) + +// With configuration action +IServiceCollection AddAtcRestClient( + this IServiceCollection services, + Action configure) + +// With HttpClient configuration IServiceCollection AddAtcRestClient( string clientName, Uri baseAddress, @@ -322,6 +454,15 @@ public interface IMessageRequestBuilder IMessageRequestBuilder WithHeaderParameter(string name, object? value); IMessageRequestBuilder WithBody(TBody body); HttpRequestMessage Build(HttpMethod method); + + // HTTP completion option for streaming + IMessageRequestBuilder WithHttpCompletionOption(HttpCompletionOption completionOption); + HttpCompletionOption HttpCompletionOption { get; } + + // Multipart form data support + IMessageRequestBuilder WithFile(Stream stream, string name, string fileName, string? contentType = null); + IMessageRequestBuilder WithFiles(IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files); + IMessageRequestBuilder WithFormField(string name, string value); } ``` @@ -347,6 +488,13 @@ public interface IMessageResponseBuilder CancellationToken cancellationToken) where TSuccessContent : class where TErrorContent : class; + + // Binary response support + Task BuildBinaryResponseAsync(CancellationToken cancellationToken); + Task BuildStreamBinaryResponseAsync(CancellationToken cancellationToken); + + // Streaming support + IAsyncEnumerable BuildStreamingResponseAsync(CancellationToken cancellationToken = default); } ``` @@ -367,6 +515,38 @@ public class EndpointResponse : IEndpointResponse // - EndpointResponse ``` +#### `BinaryEndpointResponse` + +```csharp +public class BinaryEndpointResponse +{ + public bool IsSuccess { get; } + public bool IsOk { get; } // True if StatusCode == 200 + public HttpStatusCode StatusCode { get; } + public byte[]? Content { get; } + public string? ContentType { get; } + public string? FileName { get; } + public long? ContentLength { get; } +} +``` + +#### `StreamBinaryEndpointResponse` + +```csharp +public class StreamBinaryEndpointResponse : IDisposable +{ + public bool IsSuccess { get; } + public bool IsOk { get; } // True if StatusCode == 200 + public HttpStatusCode StatusCode { get; } + public Stream? ContentStream { get; } + public string? ContentType { get; } + public string? FileName { get; } + public long? ContentLength { get; } + + public void Dispose(); +} +``` + ## How to Contribute [Contribution Guidelines](https://atc-net.github.io/introduction/about-atc#how-to-contribute) diff --git a/global.json b/global.json index 2a75c79..60fdc5d 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "rollForward": "latestMajor", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj b/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj index 20a8ec3..f10674a 100644 --- a/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj +++ b/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj @@ -1,24 +1,14 @@ - net9.0 + net10.0 false true + Exe - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 3190960..1fe1ff8 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -8,20 +8,30 @@ annotations + $(NoWarn);xUnit1051; + + + + true + true + true - - + + + + + - + - \ No newline at end of file + From 7896ac6c926b6df0a9520749abaa5f18b45d4816 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Thu, 4 Dec 2025 11:32:44 +0100 Subject: [PATCH 7/9] refacture: make ServiceCollectionExtensions method more clear --- README.md | 33 +++---- .../Options/ServiceCollectionExtensions.cs | 89 ++++++------------- .../ServiceCollectionExtensionsTests.cs | 67 ++------------ 3 files changed, 51 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index f696dce..0016eda 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A lightweight and flexible REST client library for .NET, providing a clean abstr - [Getting Started](#getting-started) - [Installation](#installation) - [Service Registration](#service-registration) - - [Approach 1: Simple Registration (No HttpClient Configuration)](#approach-1-simple-registration-no-httpclient-configuration) + - [Approach 1: Core Services Only (No HttpClient Configuration)](#approach-1-core-services-only-no-httpclient-configuration) - [Approach 2: Direct Configuration](#approach-2-direct-configuration) - [Approach 3: Custom Options Type](#approach-3-custom-options-type) - [Creating an Endpoint](#creating-an-endpoint) @@ -70,18 +70,18 @@ dotnet add package Atc.Rest.Client There are multiple ways to register services with dependency injection: -#### Approach 1: Simple Registration (No HttpClient Configuration) +#### Approach 1: Core Services Only (No HttpClient Configuration) Use this approach when you configure HttpClient separately or use source-generated endpoints: ```csharp using Atc.Rest.Client.Options; -// Registers IHttpMessageFactory and IContractSerializer only -services.AddAtcRestClient(); +// Registers IHttpMessageFactory and IContractSerializer (default JSON) only +services.AddAtcRestClientCore(); // Or with a custom serializer -services.AddAtcRestClient(myCustomSerializer); +services.AddAtcRestClientCore(myCustomSerializer); ``` #### Approach 2: Direct Configuration @@ -391,24 +391,24 @@ services.AddAtcRestClient("Payments-API", new Uri("https://payments.api.com"), T ### Core Types -#### `AddAtcRestClient` Extension Methods +#### `AddAtcRestClientCore` Extension Method -```csharp -// Simple registration (no HttpClient configuration) -IServiceCollection AddAtcRestClient(this IServiceCollection services) +Registers core services (`IHttpMessageFactory` and `IContractSerializer`) without HttpClient configuration: -// With custom serializer -IServiceCollection AddAtcRestClient( +```csharp +IServiceCollection AddAtcRestClientCore( this IServiceCollection services, - IContractSerializer contractSerializer) + IContractSerializer? contractSerializer = null) +``` -// With configuration action -IServiceCollection AddAtcRestClient( - this IServiceCollection services, - Action configure) +#### `AddAtcRestClient` Extension Methods (Internal) + +These methods are used by source-generated code and are hidden from IntelliSense: +```csharp // With HttpClient configuration IServiceCollection AddAtcRestClient( + this IServiceCollection services, string clientName, Uri baseAddress, TimeSpan timeout, @@ -417,6 +417,7 @@ IServiceCollection AddAtcRestClient( // Generic overload for typed options IServiceCollection AddAtcRestClient( + this IServiceCollection services, string clientName, TOptions options, Action? httpClientBuilder = null, diff --git a/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs b/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs index 80d46fa..f1a67b1 100644 --- a/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs +++ b/src/Atc.Rest.Client/Options/ServiceCollectionExtensions.cs @@ -7,60 +7,35 @@ public static class ServiceCollectionExtensions /// without HttpClient configuration. /// /// The service collection. + /// Optional custom contract serializer. If null, uses DefaultJsonContractSerializer. /// The service collection for chaining. - public static IServiceCollection AddAtcRestClient( - this IServiceCollection services) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - return services; - } - - /// - /// Registers the core Atc.Rest.Client services with a custom contract serializer. - /// - /// The service collection. - /// The custom contract serializer to use. - /// The service collection for chaining. - /// Thrown when is null. - public static IServiceCollection AddAtcRestClient( + public static IServiceCollection AddAtcRestClientCore( this IServiceCollection services, - IContractSerializer contractSerializer) + IContractSerializer? contractSerializer = null) { if (contractSerializer is null) { - throw new ArgumentNullException(nameof(contractSerializer)); + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(contractSerializer); } - services.TryAddSingleton(contractSerializer); services.TryAddSingleton(); return services; } /// - /// Registers the core Atc.Rest.Client services with configuration options. + /// Registers a named HttpClient with the specified options and core Atc.Rest.Client services. /// + /// The type of options, must inherit from . /// The service collection. - /// The configuration action for AtcRestClientOptions. + /// The name of the HttpClient to register. + /// The options containing BaseAddress and Timeout configuration. + /// Optional action to further configure the HttpClient. + /// Optional custom contract serializer. If null, uses DefaultJsonContractSerializer. /// The service collection for chaining. - /// Thrown when is null. - public static IServiceCollection AddAtcRestClient( - this IServiceCollection services, - Action configure) - { - if (configure is null) - { - throw new ArgumentNullException(nameof(configure)); - } - - var options = new AtcRestClientOptions(); - configure(options); - - services.TryAddSingleton(); - services.TryAddSingleton(); - return services; - } - [EditorBrowsable(EditorBrowsableState.Never)] public static IServiceCollection AddAtcRestClient( this IServiceCollection services, @@ -78,20 +53,19 @@ public static IServiceCollection AddAtcRestClient( httpClientBuilder?.Invoke(clientBuilder); - // Register utilities - services.TryAddSingleton(); - if (contractSerializer is null) - { - services.TryAddSingleton(); - } - else - { - services.TryAddSingleton(contractSerializer); - } - - return services; + return services.AddAtcRestClientCore(contractSerializer); } + /// + /// Registers a named HttpClient with the specified base address, timeout, and core Atc.Rest.Client services. + /// + /// The service collection. + /// The name of the HttpClient to register. + /// The base address for the HttpClient. + /// The timeout for the HttpClient. + /// Optional action to further configure the HttpClient. + /// Optional custom contract serializer. If null, uses DefaultJsonContractSerializer. + /// The service collection for chaining. [EditorBrowsable(EditorBrowsableState.Never)] public static IServiceCollection AddAtcRestClient( this IServiceCollection services, @@ -109,17 +83,6 @@ public static IServiceCollection AddAtcRestClient( httpClientBuilder?.Invoke(clientBuilder); - // Register utilities - services.TryAddSingleton(); - if (contractSerializer is null) - { - services.TryAddSingleton(); - } - else - { - services.TryAddSingleton(contractSerializer); - } - - return services; + return services.AddAtcRestClientCore(contractSerializer); } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs index b4fe043..fce94d7 100644 --- a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs +++ b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs @@ -6,13 +6,13 @@ namespace Atc.Rest.Client.Tests.Options; public sealed class ServiceCollectionExtensionsTests { [Fact] - public void AddAtcRestClient_RegistersHttpMessageFactory() + public void AddAtcRestClientCore_RegistersHttpMessageFactory() { // Arrange var services = new ServiceCollection(); // Act - services.AddAtcRestClient(); + services.AddAtcRestClientCore(); var provider = services.BuildServiceProvider(); var factory = provider.GetService(); @@ -21,13 +21,13 @@ public void AddAtcRestClient_RegistersHttpMessageFactory() } [Fact] - public void AddAtcRestClient_RegistersDefaultContractSerializer() + public void AddAtcRestClientCore_RegistersDefaultContractSerializer() { // Arrange var services = new ServiceCollection(); // Act - services.AddAtcRestClient(); + services.AddAtcRestClientCore(); var provider = services.BuildServiceProvider(); var serializer = provider.GetService(); @@ -37,14 +37,14 @@ public void AddAtcRestClient_RegistersDefaultContractSerializer() } [Fact] - public void AddAtcRestClient_WithCustomSerializer_UsesProvidedSerializer() + public void AddAtcRestClientCore_WithCustomSerializer_UsesProvidedSerializer() { // Arrange var services = new ServiceCollection(); var customSerializer = Substitute.For(); // Act - services.AddAtcRestClient(customSerializer); + services.AddAtcRestClientCore(customSerializer); var provider = services.BuildServiceProvider(); var resolvedSerializer = provider.GetService(); @@ -53,7 +53,7 @@ public void AddAtcRestClient_WithCustomSerializer_UsesProvidedSerializer() } [Fact] - public void AddAtcRestClient_DoesNotOverwriteExistingRegistrations() + public void AddAtcRestClientCore_DoesNotOverwriteExistingRegistrations() { // Arrange var services = new ServiceCollection(); @@ -61,62 +61,11 @@ public void AddAtcRestClient_DoesNotOverwriteExistingRegistrations() services.AddSingleton(existingSerializer); // Act - services.AddAtcRestClient(); + services.AddAtcRestClientCore(); var provider = services.BuildServiceProvider(); var resolvedSerializer = provider.GetService(); // Assert resolvedSerializer.Should().BeSameAs(existingSerializer); } - - [Fact] - public void AddAtcRestClient_WithNullSerializer_ThrowsArgumentNullException() - { - // Arrange - var services = new ServiceCollection(); - - // Act - var act = () => services.AddAtcRestClient((IContractSerializer)null!); - - // Assert - act.Should().Throw() - .WithParameterName("contractSerializer"); - } - - [Fact] - public void AddAtcRestClient_WithConfigure_RegistersServices() - { - // Arrange - var services = new ServiceCollection(); - var configureWasCalled = false; - - // Act - services.AddAtcRestClient(options => - { - configureWasCalled = true; - options.Timeout = TimeSpan.FromSeconds(60); - }); - var provider = services.BuildServiceProvider(); - var factory = provider.GetService(); - var serializer = provider.GetService(); - - // Assert - configureWasCalled.Should().BeTrue(); - factory.Should().NotBeNull(); - serializer.Should().NotBeNull(); - } - - [Fact] - public void AddAtcRestClient_WithNullConfigure_ThrowsArgumentNullException() - { - // Arrange - var services = new ServiceCollection(); - - // Act - var act = () => services.AddAtcRestClient((Action)null!); - - // Assert - act.Should().Throw() - .WithParameterName("configure"); - } } \ No newline at end of file From 93cf3e6b8dd4186a3767ad01843999b3114c384c Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Thu, 4 Dec 2025 14:02:54 +0100 Subject: [PATCH 8/9] chore: cleanup in usings --- test/Atc.Rest.Client.Tests/GlobalUsings.cs | 4 +++- .../Options/ServiceCollectionExtensionsTests.cs | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/Atc.Rest.Client.Tests/GlobalUsings.cs b/test/Atc.Rest.Client.Tests/GlobalUsings.cs index 29d079f..02d3028 100644 --- a/test/Atc.Rest.Client.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.Client.Tests/GlobalUsings.cs @@ -3,4 +3,6 @@ global using System.Runtime.Serialization; global using System.Text.Json; global using Atc.Rest.Client.Builder; -global using Atc.Rest.Client.Serialization; \ No newline at end of file +global using Atc.Rest.Client.Options; +global using Atc.Rest.Client.Serialization; +global using Microsoft.Extensions.DependencyInjection; \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs index fce94d7..bcc8d0e 100644 --- a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs +++ b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs @@ -1,8 +1,5 @@ namespace Atc.Rest.Client.Tests.Options; -using Atc.Rest.Client.Options; -using Microsoft.Extensions.DependencyInjection; - public sealed class ServiceCollectionExtensionsTests { [Fact] From 5b993d262975590040e1b35cf048a86ff554c259 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Thu, 4 Dec 2025 14:04:06 +0100 Subject: [PATCH 9/9] feat: add IBinaryEndpointResponse and IStreamBinaryEndpointResponse interfaces --- src/Atc.Rest.Client/BinaryEndpointResponse.cs | 2 +- src/Atc.Rest.Client/EndpointResponse.cs | 1 + .../IBinaryEndpointResponse.cs | 43 +++++++++++++++++++ .../IStreamBinaryEndpointResponse.cs | 42 ++++++++++++++++++ .../StreamBinaryEndpointResponse.cs | 4 +- 5 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/Atc.Rest.Client/IBinaryEndpointResponse.cs create mode 100644 src/Atc.Rest.Client/IStreamBinaryEndpointResponse.cs diff --git a/src/Atc.Rest.Client/BinaryEndpointResponse.cs b/src/Atc.Rest.Client/BinaryEndpointResponse.cs index 4149b4d..73a3183 100644 --- a/src/Atc.Rest.Client/BinaryEndpointResponse.cs +++ b/src/Atc.Rest.Client/BinaryEndpointResponse.cs @@ -4,7 +4,7 @@ namespace Atc.Rest.Client; /// Represents a binary file response from an endpoint. /// [SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Binary content requires array for practical usage.")] -public class BinaryEndpointResponse +public sealed class BinaryEndpointResponse : IBinaryEndpointResponse { /// /// Initializes a new instance of the class. diff --git a/src/Atc.Rest.Client/EndpointResponse.cs b/src/Atc.Rest.Client/EndpointResponse.cs index 1446105..82f224f 100644 --- a/src/Atc.Rest.Client/EndpointResponse.cs +++ b/src/Atc.Rest.Client/EndpointResponse.cs @@ -1,3 +1,4 @@ +// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract namespace Atc.Rest.Client; public class EndpointResponse : IEndpointResponse diff --git a/src/Atc.Rest.Client/IBinaryEndpointResponse.cs b/src/Atc.Rest.Client/IBinaryEndpointResponse.cs new file mode 100644 index 0000000..707cbec --- /dev/null +++ b/src/Atc.Rest.Client/IBinaryEndpointResponse.cs @@ -0,0 +1,43 @@ +namespace Atc.Rest.Client; + +/// +/// Represents a binary file response from an endpoint. +/// +[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Binary content requires array for practical usage.")] +public interface IBinaryEndpointResponse +{ + /// + /// Gets a value indicating whether the request was successful. + /// + bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the status code indicates OK (200). + /// + bool IsOk { get; } + + /// + /// Gets the HTTP status code. + /// + HttpStatusCode StatusCode { get; } + + /// + /// Gets the binary content. + /// + byte[]? Content { get; } + + /// + /// Gets the content type. + /// + string? ContentType { get; } + + /// + /// Gets the file name from the Content-Disposition header. + /// + string? FileName { get; } + + /// + /// Gets the content length. + /// + long? ContentLength { get; } +} \ No newline at end of file diff --git a/src/Atc.Rest.Client/IStreamBinaryEndpointResponse.cs b/src/Atc.Rest.Client/IStreamBinaryEndpointResponse.cs new file mode 100644 index 0000000..4821eb8 --- /dev/null +++ b/src/Atc.Rest.Client/IStreamBinaryEndpointResponse.cs @@ -0,0 +1,42 @@ +namespace Atc.Rest.Client; + +/// +/// Represents a streaming binary response from an endpoint. +/// +public interface IStreamBinaryEndpointResponse : IDisposable +{ + /// + /// Gets a value indicating whether the request was successful. + /// + bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the status code indicates OK (200). + /// + bool IsOk { get; } + + /// + /// Gets the HTTP status code. + /// + HttpStatusCode StatusCode { get; } + + /// + /// Gets the content stream. + /// + Stream? ContentStream { get; } + + /// + /// Gets the content type. + /// + string? ContentType { get; } + + /// + /// Gets the file name from the Content-Disposition header. + /// + string? FileName { get; } + + /// + /// Gets the content length. + /// + long? ContentLength { get; } +} \ No newline at end of file diff --git a/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs b/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs index ce93973..d969181 100644 --- a/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs +++ b/src/Atc.Rest.Client/StreamBinaryEndpointResponse.cs @@ -3,7 +3,7 @@ namespace Atc.Rest.Client; /// /// Represents a streaming binary response from an endpoint. /// -public class StreamBinaryEndpointResponse : IDisposable +public sealed class StreamBinaryEndpointResponse : IStreamBinaryEndpointResponse { private bool disposed; @@ -80,7 +80,7 @@ public void Dispose() /// Disposes managed resources. /// /// Whether to dispose managed resources. - protected virtual void Dispose(bool disposing) + private void Dispose(bool disposing) { if (disposed) {