From 3cf19144d1f0f778aaf8a7a1ad2e12768604cf03 Mon Sep 17 00:00:00 2001 From: Adam Rodger Date: Sat, 5 Apr 2025 16:58:57 +0100 Subject: [PATCH] feat!: Make the messaging provider handle scenario requests async This removes a wrinkle in the API whereby scenario factory methods can be async internally, but because the messaging provider was sync we had to do a sync-over-async call to generate those scenarios. Instead, now the messaging provider runs in an async context, which means the API has changed such that async scenario factory methods are now the default internally. Any sync scenario factories are wrapped such that they become async with `Task.FromResult`. This creates a breaking API change because the verifier and the messaging provider were both previously sync disposable, whereas now they're async disposable so the the messaging prodiver can stop the async request handling context. --- .../Consumer.Tests/Consumer.Tests.csproj | 6 +-- .../Provider.Tests/Provider.Tests.csproj | 8 ++-- .../OrdersApi/Provider.Tests/ProviderTests.cs | 18 +++++--- .../PactNet.Abstractions.csproj | 1 + .../Verifier/Messaging/IMessageScenarios.cs | 8 ++++ .../Verifier/Messaging/IMessagingProvider.cs | 2 +- .../Verifier/Messaging/Scenario.cs | 44 ++++++++++++++++--- .../Messaging/MessageScenarioBuilder.cs | 41 +++++++++-------- .../Verifier/Messaging/MessageScenarios.cs | 13 ++++++ .../Verifier/Messaging/MessagingProvider.cs | 39 ++++++++++------ src/PactNet/Verifier/PactVerifier.cs | 20 ++++++--- .../PactNet.Abstractions.Tests.csproj | 6 +-- .../Verifier/Messaging/ScenarioTests.cs | 9 ++-- tests/PactNet.Tests/PactNet.Tests.csproj | 6 +-- .../Messaging/MessageScenarioBuilderTests.cs | 8 ++-- .../Messaging/MessageScenariosTests.cs | 2 +- .../Messaging/MessagingProviderTests.cs | 6 +-- 17 files changed, 164 insertions(+), 73 deletions(-) diff --git a/samples/OrdersApi/Consumer.Tests/Consumer.Tests.csproj b/samples/OrdersApi/Consumer.Tests/Consumer.Tests.csproj index a88109dd..042ed50e 100644 --- a/samples/OrdersApi/Consumer.Tests/Consumer.Tests.csproj +++ b/samples/OrdersApi/Consumer.Tests/Consumer.Tests.csproj @@ -5,10 +5,10 @@ - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/samples/OrdersApi/Provider.Tests/Provider.Tests.csproj b/samples/OrdersApi/Provider.Tests/Provider.Tests.csproj index 1517e0f4..375e2ec6 100644 --- a/samples/OrdersApi/Provider.Tests/Provider.Tests.csproj +++ b/samples/OrdersApi/Provider.Tests/Provider.Tests.csproj @@ -1,13 +1,13 @@ - + net8.0 false - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/samples/OrdersApi/Provider.Tests/ProviderTests.cs b/samples/OrdersApi/Provider.Tests/ProviderTests.cs index 1feb302e..2252f0e9 100644 --- a/samples/OrdersApi/Provider.Tests/ProviderTests.cs +++ b/samples/OrdersApi/Provider.Tests/ProviderTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using PactNet; @@ -14,7 +15,7 @@ namespace Provider.Tests { - public class ProviderTests : IDisposable + public class ProviderTests : IAsyncLifetime { private static readonly Uri ProviderUri = new("http://localhost:5000"); @@ -36,8 +37,6 @@ public ProviderTests(ITestOutputHelper output) webBuilder.UseStartup(); }) .Build(); - - this.server.Start(); this.verifier = new PactVerifier("Orders API", new PactVerifierConfig { @@ -49,10 +48,19 @@ public ProviderTests(ITestOutputHelper output) }); } - public void Dispose() + /// + /// Called immediately after the class has been created, before it is used. + /// + public Task InitializeAsync() + { + this.server.Start(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() { + await this.verifier.DisposeAsync(); this.server.Dispose(); - this.verifier.Dispose(); } [Fact] diff --git a/src/PactNet.Abstractions/PactNet.Abstractions.csproj b/src/PactNet.Abstractions/PactNet.Abstractions.csproj index c3a5209c..d64c7f4f 100644 --- a/src/PactNet.Abstractions/PactNet.Abstractions.csproj +++ b/src/PactNet.Abstractions/PactNet.Abstractions.csproj @@ -12,6 +12,7 @@ + diff --git a/src/PactNet.Abstractions/Verifier/Messaging/IMessageScenarios.cs b/src/PactNet.Abstractions/Verifier/Messaging/IMessageScenarios.cs index 58b728a6..4fc88676 100644 --- a/src/PactNet.Abstractions/Verifier/Messaging/IMessageScenarios.cs +++ b/src/PactNet.Abstractions/Verifier/Messaging/IMessageScenarios.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace PactNet.Verifier.Messaging { @@ -20,6 +21,13 @@ public interface IMessageScenarios /// Message content factory IMessageScenarios Add(string description, Func factory); + /// + /// Add a message scenario + /// + /// Scenario description + /// Message content factory + IMessageScenarios Add(string description, Func> factory); + /// /// Add a message scenario by configuring a scenario builder /// diff --git a/src/PactNet.Abstractions/Verifier/Messaging/IMessagingProvider.cs b/src/PactNet.Abstractions/Verifier/Messaging/IMessagingProvider.cs index 5aa61d00..8d9955b5 100644 --- a/src/PactNet.Abstractions/Verifier/Messaging/IMessagingProvider.cs +++ b/src/PactNet.Abstractions/Verifier/Messaging/IMessagingProvider.cs @@ -6,7 +6,7 @@ namespace PactNet.Verifier.Messaging /// /// Messaging provider service, which simulates messaging responses in order to verify interactions /// - public interface IMessagingProvider : IDisposable + public interface IMessagingProvider : IAsyncDisposable { /// /// Scenarios configured for the provider diff --git a/src/PactNet.Abstractions/Verifier/Messaging/Scenario.cs b/src/PactNet.Abstractions/Verifier/Messaging/Scenario.cs index 1fe7e041..dac8cf44 100644 --- a/src/PactNet.Abstractions/Verifier/Messaging/Scenario.cs +++ b/src/PactNet.Abstractions/Verifier/Messaging/Scenario.cs @@ -1,5 +1,6 @@ using System; using System.Text.Json; +using System.Threading.Tasks; namespace PactNet.Verifier.Messaging { @@ -8,7 +9,7 @@ namespace PactNet.Verifier.Messaging /// public class Scenario { - private readonly Func factory; + private readonly Func> factory; /// /// The description of the scenario @@ -30,7 +31,16 @@ public class Scenario /// /// the scenario description /// Message content factory - public Scenario(string description, Func factory) + public Scenario(string description, Func factory) : this(description, Wrap(factory)) + { + } + + /// + /// Creates an instance of + /// + /// the scenario description + /// Message content factory + public Scenario(string description, Func> factory) { this.Description = !string.IsNullOrWhiteSpace(description) ? description : throw new ArgumentException("Description cannot be null or empty"); this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); @@ -44,6 +54,20 @@ public Scenario(string description, Func factory) /// the metadata /// Custom JSON serializer settings public Scenario(string description, Func factory, dynamic metadata, JsonSerializerOptions settings) + : this(description, Wrap(factory)) + { + this.Metadata = metadata; + this.JsonSettings = settings; + } + + /// + /// Creates an instance of + /// + /// the scenario description + /// Message content factory + /// the metadata + /// Custom JSON serializer settings + public Scenario(string description, Func> factory, dynamic metadata, JsonSerializerOptions settings) : this(description, factory) { this.Metadata = metadata; @@ -51,12 +75,20 @@ public Scenario(string description, Func factory, dynamic metadata, Jso } /// - /// Invoke a scenario + /// Invoke a scenario to generate message content /// /// The scenario message content - public dynamic Invoke() + public async Task InvokeAsync() => await this.factory(); + + /// + /// Wraps a sync factory to be async + /// + /// Sync factory + /// Async factory + private static Func> Wrap(Func factory) => () => { - return this.factory.Invoke(); - } + dynamic d = factory(); + return Task.FromResult(d); + }; } } diff --git a/src/PactNet/Verifier/Messaging/MessageScenarioBuilder.cs b/src/PactNet/Verifier/Messaging/MessageScenarioBuilder.cs index 06b483f4..8db626bf 100644 --- a/src/PactNet/Verifier/Messaging/MessageScenarioBuilder.cs +++ b/src/PactNet/Verifier/Messaging/MessageScenarioBuilder.cs @@ -11,7 +11,7 @@ internal class MessageScenarioBuilder : IMessageScenarioBuilder { private readonly string description; - private Func factory; + private Func> factory; private dynamic metadata = new { ContentType = "application/json" }; private JsonSerializerOptions settings; @@ -36,23 +36,37 @@ public IMessageScenarioBuilder WithMetadata(dynamic metadata) } /// - /// Set the action of the scenario + /// Set the content factory of the scenario. The factory is invoked each time the scenario is required. /// /// Content factory public void WithContent(Func factory) { - this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + this.WithAsyncContent(() => Task.FromResult(factory())); } /// - /// Set the content of the scenario + /// Set the content factory of the scenario. The factory is invoked each time the scenario is required. /// /// Content factory /// Custom JSON serializer settings public void WithContent(Func factory, JsonSerializerOptions settings) { - this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); - this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + this.WithAsyncContent(() => Task.FromResult(factory()), settings); } /// @@ -61,12 +75,7 @@ public void WithContent(Func factory, JsonSerializerOptions settings) /// Content factory public void WithAsyncContent(Func> factory) { - if (factory == null) - { - throw new ArgumentNullException(nameof(factory)); - } - - this.WithContent(() => factory().GetAwaiter().GetResult()); + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); } /// @@ -76,12 +85,8 @@ public void WithAsyncContent(Func> factory) /// Custom JSON serializer settings public void WithAsyncContent(Func> factory, JsonSerializerOptions settings) { - if (factory == null) - { - throw new ArgumentNullException(nameof(factory)); - } - - this.WithContent(() => factory().GetAwaiter().GetResult(), settings); + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); } /// diff --git a/src/PactNet/Verifier/Messaging/MessageScenarios.cs b/src/PactNet/Verifier/Messaging/MessageScenarios.cs index 25b23af7..6c5df8c0 100644 --- a/src/PactNet/Verifier/Messaging/MessageScenarios.cs +++ b/src/PactNet/Verifier/Messaging/MessageScenarios.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Threading.Tasks; namespace PactNet.Verifier.Messaging { @@ -35,6 +36,18 @@ public MessageScenarios() /// Scenario description /// Message content factory public IMessageScenarios Add(string description, Func factory) + { + Func> asyncFactory = () => Task.FromResult(factory()); + + return this.Add(description, asyncFactory); + } + + /// + /// Add a message scenario + /// + /// Scenario description + /// Message content factory + public IMessageScenarios Add(string description, Func> factory) { var scenario = new Scenario(description, factory, JsonMetadata, null); this.scenarios.Add(description, scenario); diff --git a/src/PactNet/Verifier/Messaging/MessagingProvider.cs b/src/PactNet/Verifier/Messaging/MessagingProvider.cs index 5b70cef5..d112bf5d 100644 --- a/src/PactNet/Verifier/Messaging/MessagingProvider.cs +++ b/src/PactNet/Verifier/Messaging/MessagingProvider.cs @@ -7,6 +7,7 @@ using System.Text; using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using PactNet.Exceptions; using PactNet.Internal; @@ -27,9 +28,10 @@ internal class MessagingProvider : IMessagingProvider }; private readonly PactVerifierConfig config; - private readonly Thread thread; + private readonly CancellationTokenSource cts; private HttpListener server; + private Task serverTask; private JsonSerializerOptions defaultSettings; /// @@ -46,7 +48,7 @@ public MessagingProvider(PactVerifierConfig config, IMessageScenarios scenarios) { this.config = config; this.Scenarios = scenarios; - this.thread = new Thread(this.HandleRequest); + this.cts = new CancellationTokenSource(); } /// @@ -59,7 +61,7 @@ public Uri Start(JsonSerializerOptions settings) Guard.NotNull(settings, nameof(settings)); this.defaultSettings = settings; - while (true) + while (!this.cts.Token.IsCancellationRequested) { Uri uri; @@ -85,9 +87,11 @@ public Uri Start(JsonSerializerOptions settings) throw new PactFailureException("Unable to start the internal messaging server", e); } - this.thread.Start(); + this.serverTask = Task.Run(this.HandleRequest, this.cts.Token); return uri; } + + return null; } /// @@ -118,21 +122,21 @@ private static int FindUnusedPort() /// /// Handle an incoming request from the Pact Core messaging driver /// - private void HandleRequest() + private async Task HandleRequest() { this.config.WriteLine("Messaging provider successfully started"); - while (this.server.IsListening) + while (this.server.IsListening && !this.cts.Token.IsCancellationRequested) { HttpListenerContext context; try { - context = this.server.GetContext(); + context = await this.server.GetContextAsync().ConfigureAwait(false); } catch (HttpListenerException) { - // this thread blocks waiting for the next request, and if the server stops then this exception is raised + // this task blocks waiting for the next request, and if the server stops then this exception is raised break; } @@ -146,8 +150,10 @@ private void HandleRequest() try { + // buffer the body instead of async deserialisation so we can log it if anything goes wrong var reader = new StreamReader(context.Request.InputStream); - string body = reader.ReadToEnd(); + string body = await reader.ReadToEndAsync().ConfigureAwait(false); + interaction = JsonSerializer.Deserialize(body, InteractionSettings); if (string.IsNullOrWhiteSpace(interaction.Description)) @@ -162,8 +168,10 @@ private void HandleRequest() continue; } - this.HandleInteraction(context, interaction); + await this.HandleInteractionAsync(context, interaction).ConfigureAwait(false); } + + this.config.WriteLine("Messaging provider stopped"); } /// @@ -171,7 +179,7 @@ private void HandleRequest() /// /// HTTP context /// Interaction - private void HandleInteraction(HttpListenerContext context, MessageInteraction interaction) + private async Task HandleInteractionAsync(HttpListenerContext context, MessageInteraction interaction) { try { @@ -194,7 +202,7 @@ private void HandleInteraction(HttpListenerContext context, MessageInteraction i this.config.WriteLine($"Metadata: {stringifyMetadata}"); } - dynamic content = scenario.Invoke(); + dynamic content = await scenario.InvokeAsync().ConfigureAwait(false); string response = JsonSerializer.Serialize(content, settings); this.OkResponse(context, response); @@ -278,19 +286,24 @@ private void WriteOutput(HttpListenerResponse response, HttpStatusCode status, s /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// - public void Dispose() + public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); try { + this.cts.Cancel(false); + this.server?.Stop(); + await this.serverTask; this.server?.Close(); } catch { // ignore - we're shutting down anyway } + + this.config.WriteLine("Messaging provider disposed"); } } } diff --git a/src/PactNet/Verifier/PactVerifier.cs b/src/PactNet/Verifier/PactVerifier.cs index 09d7a0c5..32ad0705 100644 --- a/src/PactNet/Verifier/PactVerifier.cs +++ b/src/PactNet/Verifier/PactVerifier.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text.Json; +using System.Threading.Tasks; using PactNet.Internal; using PactNet.Verifier.Messaging; @@ -9,7 +10,7 @@ namespace PactNet.Verifier /// /// Pact verifier /// - public class PactVerifier : IPactVerifier, IDisposable + public class PactVerifier : IPactVerifier, IAsyncDisposable { private const string VerifierNotInitialised = $"You must add at least one verifier transport by calling {nameof(WithHttpEndpoint)} and/or {nameof(WithMessages)}"; @@ -213,12 +214,21 @@ public IPactVerifierSource WithPactBrokerSource(Uri brokerBaseUri, Action - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Performs application-defined tasks associated with freeing, releasing, or + /// resetting unmanaged resources asynchronously. /// - public void Dispose() + public async ValueTask DisposeAsync() { - this.messagingProvider?.Dispose(); - this.provider?.Dispose(); + if (this.provider is IAsyncDisposable providerAsyncDisposable) + { + await providerAsyncDisposable.DisposeAsync(); + } + else + { + this.provider.Dispose(); + } + + await this.messagingProvider.DisposeAsync(); } } } diff --git a/tests/PactNet.Abstractions.Tests/PactNet.Abstractions.Tests.csproj b/tests/PactNet.Abstractions.Tests/PactNet.Abstractions.Tests.csproj index 33213fec..90df8362 100644 --- a/tests/PactNet.Abstractions.Tests/PactNet.Abstractions.Tests.csproj +++ b/tests/PactNet.Abstractions.Tests/PactNet.Abstractions.Tests.csproj @@ -13,9 +13,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/PactNet.Abstractions.Tests/Verifier/Messaging/ScenarioTests.cs b/tests/PactNet.Abstractions.Tests/Verifier/Messaging/ScenarioTests.cs index faf9ceb0..4684e07d 100644 --- a/tests/PactNet.Abstractions.Tests/Verifier/Messaging/ScenarioTests.cs +++ b/tests/PactNet.Abstractions.Tests/Verifier/Messaging/ScenarioTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using FluentAssertions; using PactNet.Verifier.Messaging; using Xunit; @@ -8,12 +9,12 @@ namespace PactNet.Abstractions.Tests.Verifier.Messaging public class ScenarioTests { [Fact] - public void InvokeScenario_Should_Invoke_Scenario_And_Return_Object() + public async Task InvokeScenario_Should_Invoke_Scenario_And_Return_Object() { object expected = new { field = "value" }; var scenario = new Scenario("a scenario", () => expected); - object actual = scenario.Invoke(); + object actual = await scenario.InvokeAsync(); actual.Should().BeEquivalentTo(expected); } @@ -23,7 +24,7 @@ public void Should_Be_Able_To_Get_Description_And_Metadata() { object expectedMetadata = new { key = "vvv" }; var expectedDescription = "a scenario"; - var scenario = new Scenario(expectedDescription, () => string.Empty, expectedMetadata, null); + var scenario = new Scenario(expectedDescription, () => (dynamic)string.Empty, expectedMetadata, null); Assert.Equal(expectedMetadata, scenario.Metadata); Assert.Equal(expectedDescription, scenario.Description); @@ -35,7 +36,7 @@ public void Should_Be_Able_To_Get_Description_And_Metadata() [InlineData(" ")] public void Ctor_Should_Fail_If_Invalid_Description(string description) { - object expected = new { field = "value" }; + dynamic expected = new { field = "value" }; object expectedMetadata = new { key = "vvv" }; Action actual = () => new Scenario(description, () => expected, expectedMetadata, null); diff --git a/tests/PactNet.Tests/PactNet.Tests.csproj b/tests/PactNet.Tests/PactNet.Tests.csproj index 98f360d6..447e72da 100644 --- a/tests/PactNet.Tests/PactNet.Tests.csproj +++ b/tests/PactNet.Tests/PactNet.Tests.csproj @@ -25,10 +25,10 @@ - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/PactNet.Tests/Verifier/Messaging/MessageScenarioBuilderTests.cs b/tests/PactNet.Tests/Verifier/Messaging/MessageScenarioBuilderTests.cs index c7969d35..7ef8fc20 100644 --- a/tests/PactNet.Tests/Verifier/Messaging/MessageScenarioBuilderTests.cs +++ b/tests/PactNet.Tests/Verifier/Messaging/MessageScenarioBuilderTests.cs @@ -42,12 +42,12 @@ public void WithMetadata_NullMetadata_ThrowsArgumentNullException() } [Fact] - public void WithContent_WhenCalled_SetsContent() + public async Task WithContent_WhenCalled_SetsContent() { object expected = new { Foo = 42 }; this.builder.WithContent(() => expected); - object actual = this.builder.Build().Invoke(); + object actual = await this.builder.Build().InvokeAsync(); actual.Should().Be(expected); } @@ -64,12 +64,12 @@ public void WithContent_WithCustomSettings_SetsSettings() } [Fact] - public void WithAsyncContent_WhenCalled_SetsContent() + public async Task WithAsyncContent_WhenCalled_SetsContent() { dynamic expected = new { Foo = 42 }; this.builder.WithAsyncContent(() => Task.FromResult(expected)); - object actual = this.builder.Build().Invoke(); + object actual = await this.builder.Build().InvokeAsync(); actual.Should().Be(expected); } diff --git a/tests/PactNet.Tests/Verifier/Messaging/MessageScenariosTests.cs b/tests/PactNet.Tests/Verifier/Messaging/MessageScenariosTests.cs index 3aafa298..a4229d2f 100644 --- a/tests/PactNet.Tests/Verifier/Messaging/MessageScenariosTests.cs +++ b/tests/PactNet.Tests/Verifier/Messaging/MessageScenariosTests.cs @@ -20,7 +20,7 @@ public MessageScenariosTests() [Fact] public void Add_SimpleScenario_AddsScenarioWithJsonMetadata() { - Func factory = () => new { Foo = 42 }; + Func> factory = () => Task.FromResult((dynamic)new { Foo = 42 }); this.scenarios.Add("description", factory); diff --git a/tests/PactNet.Tests/Verifier/Messaging/MessagingProviderTests.cs b/tests/PactNet.Tests/Verifier/Messaging/MessagingProviderTests.cs index db41fdd4..291bbf33 100644 --- a/tests/PactNet.Tests/Verifier/Messaging/MessagingProviderTests.cs +++ b/tests/PactNet.Tests/Verifier/Messaging/MessagingProviderTests.cs @@ -16,7 +16,7 @@ namespace PactNet.Tests.Verifier.Messaging { - public class MessagingProviderTests : IDisposable + public class MessagingProviderTests : IAsyncDisposable { private static readonly JsonSerializerOptions Settings = new JsonSerializerOptions { @@ -46,9 +46,9 @@ public MessagingProviderTests(ITestOutputHelper output) this.client = new HttpClient { BaseAddress = uri }; } - public void Dispose() + public ValueTask DisposeAsync() { - this.provider.Dispose(); + return this.provider.DisposeAsync(); } [Fact]