From f538ca6201335c1c791e07a9f4704bcf7bcf0c50 Mon Sep 17 00:00:00 2001 From: David Eriksson Date: Thu, 26 Feb 2026 16:03:00 +0100 Subject: [PATCH 1/2] Add back typed Part --- .../MultipartFormDataContentTest.cs | 50 ++++++++++++++++++- Activout.RestClient/Part.cs | 17 ++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Activout.RestClient.Test/MultipartFormDataContentTest.cs b/Activout.RestClient.Test/MultipartFormDataContentTest.cs index d381093..305866c 100644 --- a/Activout.RestClient.Test/MultipartFormDataContentTest.cs +++ b/Activout.RestClient.Test/MultipartFormDataContentTest.cs @@ -111,6 +111,49 @@ await client.SendFormInForm(new FormModel Assert.Equal("bar.txt", attachment2.Headers.ContentDisposition?.FileName); } + [Fact] + public async Task TestSendFormInFormWithTypedPart() + { + // Arrange + var client = CreateClient(); + var collector = new HttpRequestMessageCollector(); + + _mockHttp + .Expect(HttpMethod.Post, BaseUri + "multiparttyped") + .With(message => + { + collector.Message = message; + return message.Content?.Headers.ContentType?.MediaType == "multipart/form-data"; + }) + .Respond(HttpStatusCode.OK); + + // Act + await client.SendTypedParts(new[] + { + new Part(Content: "foo", FileName: "foo.txt"), + new Part(Content: "bar", FileName: "bar.txt") + }); + + // Assert + _mockHttp.VerifyNoOutstandingExpectation(); + + var multipartFormDataContent = collector.Message?.Content as MultipartFormDataContent; + Assert.NotNull(multipartFormDataContent); + + var content = multipartFormDataContent.ToArray(); + Assert.Equal(2, content.Length); + + var attachment1 = content[0]; + Assert.Equal("foo", await attachment1.ReadAsStringAsync()); + Assert.Equal("attachment", attachment1.Headers.ContentDisposition?.Name); + Assert.Equal("foo.txt", attachment1.Headers.ContentDisposition?.FileName); + + var attachment2 = content[1]; + Assert.Equal("bar", await attachment2.ReadAsStringAsync()); + Assert.Equal("attachment", attachment2.Headers.ContentDisposition?.Name); + Assert.Equal("bar.txt", attachment2.Headers.ContentDisposition?.FileName); + } + [Fact] public async Task TestSendMultipartFormDataContent() { @@ -186,6 +229,12 @@ Task SendFormInForm( [PartParam("attachment", contentType: "application/octet-stream")] Part[] parts); + [Path("typed")] + [Post] + Task SendTypedParts( + [PartParam("attachment", contentType: "application/octet-stream")] + Part[] parts); + [Post] Task SendParts( @@ -214,4 +263,3 @@ private IMultipartFormDataContentClient CreateClient() } } } - diff --git a/Activout.RestClient/Part.cs b/Activout.RestClient/Part.cs index fffafff..33e4688 100644 --- a/Activout.RestClient/Part.cs +++ b/Activout.RestClient/Part.cs @@ -1,3 +1,18 @@ namespace Activout.RestClient; -public record Part(object Content, string? Name = null, string? FileName = null); +public record Part( + object? Content, + string? Name = null, + string? FileName = null); + +public record Part( + T Content, + string? Name = null, + string? FileName = null) : Part(Content, Name, FileName) +{ + public new T Content + { + get => (T)base.Content!; + init => base.Content = value; + } +} From f1c699f65d4eb690721f9a58c1eb9d1e42d9c051 Mon Sep 17 00:00:00 2001 From: David Eriksson Date: Thu, 23 Jul 2026 11:53:00 +0200 Subject: [PATCH 2/2] Add back Part --- ...out.RestClient.Newtonsoft.Json.Test.csproj | 1 + .../DomainExceptions/MyApiError.cs | 1 + ...pClientFactoryWithDelegatingHandlerTest.cs | 295 ++++++++++++++++++ .../MockHttpMessageHandlerBuilder.cs | 33 ++ 4 files changed, 330 insertions(+) create mode 100644 Activout.RestClient.Newtonsoft.Json.Test/HttpClientFactoryWithDelegatingHandlerTest.cs create mode 100644 Activout.RestClient.Newtonsoft.Json.Test/MockHttpMessageHandlerBuilder.cs diff --git a/Activout.RestClient.Newtonsoft.Json.Test/Activout.RestClient.Newtonsoft.Json.Test.csproj b/Activout.RestClient.Newtonsoft.Json.Test/Activout.RestClient.Newtonsoft.Json.Test.csproj index a0f5987..411b404 100644 --- a/Activout.RestClient.Newtonsoft.Json.Test/Activout.RestClient.Newtonsoft.Json.Test.csproj +++ b/Activout.RestClient.Newtonsoft.Json.Test/Activout.RestClient.Newtonsoft.Json.Test.csproj @@ -12,6 +12,7 @@ + diff --git a/Activout.RestClient.Newtonsoft.Json.Test/DomainExceptions/MyApiError.cs b/Activout.RestClient.Newtonsoft.Json.Test/DomainExceptions/MyApiError.cs index 1bd2570..51c0186 100644 --- a/Activout.RestClient.Newtonsoft.Json.Test/DomainExceptions/MyApiError.cs +++ b/Activout.RestClient.Newtonsoft.Json.Test/DomainExceptions/MyApiError.cs @@ -2,6 +2,7 @@ namespace Activout.RestClient.Newtonsoft.Json.Test.DomainExceptions { public enum MyApiError { + Unexpected = 0, Foo = 4, Bar = 5 } diff --git a/Activout.RestClient.Newtonsoft.Json.Test/HttpClientFactoryWithDelegatingHandlerTest.cs b/Activout.RestClient.Newtonsoft.Json.Test/HttpClientFactoryWithDelegatingHandlerTest.cs new file mode 100644 index 0000000..4306f63 --- /dev/null +++ b/Activout.RestClient.Newtonsoft.Json.Test/HttpClientFactoryWithDelegatingHandlerTest.cs @@ -0,0 +1,295 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Activout.RestClient.Helpers; +using Activout.RestClient.Helpers.Implementation; +using Activout.RestClient.Newtonsoft.Json.Test.MovieReviews; +using Activout.RestClient.ParamConverter; +using Activout.RestClient.ParamConverter.Implementation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using RichardSzalay.MockHttp; +using Xunit; +using Xunit.Abstractions; + +namespace Activout.RestClient.Newtonsoft.Json.Test; + +public class HttpClientFactoryWithDelegatingHandlerTest +{ + private const string BaseUri = "https://example.com/movieReviewService"; + private const string MovieId = "test-movie-123"; + + private readonly ITestOutputHelper _outputHelper; + private readonly MockHttpMessageHandler _mockHttp = new(); + + public HttpClientFactoryWithDelegatingHandlerTest(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + } + + private class LoggingDelegatingHandler : DelegatingHandler + { + private readonly ITestOutputHelper _outputHelper; + + public LoggingDelegatingHandler(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _outputHelper.WriteLine($"[Request] {request.Method} {request.RequestUri}"); + + if (request.Content != null) + { + // Buffer the content so we can read it multiple times + await request.Content.LoadIntoBufferAsync(); + var requestContent = await request.Content.ReadAsStringAsync(cancellationToken); + _outputHelper.WriteLine($"[Request Content] {requestContent}"); + } + + var response = await base.SendAsync(request, cancellationToken); + + _outputHelper.WriteLine($"[Response] {response.StatusCode}"); + + await response.Content.LoadIntoBufferAsync(); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _outputHelper.WriteLine($"[Response Content] {responseContent}"); + + return response; + } + } + + private class MovieReviewServiceFactory + { + private readonly HttpClient _httpClient; + private readonly IRestClientFactory _restClientFactory; + private readonly ILogger _logger; + + public MovieReviewServiceFactory( + HttpClient httpClient, + IRestClientFactory restClientFactory, + ILogger logger) + { + _httpClient = httpClient; + _restClientFactory = restClientFactory; + _logger = logger; + } + + public IMovieReviewService CreateMovieReviewService() + { + return _restClientFactory.CreateBuilder() + .WithNewtonsoftJson() + .With(_logger) + .With(_httpClient) + .BaseUri(BaseUri) + .Build(); + } + } + + private static IServiceCollection AddRestClient(IServiceCollection services) + { + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + private IServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddTransient(_ => new MockHttpMessageHandlerBuilder(_mockHttp)); + + AddRestClient(services); + + services.AddLogging(builder => + { + builder + .AddFilter("Microsoft", LogLevel.Warning) + .AddFilter("System", LogLevel.Warning) + .AddFilter("Activout.RestClient", LogLevel.Debug) + .AddXUnit(_outputHelper); + }); + + services.AddHttpClient() + .AddHttpMessageHandler(() => new LoggingDelegatingHandler(_outputHelper)); + + return services.BuildServiceProvider(); + } + + private IMovieReviewService CreateMovieReviewService() + { + var serviceProvider = CreateServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + return factory.CreateMovieReviewService(); + } + + private IMovieReviewService CreateMovieReviewServiceWithConfiguredPrimaryHandler() + { + var services = new ServiceCollection(); + + AddRestClient(services); + + services.AddLogging(builder => + { + builder + .AddFilter("Microsoft", LogLevel.Warning) + .AddFilter("System", LogLevel.Warning) + .AddFilter("Activout.RestClient", LogLevel.Debug) + .AddXUnit(_outputHelper); + }); + + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => _mockHttp) + .AddHttpMessageHandler(() => new LoggingDelegatingHandler(_outputHelper)); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + return factory.CreateMovieReviewService(); + } + + [Fact] + public async Task TestGetWithDelegatingHandler_ShouldDeserializeSuccessfully() + { + // Arrange + var movies = new[] + { + new Movie { Title = "Test Movie 1" }, + new Movie { Title = "Test Movie 2" } + }; + + _mockHttp + .Expect(HttpMethod.Get, $"{BaseUri}/movies") + .Respond("application/json", JsonConvert.SerializeObject(movies)); + + var reviewSvc = CreateMovieReviewServiceWithConfiguredPrimaryHandler(); + + // Act + var result = await reviewSvc.GetAllMovies(); + + // Assert + _mockHttp.VerifyNoOutstandingExpectation(); + Assert.NotNull(result); + Assert.Equal(2, result.Count()); + } + + [Fact] + public async Task TestGetReviewsWithDelegatingHandler_ShouldDeserializeSuccessfully() + { + // Arrange + var reviews = new[] + { + new Review(5, "Great movie!") { MovieId = MovieId, ReviewId = "rev1" }, + new Review(4, "Good movie") { MovieId = MovieId, ReviewId = "rev2" } + }; + + _mockHttp + .Expect(HttpMethod.Get, $"{BaseUri}/movies/{MovieId}/reviews") + .Respond("application/json", JsonConvert.SerializeObject(reviews)); + + var reviewSvc = CreateMovieReviewService(); + + var result = await reviewSvc.GetAllReviews(MovieId); + + // Assert + _mockHttp.VerifyNoOutstandingExpectation(); + Assert.NotNull(result); + Assert.Equal(2, result.Count()); + } + + [Fact] + public async Task TestUnsafeWhenWithReusedHttpContent_SecondCallThrowsObjectDisposedException() + { + // Arrange + var movies = new[] + { + new Movie { Title = "Test Movie 1" }, + new Movie { Title = "Test Movie 2" } + }; + var sharedContent = new StringContent( + JsonConvert.SerializeObject(movies), + Encoding.UTF8, + "application/json"); + + // Intentionally unsafe: same HttpContent instance is returned for every request. + _mockHttp + .When(HttpMethod.Get, $"{BaseUri}/movies") + .Respond(_ => sharedContent); + + var reviewSvc = CreateMovieReviewService(); + + // Act + var firstCallResult = await reviewSvc.GetAllMovies(); + + // Assert first call succeeds, second call reuses disposed content and fails. + Assert.NotNull(firstCallResult); + Assert.Equal(2, firstCallResult.Count()); + + await Assert.ThrowsAsync(() => reviewSvc.GetAllMovies()); + } + + [Fact] + public async Task TestPostWithDelegatingHandler_ShouldSubmitReview() + { + // Arrange + var review = new Review(5, "Amazing!") { MovieId = MovieId, ReviewId = "new-review" }; + + _mockHttp + .Expect(HttpMethod.Post, $"{BaseUri}/movies/{MovieId}/reviews") + .Respond("application/json", JsonConvert.SerializeObject(review)); + + var reviewSvc = CreateMovieReviewService(); + + // Act + var result = await reviewSvc.SubmitReview(MovieId, review); + + // Assert + _mockHttp.VerifyNoOutstandingExpectation(); + Assert.NotNull(result); + Assert.Equal(review.ReviewId, result.ReviewId); + } + + [Fact] + public async Task TestErrorResponseWithDelegatingHandler_ShouldDeserializeErrorResponse() + { + // Arrange + _mockHttp + .Expect(HttpMethod.Get, $"{BaseUri}/movies/{MovieId}/reviews") + .Respond(HttpStatusCode.NotFound, _ => new StringContent( + JsonConvert.SerializeObject(new + { + Errors = new object[] + { + new { Message = "Movie not found", Code = 404 } + } + }), + Encoding.UTF8, + "application/json")); + + var reviewSvc = CreateMovieReviewService(); + + // Act & Assert + // The error response also goes through the delegating handler + // and needs to be deserialized, so this is another path where + // ObjectDisposedException could occur + var exception = await Assert.ThrowsAsync(() => reviewSvc.GetAllReviews(MovieId)); + + _mockHttp.VerifyNoOutstandingExpectation(); + Assert.Equal(HttpStatusCode.NotFound, exception.StatusCode); + + var error = exception.GetErrorResponse(); + Assert.NotNull(error); + Assert.Single(error.Errors); + Assert.Equal(404, error.Errors[0].Code); + } +} \ No newline at end of file diff --git a/Activout.RestClient.Newtonsoft.Json.Test/MockHttpMessageHandlerBuilder.cs b/Activout.RestClient.Newtonsoft.Json.Test/MockHttpMessageHandlerBuilder.cs new file mode 100644 index 0000000..42b18aa --- /dev/null +++ b/Activout.RestClient.Newtonsoft.Json.Test/MockHttpMessageHandlerBuilder.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using Microsoft.Extensions.Http; +using RichardSzalay.MockHttp; + +namespace Activout.RestClient.Newtonsoft.Json.Test; + +internal sealed class MockHttpMessageHandlerBuilder : HttpMessageHandlerBuilder +{ + private readonly MockHttpMessageHandler _mockHttp; + + public MockHttpMessageHandlerBuilder(MockHttpMessageHandler mockHttp) + { + _mockHttp = mockHttp; + } + + [DisallowNull] + public override string? Name { get; set; } + + public override HttpMessageHandler PrimaryHandler + { + get => _mockHttp; + set { } + } + + public override IList AdditionalHandlers { get; } = new List(); + + public override HttpMessageHandler Build() + { + return CreateHandlerPipeline(PrimaryHandler, AdditionalHandlers); + } +} \ No newline at end of file